Skip to content
Merged
2 changes: 1 addition & 1 deletion autodoc/AGENT_INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ It is the Taskdeck equivalent of the harness `AGENT_MAP.md` (grandfathered name)
| --- | --- | --- | --- |
| Optional attention | `Application/Services/WorkspaceAttentionService.cs`; `Domain/Entities/WorkspaceAttention.cs`; `Infrastructure/Repositories/WorkspaceAttentionRepository.cs`; API `WorkspaceAttentionController`; frontend `WorkspaceAttentionSettings.vue` / `WorkspaceAttentionReminder.vue` | Default off; existing revalidated questions only; shared UTC count/spacing via conditional revision update; no model or analysis; board/identity/input/dialog/Focus/Zen/visibility guards. [Policy](../docs/product/WORKSPACE_ATTENTION.md) | `WorkspaceAttentionTests`, `WorkspaceAttentionApiTests`, `WorkspaceAttention.spec.ts`; Chromium `workspace-attention.spec.ts` |
| Explicit model questions | `Application/Services/WorkspaceObservationService.cs` and `WorkspaceObservationContract.cs`; `Infrastructure/Repositories/WorkspaceObservationReader.cs`; API model setup registers the provider-dependent service; frontend `components/workspace/GroundedObservationsPanel.vue` | One disclosed card excerpt, active membership/ownership, before/after fingerprint checks, shared Chat quota/kill switch, strict quoted output, private `model-question-*` queue, one-day expiry and preserved user decisions. No board write or automatic attention; not the Context Fabric semantic-candidate lifecycle. [Contract/corpus](../docs/product/GROUNDED_OBSERVATIONS.md) | `WorkspaceObservationTests`, `WorkspaceObservationApiTests`, `WorkspaceInsightApiTests`, `McpStdioTransportTests`; frontend `GroundedObservationsPanel.spec.ts`, `QuietInsightsView.spec.ts`; Chromium `grounded-observations.spec.ts` |
| Private audio answers | `Api/Controllers/ThinkingAudioController.cs` → `Application/Services/ThinkingAudioService.cs`; `CaptureIntakeService.StageAudioAnswerAsync` is the canonical capture writer; `Infrastructure/Storage/SqliteBlobStore.cs`, `Repositories/EfManualRepresentationStore.cs`, `Repositories/SourcePortabilityStore.cs`; frontend `components/thinking/ThinkingAudioAnswer.vue` + `AudioAnswerRecorder.vue`; owner library `components/workspace/OriginalAudioLibrary.vue` from Memory, API `library` list/detail/original | Audio alone is untranscribed/unanswered; explicit human writing and confirmation are separate immutable representations. Owner-scoped bytes and current board access; read-only library also permits archived boards with current permission and owner-linked deleted-board originals; upload ID/hash retry; question/revision conflicts; quotas before bounded reads; caller transaction; account erasure deletes representation dependants before source assets. No queued/remote transcription. Legacy representation backfill is separate. Avoid generated migration designers except for schema work; product/status sync: `docs/product/WORKSPACE_OVERHAUL.md` + validation ledger and STATUS/MASTERPLAN | API `ThinkingAudioApiTests`, `SqliteBlobStoreTests`, `ManualRepresentationStoreTests`, `DataPortabilityApiTests`; frontend `ThinkingAudioAnswer.spec.ts`, `AudioAnswerRecorder.spec.ts`, `OriginalAudioLibrary.spec.ts`, `ThinkingQuestionAnswer.spec.ts`, `ThinkingWorkspaceContinuity.spec.ts`; Chromium `tests/e2e/thinking-audio.spec.ts` with an isolated `TASKDECK_E2E_DB`; full backend/frontend per CLAUDE before PR |
| Private audio answers | `Api/Controllers/ThinkingAudioController.cs` → `Application/Services/ThinkingAudioService.cs`; `CaptureIntakeService.StageAudioAnswerAsync` is the canonical capture writer; `Infrastructure/Storage/SqliteBlobStore.cs`, `Repositories/EfManualRepresentationStore.cs`, `Repositories/SourcePortabilityStore.cs`; frontend `components/thinking/ThinkingAudioAnswer.vue` + `AudioAnswerRecorder.vue`; owner library `components/workspace/OriginalAudioLibrary.vue` from Memory, API `library` list/detail/original | Audio alone is untranscribed/unanswered; explicit human writing and confirmation are separate immutable representations. Owner-scoped bytes and current board access; retained library also permits archived boards with current permission and owner-linked deleted-board originals; upload ID/hash retry; question/revision conflicts; quotas before bounded reads; caller transaction; account erasure deletes representation dependants before source assets. No automatic send on upload. Explicit speech uses `AudioTranscriptionController` / `AudioTranscriptionService` / `AudioTranscriptionStore` / `HttpAudioTranscriptionProvider` and `AudioTranscriptionPanel.vue`; see `docs/product/AUDIO_TRANSCRIPTION.md` for consent, attempt/byte limits, protected egress, provisional provenance and export/erasure. Generic processor supervision remains separate. Legacy representation backfill is separate. Avoid generated migration designers except for schema work; product/status sync: `docs/product/WORKSPACE_OVERHAUL.md` + validation ledger and STATUS/MASTERPLAN | API `AudioTranscriptionApiTests`, `SpeechTranscriptionTransportTests`, `ThinkingAudioApiTests`, `SqliteBlobStoreTests`, `ManualRepresentationStoreTests`, `DataPortabilityApiTests`; frontend `AudioTranscriptionPanel.spec.ts`, `ThinkingAudioAnswer.spec.ts`, `AudioAnswerRecorder.spec.ts`, `OriginalAudioLibrary.spec.ts`, `ThinkingQuestionAnswer.spec.ts`, `ThinkingWorkspaceContinuity.spec.ts`; Chromium `tests/e2e/thinking-audio.spec.ts` with an isolated `TASKDECK_E2E_DB`; full backend/frontend per CLAUDE before PR |
| Capture → review → board | `Api/Controllers/CaptureController.cs`, `AutomationProposalsController.cs`; `Application/Services/LlmCaptureTriageExtractor.cs` and `TranscriptTriageChunking.cs`; `views/paper/PaperInboxView.vue` + `views/paper/inbox/*` and `views/paper/PaperReviewView.vue` + `views/paper/review/*` (Paper is the default skin, ADR-0038, `store/paperThemeStore.ts`); `views/InboxView.vue` and `views/ReviewView.vue` are 12-line theme switches, not the implementation, and `LegacyInboxView.vue` / `LegacyReviewView.vue` are the opt-out fallbacks; `composables/useReviewProposals.ts` | **Preview == Apply** (#1235: diff + executor both materialize the latest `ProposalRevision`); approve & execute are two explicit calls; execute needs an Idempotency-Key; a missing/mismatched proposal deep link never substitutes another actionable proposal; Paper post-decision receipts are board-scoped, terminal receipts are keymap-nonactionable, and only Approved offers explicit Apply; provenance server-stamped, client identity fields rejected; transcript triage uses the LLM-backed extractor only after kill-switch/provider-health/quota gates and otherwise records deterministic fallback honestly. Under-budget transcripts stay one provider call; only a budget-forced split maps chunks, and any failed map leg falls back for the complete capture. Provider-call progress renews the transcript-worker heartbeat, so readiness permits one selected-provider timeout but not an entire stuck map-reduce batch. | `TranscriptTriageChunkingTests`, `LlmCaptureTriageExtractorTests`, `TranscriptTriageLlmGoldenPathIntegrationTests`, `HealthApiTests`; capture/review unit + `CaptureApiTests`, `ProposalRevisionApiTests`; E2E `capture-loop.spec.ts` |
| Artefact intake and local extraction | `backend/src/Taskdeck.Api/Controllers/ArtefactsController.cs`, `backend/src/Taskdeck.Application/Interfaces/IArtefactTextExtractor.cs`, `backend/src/Taskdeck.Application/Services/IArtefactExtractionService.cs` | `SourceArtefact` is the immutable user-owned source; extraction appends bounded, warning-bearing history and never mutates task state | `dotnet test backend/Taskdeck.sln -c Release -m:1 --filter "FullyQualifiedName~ArtefactExtraction"`; `MigrationBootstrapTests` |
| Context Fabric (durable capture, processing contracts) — ADR-0065 | `backend/src/Taskdeck.Domain/Entities/Capture.cs`, `Entities/SourceAsset.cs`, `Entities/CaptureBackfillState.cs`, `Domain/Enums/CaptureTimeline.cs`, `Domain/Enums/CaptureSourceMapping.cs`, `Domain/Enums/CaptureLegacyStateMapping.cs`, `Domain/Processing/ProcessingCapability.cs`, `Application/Services/CaptureIntakeService.cs`, `Application/Services/CaptureBackfillService.cs`, `Application/Interfaces/ICaptureStore.cs`, `Application/Interfaces/ICaptureBackfillStore.cs` (+ `IRepresentationStore`; owner-scoped manual implementation through `IManualRepresentationStore`/`EfManualRepresentationStore`, and `IBlobStore` through `SqliteBlobStore`), `Application/Processing/ProcessorManifest*.cs`, `Application/Processing/Protocol/WorkerProtocol.cs`, `Infrastructure/Repositories/EfCaptureStore.cs`, `EfCaptureBackfillStore.cs`, `Infrastructure/Persistence/ContextFabricBootstrap.cs`; map `docs/architecture/CONTEXT_FABRIC.md`, protocol `docs/architecture/WORKER_PROTOCOL_V1.md`; **v0.5/v0.6 head-start material (planning input, not authority)** `docs/analysis/2026-08-30-acceleration-bundles-v0.5-v0.6/` — one corrected file per v0.6 issue, `v0.5/CANDIDATE_MAP.md`, contract-draft schemas/fixtures under `contracts.manifest.json`, reference candidates; read it when a CF issue is admitted | A capture is valid the moment its source is stored and never becomes unreadable through job failure; `Capture.Id = LlmRequest.Id` (ID-preserving); capture state is three orthogonal axes (user disposition · processing summary · action state) and the timeline is a projection, never the only persisted truth; typed/pasted text is an immutable `SourceAsset`, never job state, and a post-intake edit appends a SUPERSEDING asset rather than rewriting one; every capture creation path writes through `CaptureIntakeService` (architecture-test enforced — nothing else constructs a `Capture` or writes the `Captures` set); processors declare capabilities from `ProcessingCapability` and hold no mutation tools, and sidecars and remote processors may declare only externalizable capabilities (no `context.resolve` / `change.plan` / `change.verify`); Worker Protocol is v1-alpha until PdfPig and WhisperX both pass conformance; `IBlobStore` deletes only on the last released reference; `IRepresentationStore` retains nullable `CaptureId` for the legacy migration window; manual transcript reads/writes are implemented, while general representation backfill and processor orchestration remain separate; **since CF-01 `#2255` `ContextFabric:DualWriteCaptures`, `BackfillCaptures` and `ReadCapturesFromStore` all default ON** — the startup pass (`ContextFabricBootstrap`, idempotent **divergence** join, marker in `CaptureBackfillStates`) both fills the aggregate and reconciles any capture whose queue row moved past it, and Inbox list/get read a capture's own material (source text, source snapshot, intake time) through `ICaptureStore` while job state stays on the queue row; the read switch has three guards — the marker, a per-item fallback, and a divergence check that prefers whichever writer moved last — so a capture can neither disappear from the Inbox nor go backwards in it; a listing reads the narrow `GetListMaterialForUserAsync` projection, never whole aggregates; no new `CaptureSource` values and no new request-type lane predicates — dimensions come from `CaptureSourceMapping`; router v1 = constraints + ordered preference + route receipt, no scoring before the CF-24A `#2319` corpus | `CaptureTests`, `SourceAssetTests`, `CaptureTimelineTests`, `CaptureSourceMappingTests`, `CaptureSourceSupersessionTests`, `CaptureLegacyStateMappingTests`, `ProcessingCapabilityTests` (Domain); `ProcessorManifestValidatorTests`, `WorkerProtocolSerializationTests`, `CaptureServiceDualWriteTests`, `LlmQueueServiceDualWriteTests`, `CaptureBackfillServiceTests`, `CaptureServiceReadSwitchTests`, `CaptureIntakeSourceTests` (Application); `ContextFabricCaptureBackfillTests` (Api — golden path over a seeded legacy queue), `MigrationBootstrapTests` (tables present + store round-trips); `CaptureIntakeSingleWriterTests` (Architecture); `py -3 -B scripts/context_fabric/check_contract_drafts.py` (contract drafts); tracker CF-00 `#2254` |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Taskdeck.Api.Extensions;
using Taskdeck.Application.DTOs;
using Taskdeck.Application.Interfaces;
using Taskdeck.Application.Services;

namespace Taskdeck.Api.Controllers;

[ApiController]
[Authorize]
[Route("api/thinking-audio/{id:guid}/transcriptions")]
public class AudioTranscriptionController : AuthenticatedControllerBase
{
private readonly AudioTranscriptionService service;
public AudioTranscriptionController(AudioTranscriptionService service, IUserContext userContext) : base(userContext)
{ this.service = service; }

[HttpGet]
public async Task<IActionResult> Status(Guid id, CancellationToken ct)
{
if (!TryGetCurrentUserId(out var userId, out var error)) return error!;
Response.Headers.CacheControl = "no-store";
var result = await service.StatusAsync(userId, id, ct);
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
}

[HttpPost]
public async Task<IActionResult> Start(Guid id, AudioTranscriptionRequestDto request, CancellationToken ct)
{
if (!TryGetCurrentUserId(out var userId, out var error)) return error!;
Response.Headers.CacheControl = "no-store";
var result = await service.StartAsync(userId, id, request, ct);
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ internal sealed class ProtectedOutboundSentryHttpMessageHandlerFilter : IHttpMes
nameof(OpenAiLlmProvider),
LlmProviderRegistration.OpenAiCompatibleHttpClientName,
nameof(OllamaLlmProvider),
nameof(HttpAudioTranscriptionProvider),
"OutboundWebhookDelivery"
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using Taskdeck.Api.Workers;
using Taskdeck.Application.Interfaces;
using Taskdeck.Application.Services;

namespace Taskdeck.Api.Extensions;

public static class SpeechTranscriptionRegistration
{
public static IServiceCollection AddSpeechTranscription(this IServiceCollection services, IConfiguration configuration, string environment)
{
var settings = configuration.GetSection("SpeechTranscription").Get<SpeechTranscriptionSettings>() ?? new();
var policy = settings.Validate(environment);
// AddLlmProviders owns these application-wide instances; reuse the same disclosure registry and tracker.
var registry = services.Last(x => x.ServiceType == typeof(IEgressRegistry)).ImplementationInstance as IEgressRegistry
?? throw new InvalidOperationException("Register the shared provider egress registry before speech transcription.");
if (policy.Enabled) registry.Register(new(new Uri(policy.Origin).Host,
"Explicitly selected original recording for provisional transcription", nameof(HttpAudioTranscriptionProvider), EgressDataClassification.UserContent));
var tracker = (CircuitBreakerStateTracker)services.Last(x => x.ServiceType == typeof(CircuitBreakerStateTracker)).ImplementationInstance!;
var circuitSettings = (CircuitBreakerSettings)services.Last(x => x.ServiceType == typeof(CircuitBreakerSettings)).ImplementationInstance!;
var circuit = LlmProviderRegistration.BuildCircuitBreakerPolicy(tracker, "SpeechTranscription", circuitSettings);
services.AddSingleton(settings);
services.AddSingleton(policy);
services.AddScoped<AudioTranscriptionService>();
services.AddHttpClient<HttpAudioTranscriptionProvider>(client => client.Timeout = Timeout.InfiniteTimeSpan)
.ConfigurePrimaryHttpMessageHandler(sp => new SocketsHttpHandler
{
AllowAutoRedirect = false, UseProxy = false, ActivityHeadersPropagator = null,
MeterFactory = sp.GetRequiredService<ProtectedOutboundMeterFactory>(),
ConnectCallback = (context, ct) => OutboundWebhookConnectCallback.ConnectAsync(context,
environment == "Development" && settings.AllowLocalhostInDevelopment, ct)
})
.RemoveAllLoggers()
.AddHttpMessageHandler(() => new SpeechCircuitFailureHandler())
.AddPolicyHandler(circuit)
.AddHttpMessageHandler<ProtectedOutboundTelemetryHandler>()
.AddHttpMessageHandler(sp => new EgressEnvelopeHandler(sp.GetRequiredService<IEgressRegistry>(),
sp.GetRequiredService<ILogger<EgressEnvelopeHandler>>(), nameof(HttpAudioTranscriptionProvider), followRedirects: false));
services.AddScoped<IAudioTranscriptionProvider>(sp => sp.GetRequiredService<HttpAudioTranscriptionProvider>());
return services;
}

private sealed class SpeechCircuitFailureHandler : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
try { return await base.SendAsync(request, cancellationToken); }
catch (Polly.CircuitBreaker.BrokenCircuitException)
{ return new HttpResponseMessage(System.Net.HttpStatusCode.ServiceUnavailable); }
}
}
}
1 change: 1 addition & 0 deletions backend/src/Taskdeck.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,7 @@

// Add LLM providers (quota, kill switch, OpenAI/compatible/Ollama/Mock selection)
builder.Services.AddLlmProviders(builder.Configuration);
builder.Services.AddSpeechTranscription(builder.Configuration, builder.Environment.EnvironmentName);

// Add IUserContext for claim-based identity
builder.Services.AddHttpContextAccessor();
Expand Down
17 changes: 17 additions & 0 deletions backend/src/Taskdeck.Application/DTOs/AudioTranscriptionDtos.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Taskdeck.Application.Services;

namespace Taskdeck.Application.DTOs;

public sealed record AudioTranscriptionRequestDto(Guid RequestId, long ExpectedRevision, string ConfigurationHash);
public sealed record AudioTranscriptionReceiptDto(Guid Id, Guid RequestId, Guid AudioAnswerId, string State,
string Provider, string Model, string ConfigurationHash, DateTimeOffset StartedAt, DateTimeOffset Deadline,
DateTimeOffset? FinishedAt, string? FailureCode, Guid? RepresentationId, string? Text);
public sealed record AudioTranscriptionStatusDto(SpeechTranscriptionConfiguration Configuration,
int AttemptsUsedToday, long InputBytesUsedToday, IReadOnlyList<AudioTranscriptionReceiptDto> Attempts);
public sealed record AudioTranscriptionSource(Guid ReferenceId, long ByteSize, string ContentHash, string MediaType);
public sealed record AudioTranscriptionAdmission(AudioTranscriptionReceiptDto Receipt, AudioTranscriptionSource? Source);
public sealed record AudioTranscriptionAttemptExportDto(Guid Id, Guid UserId, Guid AudioAnswerId, Guid CaptureId,
Guid SourceAssetId, Guid RequestId, string RequestHash, string ConfigurationHash, string Provider, string Model,
DateTimeOffset StartedAt, DateTimeOffset Deadline, DateTimeOffset? FinishedAt, string State, string? FailureCode,
Guid? RepresentationId, long Revision);
public sealed record AudioTranscriptionBudgetExportDto(Guid UserId, int UtcDay, int Attempts, long InputBytes, long Revision);
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ public sealed record SourceStorageExportDto(
IReadOnlyList<SourceBlobReferenceExportDto> References,
IReadOnlyList<SourceBlobChunkExportDto> Chunks,
IReadOnlyList<RepresentationDescriptor> Representations,
IReadOnlyList<ThinkingAudioExportDto> AudioAnswers);
IReadOnlyList<ThinkingAudioExportDto> AudioAnswers,
IReadOnlyList<AudioTranscriptionAttemptExportDto>? AudioTranscriptionAttempts = null,
IReadOnlyList<AudioTranscriptionBudgetExportDto>? AudioTranscriptionBudgets = null);
public sealed record SourceBlobObjectExportDto(Guid Id, string ContentHash, long ByteSize);
public sealed record SourceBlobReferenceExportDto(Guid Id, Guid BlobId, string Modality, string? ReferrerKind, Guid? ReferrerId, DateTimeOffset AcquiredAt);
/// <summary>Concatenate decoded chunks by ordinal, then verify the object's byte size and SHA-256.</summary>
Expand Down
Loading
Loading