Skip to content
132 changes: 127 additions & 5 deletions backend/src/Taskdeck.Application/DTOs/CaptureTriageContracts.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Taskdeck.Domain.Common;
Expand Down Expand Up @@ -26,11 +28,19 @@ public static class CaptureTriageOutputContract
/// </summary>
public const string PromptVersionLlmV1 = "llm-triage.v1";

/// <summary>
/// Prompt version for collision-resistant untrusted-data framing, exact raw-JSON containment,
/// and ordinal evidence grounding (#1323). Historical llm-v1 envelopes remain readable.
/// Schema file: capture-triage-output.llm-v2.schema.json.
/// </summary>
public const string PromptVersionLlmV2 = "llm-triage.v2";

public const int MaxTasks = 20;
public const int MaxTaskTitleLength = 180;
public const int MaxTaskEvidenceLength = 280;

private static readonly string[] KnownPromptVersions = [PromptVersionV1, PromptVersionLlmV1];
private static readonly string[] KnownPromptVersions =
[PromptVersionV1, PromptVersionLlmV1, PromptVersionLlmV2];

private static readonly JsonSerializerOptions JsonOptions = new()
{
Expand Down Expand Up @@ -104,35 +114,53 @@ public static Result<CaptureTriageOutputV1> Validate(CaptureTriageOutputV1 outpu
{
var task = output.Tasks[i];
var index = i + 1;
var usesLlmV2Contract = output.PromptVersion == PromptVersionLlmV2;
if (task is null)
{
return Result.Failure<CaptureTriageOutputV1>(
ErrorCodes.ValidationError,
$"Capture triage task {index} cannot be null");
}

if (string.IsNullOrWhiteSpace(task.Title))
if (usesLlmV2Contract
? IsNullOrEcmaWhitespace(task.Title)
: string.IsNullOrWhiteSpace(task.Title))
{
return Result.Failure<CaptureTriageOutputV1>(
ErrorCodes.ValidationError,
$"Capture triage task {index} title cannot be empty");
}

if (task.Title.Length > MaxTaskTitleLength)
var titleLength = usesLlmV2Contract
? GetUnicodeScalarLength(task.Title)
: task.Title.Length;
if (titleLength > MaxTaskTitleLength)
{
return Result.Failure<CaptureTriageOutputV1>(
ErrorCodes.ValidationError,
$"Capture triage task {index} title cannot exceed {MaxTaskTitleLength} characters");
}

if (string.IsNullOrWhiteSpace(task.Evidence))
if (usesLlmV2Contract && !IsSafeTaskTitle(task.Title))
{
return Result.Failure<CaptureTriageOutputV1>(
ErrorCodes.ValidationError,
$"Capture triage task {index} title contains unsafe whitespace, control, or bidi characters");
}

if (usesLlmV2Contract
? IsNullOrEcmaWhitespace(task.Evidence)
: string.IsNullOrWhiteSpace(task.Evidence))
{
return Result.Failure<CaptureTriageOutputV1>(
ErrorCodes.ValidationError,
$"Capture triage task {index} evidence cannot be empty");
}

if (task.Evidence.Length > MaxTaskEvidenceLength)
var evidenceLength = usesLlmV2Contract
? GetUnicodeScalarLength(task.Evidence)
: task.Evidence.Length;
if (evidenceLength > MaxTaskEvidenceLength)
{
return Result.Failure<CaptureTriageOutputV1>(
ErrorCodes.ValidationError,
Expand All @@ -143,6 +171,100 @@ public static Result<CaptureTriageOutputV1> Validate(CaptureTriageOutputV1 outpu
return Result.Success(output);
}

internal static bool IsSafeTaskTitle(string title)
{
if (string.IsNullOrEmpty(title) ||
IsEcmaWhitespace(title[0]) ||
IsEcmaWhitespace(title[^1]))
{
return false;
}

foreach (var character in title)
{
if (IsUnsafeTaskTitleCharacter(character))
{
return false;
}
}

return true;
}

internal static string SanitizeTaskTitle(string title)
{
if (string.IsNullOrEmpty(title))
{
return string.Empty;
}

var sanitized = new string(title
.Select(character => IsUnsafeTaskTitleCharacter(character) ? ' ' : character)
.ToArray());
return string.Join(
" ",
sanitized.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
}

internal static bool IsNullOrEcmaWhitespace([NotNullWhen(false)] string? value)
{
return string.IsNullOrEmpty(value) || value.EnumerateRunes().All(IsEcmaWhitespace);
}

internal static int GetUnicodeScalarLength(string value)
{
return value.EnumerateRunes().Count();
}

internal static string TruncateToUtf16LengthAtScalarBoundary(string value, int maxUtf16Length)
{
if (maxUtf16Length <= 0 || string.IsNullOrEmpty(value))
{
return string.Empty;
}

if (value.Length <= maxUtf16Length)
{
return value;
}

var utf16Length = 0;
foreach (var rune in value.EnumerateRunes())
{
if (utf16Length + rune.Utf16SequenceLength > maxUtf16Length)
{
break;
}

utf16Length += rune.Utf16SequenceLength;
}

return value[..utf16Length];
}

private static bool IsEcmaWhitespace(Rune rune)
{
return IsEcmaWhitespace(rune.Value);
}

private static bool IsEcmaWhitespace(int codePoint)
{
return codePoint is >= 0x0009 and <= 0x000D or
0x0020 or 0x00A0 or 0x1680 or
>= 0x2000 and <= 0x200A or
0x2028 or 0x2029 or 0x202F or 0x205F or 0x3000 or 0xFEFF;
}

private static bool IsUnsafeTaskTitleCharacter(char character)
{
return char.IsControl(character) ||
character == '\uFEFF' ||
character == '\u2028' || character == '\u2029' ||
character == '\u061C' || character == '\u200E' || character == '\u200F' ||
(character >= '\u202A' && character <= '\u202E') ||
(character >= '\u2066' && character <= '\u2069');
}

public static string Serialize(CaptureTriageOutputV1 output)
{
var validation = Validate(output);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://taskdeck.dev/schemas/capture-triage-output.llm-v2.schema.json",
"title": "Taskdeck Capture Triage Output llm-v2",
"description": "Server-authored output of the strict LLM capture-triage path. The model response is limited to the tasks member; Taskdeck adds this versioned envelope after raw-JSON containment and ordinal source-evidence grounding.",
"type": "object",
"additionalProperties": false,
"required": [
"version",
"promptVersion",
"tasks"
],
"properties": {
"version": {
"type": "integer",
"const": 1
},
"promptVersion": {
"type": "string",
"const": "llm-triage.v2"
},
"tasks": {
"type": "array",
"minItems": 1,
"maxItems": 20,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"title",
"evidence"
],
"properties": {
"title": {
"type": "string",
"minLength": 1,
"maxLength": 180,
Comment thread
Chris0Jeky marked this conversation as resolved.
"pattern": "^(?!\\s)(?!.*\\s$)(?!.*[\\u0000-\\u001F\\u007F-\\u009F\\u061C\\u200E\\u200F\\u2028\\u2029\\u202A-\\u202E\\u2066-\\u2069\\uFEFF]).+$"
},
"evidence": {
"type": "string",
"minLength": 1,
"pattern": "[^\\s\\uFEFF]",
"maxLength": 280
}
}
}
}
}
}
27 changes: 20 additions & 7 deletions backend/src/Taskdeck.Application/Services/CaptureTriageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,15 @@ public class CaptureTriageService : ICaptureTriageService
public const string UnknownProvenanceValue = "unknown";

private static readonly Regex ChecklistPattern = new(
@"^\s*[-*]\s+\[[xX ]\]\s+(.+?)\s*$",
@"^\s*[-*]\s+\[ \]\s+(.+?)\s*$",
RegexOptions.Compiled);

private static readonly Regex BulletPattern = new(
@"^\s*[-*\u2022]\s+(.+?)\s*$",
@"^\s*[-*\u2022]\s+(?!\[[xX]\](?:\s|$))(.+?)\s*$",
RegexOptions.Compiled);

private static readonly Regex CompletedChecklistPattern = new(
@"^\s*[-*]\s+\[[xX]\](?:\s+.*)?\s*$",
RegexOptions.Compiled);

private static readonly Regex NumberedPattern = new(
Expand Down Expand Up @@ -184,7 +188,8 @@ public async Task<Result<CaptureTriageProposalResultDto>> CreateProposalFromCapt
Guid.NewGuid(),
ProposalId: null,
OperationCount: 0,
CaptureTriageOutputContract.PromptVersionLlmV1,
CaptureRequestContract.SanitizeProvenanceMetadata(
extraction.PromptVersion, CaptureRequestContract.MaxPromptVersionLength),
CaptureRequestContract.SanitizeProvenanceMetadata(
extraction.Provider, CaptureRequestContract.MaxProviderLength),
CaptureRequestContract.SanitizeProvenanceMetadata(
Expand Down Expand Up @@ -368,6 +373,11 @@ private static (List<string> Tasks, string? ContextHint) ExtractTaskCandidates(s
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Split('\n', StringSplitOptions.RemoveEmptyEntries);

// Completed checklist entries are evidence of finished work, not new task candidates. Keep
// them out of both the structured pass and the whole-text fallback so a model failure cannot
// silently turn an already-completed item back into an actionable proposal.
var fallbackText = string.Join('\n', lines.Where(line => !CompletedChecklistPattern.IsMatch(line)));

foreach (var line in lines)
{
var extracted = TryExtractStructuredTask(line);
Expand Down Expand Up @@ -395,7 +405,7 @@ private static (List<string> Tasks, string? ContextHint) ExtractTaskCandidates(s
}

// Try dash-separated: first segment is context hint, rest are tasks
var dashSegments = DashDelimiterPattern.Split(rawText)
var dashSegments = DashDelimiterPattern.Split(fallbackText)
.Select(s => s.Trim())
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToList();
Expand Down Expand Up @@ -423,7 +433,7 @@ private static (List<string> Tasks, string? ContextHint) ExtractTaskCandidates(s
}

// Try semicolons: all segments are equal tasks
var semicolonSegments = SemicolonDelimiterPattern.Split(rawText)
var semicolonSegments = SemicolonDelimiterPattern.Split(fallbackText)
.Select(s => s.Trim())
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToList();
Expand All @@ -450,7 +460,7 @@ private static (List<string> Tasks, string? ContextHint) ExtractTaskCandidates(s
}

// Single-sentence fallback: create one card with the full text
var fallback = NormalizeTaskTitle(rawText);
var fallback = NormalizeTaskTitle(fallbackText);
if (!string.IsNullOrWhiteSpace(fallback))
{
candidates.Add(fallback);
Expand Down Expand Up @@ -502,9 +512,12 @@ private static string NormalizeTaskTitle(string value)
}

var normalized = Regex.Replace(trimmed, @"\s+", " ").Trim();
normalized = CaptureTriageOutputContract.SanitizeTaskTitle(normalized);
if (normalized.Length > CaptureTriageOutputContract.MaxTaskTitleLength)
{
normalized = normalized[..CaptureTriageOutputContract.MaxTaskTitleLength].TrimEnd();
normalized = CaptureTriageOutputContract.TruncateToUtf16LengthAtScalarBoundary(
normalized,
CaptureTriageOutputContract.MaxTaskTitleLength).TrimEnd();
}

return normalized;
Expand Down
20 changes: 14 additions & 6 deletions backend/src/Taskdeck.Application/Services/ChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ public async Task<Result<ChatMessageDto>> SendMessageAsync(Guid sessionId, Guid
string? quotaBilledProvider = null;
string? quotaBilledModel = null;
var quotaBilledTokens = 0;
var quotaEstimatedTokens = 0;
try
{
if (string.IsNullOrWhiteSpace(dto.Content))
Expand Down Expand Up @@ -190,6 +191,7 @@ public async Task<Result<ChatMessageDto>> SendMessageAsync(Guid sessionId, Guid
if (!reservation.Allowed)
return Result.Failure<ChatMessageDto>(ErrorCodes.LlmQuotaExceeded, reservation.DeniedReason ?? "LLM quota exceeded");
quotaReservationId = reservation.ReservationId;
quotaEstimatedTokens = reservation.EstimatedTokens;
}

if (dto.RequestProposal && LooksLikeChecklistBootstrapRequest(dto.Content))
Expand Down Expand Up @@ -381,17 +383,22 @@ await _quotaService.CommitReservationAsync(
// reports a combined TokensUsed total without an input/output split. Record
// the full total as input tokens and 0 for output until providers surface
// separate counts.
if (_quotaService != null && quotaReservationId is Guid singleResId && llmResult.TokensUsed > 0)
var singleTurnQuotaTokens = llmResult.TokensUsed > 0
? llmResult.TokensUsed
: llmResult.ShouldCommitEstimatedUsage
? Math.Max(0, quotaEstimatedTokens)
: 0;
if (_quotaService != null && quotaReservationId is Guid singleResId && singleTurnQuotaTokens > 0)
{
// CancellationToken.None (M1, #1427 review): see the tool-result commit above.
quotaBilledProvider = llmResult.Provider;
quotaBilledModel = llmResult.Model;
quotaBilledTokens = llmResult.TokensUsed;
quotaBilledTokens = singleTurnQuotaTokens;
await _quotaService.CommitReservationAsync(
singleResId,
userId, Domain.Enums.LlmSurface.Chat,
llmResult.Provider, llmResult.Model,
llmResult.TokensUsed, 0,
singleTurnQuotaTokens, 0,
CancellationToken.None);
quotaCommitted = true;
}
Expand Down Expand Up @@ -531,9 +538,10 @@ await _quotaService.CommitReservationAsync(
{
// Settle any reservation that was never committed (#1427 review): if billed tokens exist
// (the in-try commit itself failed on a DB fault), COMMIT them — releasing would erase real
// usage; otherwise release (no-LLM paths, a zero-token call, a pre-billing exception) so the
// slot consumes no quota. CancellationToken.None so cleanup runs under a cancelled request
// token; try/catch so a settle failure cannot mask the original exception.
// usage, including a flagged unknown-usage timeout at the reservation estimate. Otherwise
// release (no-LLM paths, an unflagged zero-token call, a pre-billing exception) so the slot
// consumes no quota. CancellationToken.None lets cleanup run under a cancelled request
// token; try/catch keeps a settle failure from masking the original exception.
if (_quotaService != null && quotaReservationId is Guid rid && !quotaCommitted)
{
try
Expand Down
Loading
Loading