Skip to content

Commit eec9eff

Browse files
authored
Merge pull request #1039 from Chris0Jeky/paper/1019-provenance
Expose proposal provenance via API for Paper deep-Review
2 parents bb35128 + 319db1a commit eec9eff

24 files changed

Lines changed: 6015 additions & 27 deletions

backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public class AutomationProposalsController : AuthenticatedControllerBase
2929
private readonly IAutomationExecutorService _executorService;
3030
private readonly ISimilarDecisionService _similarDecisionService;
3131
private readonly BoardAuthorizationService _authorizationService;
32+
private readonly IProvenanceQueryService _provenanceQueryService;
3233
private readonly IConfidenceBreakdownService _confidenceBreakdownService;
3334
private readonly ICardHistoryService _cardHistoryService;
3435
private readonly ISideEffectAnalyzer _sideEffectAnalyzer;
@@ -38,6 +39,7 @@ public AutomationProposalsController(
3839
IAutomationExecutorService executorService,
3940
ISimilarDecisionService similarDecisionService,
4041
BoardAuthorizationService authorizationService,
42+
IProvenanceQueryService provenanceQueryService,
4143
IConfidenceBreakdownService confidenceBreakdownService,
4244
ICardHistoryService cardHistoryService,
4345
ISideEffectAnalyzer sideEffectAnalyzer,
@@ -47,6 +49,7 @@ public AutomationProposalsController(
4749
_executorService = executorService;
4850
_similarDecisionService = similarDecisionService;
4951
_authorizationService = authorizationService;
52+
_provenanceQueryService = provenanceQueryService;
5053
_confidenceBreakdownService = confidenceBreakdownService;
5154
_cardHistoryService = cardHistoryService;
5255
_sideEffectAnalyzer = sideEffectAnalyzer;
@@ -326,6 +329,24 @@ public async Task<IActionResult> GetProposalDiff(Guid id, CancellationToken canc
326329
return result.IsSuccess ? Ok(new { diff = result.Value }) : result.ToErrorActionResult();
327330
}
328331

332+
/// <summary>
333+
/// Gets the provenance rows for a proposal, describing the sources read,
334+
/// excluded, or inferred during proposal generation.
335+
/// </summary>
336+
[HttpGet("{id}/provenance")]
337+
public async Task<IActionResult> GetProposalProvenance(Guid id, CancellationToken cancellationToken = default)
338+
{
339+
if (!TryGetCurrentUserId(out var callerUserId, out var errorResult))
340+
return errorResult!;
341+
342+
var auth = await AuthorizeProposalAsync(id, callerUserId, requireWriteAccess: false, cancellationToken);
343+
if (auth.ErrorResult is not null)
344+
return auth.ErrorResult;
345+
346+
var result = await _provenanceQueryService.GetProvenanceRowsAsync(id, cancellationToken);
347+
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
348+
}
349+
329350
/// <summary>
330351
/// Gets the multi-component confidence breakdown for a proposal.
331352
/// </summary>

backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
4848
services.AddScoped<HistoryService>();
4949
services.AddScoped<IHistoryService>(sp => sp.GetRequiredService<HistoryService>());
5050
services.AddScoped<IAutomationProposalService, AutomationProposalService>();
51+
services.AddScoped<IProvenanceQueryService, ProvenanceQueryService>();
5152
services.AddScoped<IConfidenceBreakdownService, ConfidenceBreakdownService>();
5253
services.AddScoped<ICardHistoryService, CardHistoryService>();
5354
services.AddScoped<ISideEffectAnalyzer, SideEffectAnalyzer>();

backend/src/Taskdeck.Application/DTOs/AutomationProposalDtos.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ public record CreateProposalDto(
7777
Guid? BoardId = null,
7878
string? SourceReferenceId = null,
7979
int ExpiryMinutes = 1440,
80-
List<CreateProposalOperationDto>? Operations = null
80+
List<CreateProposalOperationDto>? Operations = null,
81+
string? ProvenanceModelId = null,
82+
int ProvenanceTotalTokens = 0
8183
);
8284

8385
public record CreateProposalOperationDto(
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace Taskdeck.Application.DTOs;
2+
3+
/// <summary>
4+
/// Represents a single provenance row for the Paper deep-Review surface.
5+
/// Each row describes a source that was read, excluded, or inferred during
6+
/// proposal generation.
7+
/// </summary>
8+
public record ProvenanceRowDto(
9+
string Icon,
10+
string Key,
11+
string Value,
12+
string Weight
13+
);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using Taskdeck.Domain.Entities;
2+
3+
namespace Taskdeck.Application.Interfaces;
4+
5+
public interface IProposalProvenanceRepository : IRepository<ProposalProvenance>
6+
{
7+
/// <summary>
8+
/// Returns the provenance chain for a given proposal, including its fields
9+
/// and evidence links, or null if no provenance exists for the proposal.
10+
/// </summary>
11+
Task<ProposalProvenance?> GetByProposalIdAsync(Guid proposalId, CancellationToken cancellationToken = default);
12+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using Taskdeck.Application.DTOs;
2+
using Taskdeck.Domain.Common;
3+
4+
namespace Taskdeck.Application.Interfaces;
5+
6+
/// <summary>
7+
/// Queries the provenance chain for a proposal and maps it to the
8+
/// Paper deep-Review DTO shape (icon, key, value, weight).
9+
/// </summary>
10+
public interface IProvenanceQueryService
11+
{
12+
/// <summary>
13+
/// Returns the provenance rows for the specified proposal.
14+
/// The caller's userId is validated for read access to the proposal's board.
15+
/// Returns an empty list (not an error) when the proposal has no provenance.
16+
/// </summary>
17+
Task<Result<IReadOnlyList<ProvenanceRowDto>>> GetProvenanceRowsAsync(
18+
Guid proposalId,
19+
CancellationToken cancellationToken = default);
20+
}

backend/src/Taskdeck.Application/Services/AutomationProposalService.cs

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Taskdeck.Application.Interfaces;
44
using Taskdeck.Domain.Common;
55
using Taskdeck.Domain.Entities;
6+
using Taskdeck.Domain.Enums;
67
using Taskdeck.Domain.Exceptions;
78

89
namespace Taskdeck.Application.Services;
@@ -35,13 +36,16 @@ public class AutomationProposalService : IAutomationProposalService
3536

3637
private readonly IUnitOfWork _unitOfWork;
3738
private readonly INotificationService _notificationService;
39+
private readonly IProposalProvenanceRepository? _provenanceRepository;
3840

3941
public AutomationProposalService(
4042
IUnitOfWork unitOfWork,
41-
INotificationService? notificationService = null)
43+
INotificationService? notificationService = null,
44+
IProposalProvenanceRepository? provenanceRepository = null)
4245
{
4346
_unitOfWork = unitOfWork;
4447
_notificationService = notificationService ?? NoOpNotificationService.Instance;
48+
_provenanceRepository = provenanceRepository;
4549
}
4650

4751
public async Task<Result<ProposalDto>> CreateProposalAsync(CreateProposalDto dto, CancellationToken cancellationToken = default)
@@ -79,6 +83,12 @@ public async Task<Result<ProposalDto>> CreateProposalAsync(CreateProposalDto dto
7983
}
8084
}
8185

86+
if (_provenanceRepository is not null)
87+
{
88+
var provenance = BuildCreationProvenance(proposal, dto);
89+
await _provenanceRepository.AddAsync(provenance, cancellationToken);
90+
}
91+
8292
await _unitOfWork.SaveChangesAsync(cancellationToken);
8393

8494
return Result.Success(MapToDto(proposal));
@@ -89,6 +99,61 @@ public async Task<Result<ProposalDto>> CreateProposalAsync(CreateProposalDto dto
8999
}
90100
}
91101

102+
private static ProposalProvenance BuildCreationProvenance(AutomationProposal proposal, CreateProposalDto dto)
103+
{
104+
var provenance = new ProposalProvenance(
105+
proposal.Id,
106+
proposal.CorrelationId,
107+
ResolveProvenanceModelId(dto),
108+
Math.Max(0, dto.ProvenanceTotalTokens));
109+
110+
provenance.AddField(new ProvenanceField(
111+
"Summary",
112+
ProvenanceKind.Inferred,
113+
0.8,
114+
provenance.Id));
115+
116+
var orderedOperations = proposal.Operations
117+
.OrderBy(operation => operation.Sequence)
118+
.ToList();
119+
120+
for (var i = 0; i < orderedOperations.Count; i++)
121+
{
122+
var operation = orderedOperations[i];
123+
provenance.AddField(new ProvenanceField(
124+
TruncateProvenanceFieldName($"Operation {i + 1}: {operation.ActionType} {operation.TargetType}"),
125+
ProvenanceKind.Inferred,
126+
0.75,
127+
provenance.Id));
128+
}
129+
130+
return provenance;
131+
}
132+
133+
private static string ResolveProvenanceModelId(CreateProposalDto dto)
134+
{
135+
if (!string.IsNullOrWhiteSpace(dto.ProvenanceModelId))
136+
return TruncateProvenanceModelId(dto.ProvenanceModelId.Trim());
137+
138+
return dto.SourceType switch
139+
{
140+
ProposalSourceType.Chat => "chat-tools",
141+
ProposalSourceType.Manual => "manual",
142+
ProposalSourceType.Queue => "queue",
143+
_ => "unknown"
144+
};
145+
}
146+
147+
private static string TruncateProvenanceFieldName(string fieldName)
148+
{
149+
return fieldName.Length <= 100 ? fieldName : fieldName[..100];
150+
}
151+
152+
private static string TruncateProvenanceModelId(string modelId)
153+
{
154+
return modelId.Length <= 100 ? modelId : modelId[..100];
155+
}
156+
92157
public async Task<Result<ProposalDto>> GetProposalByIdAsync(Guid id, CancellationToken cancellationToken = default)
93158
{
94159
var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(id, cancellationToken);
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
using Taskdeck.Application.DTOs;
2+
using Taskdeck.Application.Interfaces;
3+
using Taskdeck.Domain.Common;
4+
using Taskdeck.Domain.Entities;
5+
using Taskdeck.Domain.Enums;
6+
using Taskdeck.Domain.Exceptions;
7+
8+
namespace Taskdeck.Application.Services;
9+
10+
/// <summary>
11+
/// Maps <see cref="ProposalProvenance"/> domain entities to the Paper
12+
/// deep-Review provenance row shape consumed by the frontend.
13+
/// </summary>
14+
public class ProvenanceQueryService : IProvenanceQueryService
15+
{
16+
private readonly IProposalProvenanceRepository _provenanceRepository;
17+
18+
/// <summary>
19+
/// Stable mapping from canonical field name (lower-cased) to emoji icon.
20+
/// Unknown field names fall back to a generic icon.
21+
/// </summary>
22+
internal static readonly IReadOnlyDictionary<string, string> IconMap =
23+
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
24+
{
25+
["title"] = "\U0001F4DD", // memo
26+
["description"] = "\U0001F4C4", // page facing up
27+
["card body"] = "\U0001F4C4", // page facing up
28+
["body"] = "\U0001F4C4", // page facing up
29+
["label"] = "\U0001F3F7️", // label
30+
["labels"] = "\U0001F3F7️", // label
31+
["column"] = "\U0001F4CA", // bar chart
32+
["due date"] = "\U0001F4C5", // calendar
33+
["duedate"] = "\U0001F4C5", // calendar
34+
["due_date"] = "\U0001F4C5", // calendar
35+
["assignee"] = "\U0001F464", // bust in silhouette
36+
["priority"] = "\U0001F6A9", // triangular flag
37+
["board activity"] = "\U0001F4DC", // scroll
38+
["activity"] = "\U0001F4DC", // scroll
39+
["checklist"] = "\U00002705", // white heavy check mark
40+
["subtask"] = "\U00002705", // white heavy check mark
41+
["subtasks"] = "\U00002705", // white heavy check mark
42+
["comment"] = "\U0001F4AC", // speech balloon
43+
["comments"] = "\U0001F4AC", // speech balloon
44+
["attachment"] = "\U0001F4CE", // paperclip
45+
["link"] = "\U0001F517", // link symbol
46+
["design-doc"] = "\U0001F517", // link symbol
47+
["capture"] = "\U0001F4E5", // inbox tray
48+
["inbox"] = "\U0001F4E5", // inbox tray
49+
["not read"] = "\U00002298", // circled division slash
50+
["excluded"] = "\U00002298", // circled division slash
51+
["inferred"] = "\U00002726", // four pointed star (✦)
52+
};
53+
54+
/// <summary>
55+
/// Fallback icon when no mapping exists for the field name.
56+
/// </summary>
57+
internal const string DefaultIcon = "\U0001F4C4"; // page facing up
58+
59+
public ProvenanceQueryService(IProposalProvenanceRepository provenanceRepository)
60+
{
61+
_provenanceRepository = provenanceRepository ?? throw new ArgumentNullException(nameof(provenanceRepository));
62+
}
63+
64+
public async Task<Result<IReadOnlyList<ProvenanceRowDto>>> GetProvenanceRowsAsync(
65+
Guid proposalId,
66+
CancellationToken cancellationToken = default)
67+
{
68+
if (proposalId == Guid.Empty)
69+
return Result.Failure<IReadOnlyList<ProvenanceRowDto>>(
70+
ErrorCodes.ValidationError,
71+
"ProposalId cannot be empty");
72+
73+
var provenance = await _provenanceRepository.GetByProposalIdAsync(proposalId, cancellationToken);
74+
75+
if (provenance is null)
76+
{
77+
// No provenance recorded for this proposal -- return empty list, not an error.
78+
return Result.Success<IReadOnlyList<ProvenanceRowDto>>(
79+
Array.Empty<ProvenanceRowDto>());
80+
}
81+
82+
var rows = provenance.Fields
83+
.Select(MapFieldToRow)
84+
.ToList()
85+
.AsReadOnly();
86+
87+
return Result.Success<IReadOnlyList<ProvenanceRowDto>>(rows);
88+
}
89+
90+
internal static ProvenanceRowDto MapFieldToRow(ProvenanceField field)
91+
{
92+
var icon = ResolveIcon(field.FieldName);
93+
var key = field.FieldName;
94+
var value = BuildValue(field);
95+
var weight = MapWeight(field.Kind, field.Confidence);
96+
97+
return new ProvenanceRowDto(icon, key, value, weight);
98+
}
99+
100+
/// <summary>
101+
/// Resolves the emoji icon for a field name using the stable icon map.
102+
/// Falls back to <see cref="DefaultIcon"/> for unknown field names.
103+
/// </summary>
104+
internal static string ResolveIcon(string fieldName)
105+
{
106+
if (string.IsNullOrWhiteSpace(fieldName))
107+
return DefaultIcon;
108+
109+
return IconMap.TryGetValue(fieldName, out var icon) ? icon : DefaultIcon;
110+
}
111+
112+
/// <summary>
113+
/// Builds a human-readable value string from the provenance field.
114+
/// For extractive fields, includes the quote snippet.
115+
/// For inferred fields, notes the confidence level.
116+
/// </summary>
117+
internal static string BuildValue(ProvenanceField field)
118+
{
119+
var confidencePercent = (int)Math.Round(field.Confidence * 100);
120+
121+
if (field.Kind == ProvenanceKind.Extractive && !string.IsNullOrWhiteSpace(field.ExtractiveQuote))
122+
{
123+
// Truncate long quotes to keep the UI scannable.
124+
var quote = field.ExtractiveQuote.Length > 120
125+
? string.Concat(field.ExtractiveQuote.AsSpan(0, 117), "...")
126+
: field.ExtractiveQuote;
127+
return $"Extracted: \"{quote}\" ({confidencePercent}% match)";
128+
}
129+
130+
if (field.Kind == ProvenanceKind.Inferred)
131+
{
132+
return $"Inferred by model ({confidencePercent}% confidence)";
133+
}
134+
135+
// Fallback for extractive fields without a quote (should not happen per domain rules,
136+
// but we handle it defensively).
137+
return $"Source field ({confidencePercent}% confidence)";
138+
}
139+
140+
/// <summary>
141+
/// Maps the domain <see cref="ProvenanceKind"/> and confidence score to
142+
/// the 4-bucket weight system used by the Paper deep-Review surface.
143+
///
144+
/// Buckets:
145+
/// - primary: Extractive with high confidence (>= 0.7)
146+
/// - contextual: Extractive with lower confidence (< 0.7)
147+
/// - inferred: Inferred kind regardless of confidence
148+
/// - excluded: Never reached from existing fields, but the frontend
149+
/// defines this bucket for "not read" entries that are
150+
/// synthesized separately.
151+
///
152+
/// Note: "excluded" rows are not currently generated from ProvenanceField
153+
/// data. The frontend can inject them client-side or a future backend
154+
/// enhancement can add a dedicated "excluded sources" list.
155+
/// </summary>
156+
internal static string MapWeight(ProvenanceKind kind, double confidence)
157+
{
158+
return kind switch
159+
{
160+
ProvenanceKind.Inferred => "inferred",
161+
ProvenanceKind.Extractive => confidence >= 0.7 ? "primary" : "contextual",
162+
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unrecognized ProvenanceKind")
163+
};
164+
}
165+
}

backend/src/Taskdeck.Infrastructure/DependencyInjection.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
7575
services.AddScoped<IFtsKnowledgeSearchService>(sp =>
7676
sp.GetRequiredService<Taskdeck.Infrastructure.Services.KnowledgeFtsSearchService>());
7777
services.AddScoped<IProposalRevisionRepository, ProposalRevisionRepository>();
78+
services.AddScoped<IProposalProvenanceRepository, ProposalProvenanceRepository>();
7879
services.AddScoped<IDailySnapshotRepository, DailySnapshotRepository>();
7980
services.AddScoped<ITomorrowNoteRepository, TomorrowNoteRepository>();
8081

0 commit comments

Comments
 (0)