Skip to content

Commit ad5ea25

Browse files
committed
Fix mangled merge of main into paper/1023-history
The merge of main smashed GetProposalHistory and GetProposalSideEffects together (missing opening brace, duplicate var result lines) and dropped the ISimilarDecisionService constructor parameter and GetSimilarPast endpoint entirely. This commit restores: - ISimilarDecisionService field, constructor parameter, and DI registration - Separated GetProposalHistory and GetProposalSideEffects into distinct methods - GetSimilarPast endpoint after the diff endpoint - All supporting files: SimilarDecisionService, ISimilarDecisionService, SimilarPastDtos, Domain SimilarPast entities, repository method, and tests
1 parent b2f3989 commit ad5ea25

16 files changed

Lines changed: 1004 additions & 1 deletion

File tree

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,20 +26,23 @@ public class AutomationProposalsController : AuthenticatedControllerBase
2626

2727
private readonly IAutomationProposalService _proposalService;
2828
private readonly IAutomationExecutorService _executorService;
29+
private readonly ISimilarDecisionService _similarDecisionService;
2930
private readonly BoardAuthorizationService _authorizationService;
3031
private readonly ICardHistoryService _cardHistoryService;
3132
private readonly ISideEffectAnalyzer _sideEffectAnalyzer;
3233

3334
public AutomationProposalsController(
3435
IAutomationProposalService proposalService,
3536
IAutomationExecutorService executorService,
37+
ISimilarDecisionService similarDecisionService,
3638
BoardAuthorizationService authorizationService,
3739
ICardHistoryService cardHistoryService,
3840
ISideEffectAnalyzer sideEffectAnalyzer,
3941
IUserContext userContext) : base(userContext)
4042
{
4143
_proposalService = proposalService;
4244
_executorService = executorService;
45+
_similarDecisionService = similarDecisionService;
4346
_authorizationService = authorizationService;
4447
_cardHistoryService = cardHistoryService;
4548
_sideEffectAnalyzer = sideEffectAnalyzer;
@@ -272,6 +275,19 @@ public async Task<IActionResult> DismissProposals(
272275
/// </summary>
273276
[HttpGet("{id}/history")]
274277
public async Task<IActionResult> GetProposalHistory(Guid id, CancellationToken cancellationToken = default)
278+
{
279+
if (!TryGetCurrentUserId(out var callerUserId, out var errorResult))
280+
return errorResult!;
281+
282+
var auth = await AuthorizeProposalAsync(id, callerUserId, requireWriteAccess: false, cancellationToken);
283+
if (auth.ErrorResult is not null)
284+
return auth.ErrorResult;
285+
286+
var result = await _cardHistoryService.GetCardHistoryForProposalAsync(id, cancellationToken);
287+
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
288+
}
289+
290+
/// <summary>
275291
/// Gets the side-effect analysis for a proposal, including the 7-category breakdown
276292
/// and reversibility posture.
277293
/// </summary>
@@ -285,7 +301,6 @@ public async Task<IActionResult> GetProposalSideEffects(Guid id, CancellationTok
285301
if (auth.ErrorResult is not null)
286302
return auth.ErrorResult;
287303

288-
var result = await _cardHistoryService.GetCardHistoryForProposalAsync(id, cancellationToken);
289304
var result = await _sideEffectAnalyzer.AnalyzeAsync(id, cancellationToken);
290305
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
291306
}
@@ -307,6 +322,24 @@ public async Task<IActionResult> GetProposalDiff(Guid id, CancellationToken canc
307322
return result.IsSuccess ? Ok(new { diff = result.Value }) : result.ToErrorActionResult();
308323
}
309324

325+
/// <summary>
326+
/// Gets similar past decisions for a proposal, including the latest 3 decisions
327+
/// with the same action class and an aggregate apply rate.
328+
/// </summary>
329+
[HttpGet("{id}/similar-past")]
330+
public async Task<IActionResult> GetSimilarPast(Guid id, CancellationToken cancellationToken = default)
331+
{
332+
if (!TryGetCurrentUserId(out var callerUserId, out var errorResult))
333+
return errorResult!;
334+
335+
var auth = await AuthorizeProposalAsync(id, callerUserId, requireWriteAccess: false, cancellationToken);
336+
if (auth.ErrorResult is not null)
337+
return auth.ErrorResult;
338+
339+
var result = await _similarDecisionService.GetSimilarPastAsync(id, callerUserId, cancellationToken);
340+
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
341+
}
342+
310343
private async Task<(ProposalDto? Proposal, IActionResult? ErrorResult)> AuthorizeProposalAsync(
311344
Guid proposalId,
312345
Guid callerUserId,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
5050
services.AddScoped<ICardHistoryService, CardHistoryService>();
5151
services.AddScoped<ISideEffectAnalyzer, SideEffectAnalyzer>();
5252
services.AddScoped<IProposalRevisionService, ProposalRevisionService>();
53+
services.AddScoped<ISimilarDecisionService, SimilarDecisionService>();
5354
services.AddScoped<IAutomationPolicyEngine, AutomationPolicyEngine>();
5455
services.AddScoped<IAutomationPlannerService, AutomationPlannerService>();
5556
services.AddScoped<IAutomationExecutorService, AutomationExecutorService>();
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace Taskdeck.Application.DTOs;
2+
3+
/// <summary>
4+
/// DTO for a single similar past decision.
5+
/// </summary>
6+
public record SimilarPastDecisionDto(
7+
string Serial,
8+
string Title,
9+
string Verdict,
10+
string Date);
11+
12+
/// <summary>
13+
/// DTO for the aggregated similar past result.
14+
/// </summary>
15+
public record SimilarPastResultDto(
16+
IReadOnlyList<SimilarPastDecisionDto> Decisions,
17+
double ApplyRate);

backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,16 @@ public interface IAutomationProposalRepository : IRepository<AutomationProposal>
2121
ProposalSourceType sourceType,
2222
CancellationToken cancellationToken = default);
2323
Task<IEnumerable<AutomationProposal>> GetExpiredAsync(CancellationToken cancellationToken = default);
24+
25+
/// <summary>
26+
/// Gets proposals that have at least one operation matching the given action type
27+
/// and are in a terminal state (Applied or Rejected), ordered by most recent first.
28+
/// Optionally scoped to a specific board. Limited to a lookback window for performance.
29+
/// </summary>
30+
Task<IReadOnlyList<AutomationProposal>> GetTerminalByActionTypeAsync(
31+
string actionType,
32+
Guid? boardId,
33+
Guid userId,
34+
int limit = 100,
35+
CancellationToken cancellationToken = default);
2436
}
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.Services;
5+
6+
/// <summary>
7+
/// Surfaces similar past proposal decisions for a given proposal,
8+
/// providing reviewers with a historical base rate.
9+
/// </summary>
10+
public interface ISimilarDecisionService
11+
{
12+
/// <summary>
13+
/// Gets the most recent similar past decisions for a proposal,
14+
/// matching on the proposal's primary action class.
15+
/// </summary>
16+
Task<Result<SimilarPastResultDto>> GetSimilarPastAsync(
17+
Guid proposalId,
18+
Guid userId,
19+
CancellationToken cancellationToken = default);
20+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
using System.Globalization;
2+
using Taskdeck.Application.DTOs;
3+
using Taskdeck.Application.Interfaces;
4+
using Taskdeck.Domain.Common;
5+
using Taskdeck.Domain.Entities;
6+
using Taskdeck.Domain.Exceptions;
7+
using Taskdeck.Domain.SimilarPast;
8+
9+
namespace Taskdeck.Application.Services;
10+
11+
/// <summary>
12+
/// Queries past proposals with the same action class to surface similar
13+
/// historical decisions and an aggregate apply rate.
14+
/// </summary>
15+
public class SimilarDecisionService : ISimilarDecisionService
16+
{
17+
/// <summary>
18+
/// Maximum number of similar decisions to return.
19+
/// </summary>
20+
internal const int MaxDecisions = 3;
21+
22+
/// <summary>
23+
/// Maximum number of past proposals to query for rate calculation.
24+
/// Limits the lookback window to avoid unbounded queries.
25+
/// </summary>
26+
internal const int LookbackLimit = 200;
27+
28+
private readonly IUnitOfWork _unitOfWork;
29+
30+
public SimilarDecisionService(IUnitOfWork unitOfWork)
31+
{
32+
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
33+
}
34+
35+
public async Task<Result<SimilarPastResultDto>> GetSimilarPastAsync(
36+
Guid proposalId,
37+
Guid userId,
38+
CancellationToken cancellationToken = default)
39+
{
40+
// Load the current proposal to determine its action class
41+
var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(proposalId, cancellationToken);
42+
if (proposal is null)
43+
return Result.Failure<SimilarPastResultDto>(ErrorCodes.NotFound, "Proposal not found.");
44+
45+
// Determine the primary action type from the proposal's operations
46+
var actionType = GetPrimaryActionType(proposal);
47+
if (string.IsNullOrWhiteSpace(actionType))
48+
{
49+
// Proposal has no operations -- return empty result
50+
return Result.Success(ToDto(SimilarPastResult.Empty));
51+
}
52+
53+
// Query past proposals with the same action class in terminal states,
54+
// always scoped to the proposal's board to prevent cross-board title leakage.
55+
// No cross-board fallback: if the board has no history, return empty rather
56+
// than surfacing proposal titles from other boards.
57+
var pastProposals = (await _unitOfWork.AutomationProposals
58+
.GetTerminalByActionTypeAsync(actionType, proposal.BoardId, userId, LookbackLimit, cancellationToken))
59+
?? Array.Empty<AutomationProposal>();
60+
61+
// Exclude the current proposal itself from the results
62+
var filtered = pastProposals
63+
.Where(p => p.Id != proposalId)
64+
.ToList();
65+
66+
if (filtered.Count == 0)
67+
return Result.Success(ToDto(SimilarPastResult.Empty));
68+
69+
// Compute apply rate across ALL matching proposals (not just top 3)
70+
var appliedCount = filtered.Count(p => p.Status == ProposalStatus.Applied);
71+
var rejectedCount = filtered.Count(p => p.Status == ProposalStatus.Rejected);
72+
var applyRate = SimilarPastResult.ComputeApplyRate(appliedCount, rejectedCount);
73+
74+
// Take the 3 most recent for display
75+
var topDecisions = filtered
76+
.Take(MaxDecisions)
77+
.Select((p, index) => SimilarPastDecision.Create(
78+
serial: $"#{(index + 1):D3}",
79+
title: GetProposalTitle(p),
80+
verdict: MapVerdict(p.Status),
81+
date: FormatWeekDate(p.DecidedAt ?? p.CreatedAt)))
82+
.ToList();
83+
84+
var result = new SimilarPastResult(topDecisions, applyRate);
85+
return Result.Success(ToDto(result));
86+
}
87+
88+
/// <summary>
89+
/// Gets the primary action type from the first operation of a proposal.
90+
/// The "action class" is the ActionType string of the first (or most representative) operation.
91+
/// </summary>
92+
internal static string? GetPrimaryActionType(AutomationProposal proposal)
93+
{
94+
if (proposal.Operations.Count == 0)
95+
return null;
96+
97+
// Use the first operation's action type as the primary action class.
98+
// For multi-operation proposals (e.g. bulk_move generates multiple "move" ops),
99+
// the first operation is representative.
100+
return proposal.Operations
101+
.OrderBy(op => op.Sequence)
102+
.First()
103+
.ActionType;
104+
}
105+
106+
/// <summary>
107+
/// Gets a display title from a proposal: its summary, or the first operation description.
108+
/// </summary>
109+
internal static string GetProposalTitle(AutomationProposal proposal)
110+
{
111+
if (!string.IsNullOrWhiteSpace(proposal.Summary))
112+
return proposal.Summary;
113+
114+
if (proposal.Operations.Count > 0)
115+
{
116+
var firstOp = proposal.Operations
117+
.OrderBy(op => op.Sequence)
118+
.First();
119+
return $"{firstOp.ActionType} {firstOp.TargetType}";
120+
}
121+
122+
return "Untitled proposal";
123+
}
124+
125+
/// <summary>
126+
/// Maps a proposal terminal status to a <see cref="PastVerdict"/>.
127+
/// </summary>
128+
internal static PastVerdict MapVerdict(ProposalStatus status)
129+
{
130+
return status switch
131+
{
132+
ProposalStatus.Applied => PastVerdict.Applied,
133+
ProposalStatus.Rejected => PastVerdict.Rejected,
134+
_ => throw new InvalidOperationException(
135+
$"Cannot map non-terminal status '{status}' to a PastVerdict.")
136+
};
137+
}
138+
139+
/// <summary>
140+
/// Formats a DateTime as an ISO week string, e.g. "wk 14 '26".
141+
/// Uses ISO 8601 week numbering (Monday-start, first 4-day week).
142+
/// Includes the 2-digit year to disambiguate cross-year boundaries.
143+
/// </summary>
144+
internal static string FormatWeekDate(DateTimeOffset dateTime)
145+
{
146+
var weekNumber = ISOWeek.GetWeekOfYear(dateTime.UtcDateTime);
147+
var isoYear = ISOWeek.GetYear(dateTime.UtcDateTime);
148+
return $"wk {weekNumber} '{isoYear % 100:D2}";
149+
}
150+
151+
private static SimilarPastResultDto ToDto(SimilarPastResult result)
152+
{
153+
var decisions = result.Decisions
154+
.Select(d => new SimilarPastDecisionDto(
155+
d.Serial,
156+
d.Title,
157+
d.Verdict.ToString().ToLowerInvariant(),
158+
d.Date))
159+
.ToList();
160+
161+
return new SimilarPastResultDto(decisions, result.ApplyRate);
162+
}
163+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace Taskdeck.Domain.SimilarPast;
2+
3+
/// <summary>
4+
/// Terminal verdict for a past automation proposal decision.
5+
/// </summary>
6+
public enum PastVerdict
7+
{
8+
Applied,
9+
Rejected
10+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
namespace Taskdeck.Domain.SimilarPast;
2+
3+
/// <summary>
4+
/// A past proposal decision surfaced as context for the current proposal review.
5+
/// Immutable value object.
6+
/// </summary>
7+
/// <param name="Serial">Display serial, e.g. '#001'.</param>
8+
/// <param name="Title">Proposal summary or first operation description.</param>
9+
/// <param name="Verdict">Whether the proposal was applied or rejected.</param>
10+
/// <param name="Date">Pre-formatted date string, e.g. 'wk 14'.</param>
11+
public sealed record SimilarPastDecision(
12+
string Serial,
13+
string Title,
14+
PastVerdict Verdict,
15+
string Date)
16+
{
17+
/// <summary>
18+
/// Maximum title length to prevent unbounded strings from leaking into the UI.
19+
/// </summary>
20+
public const int MaxTitleLength = 200;
21+
22+
/// <summary>
23+
/// Creates a <see cref="SimilarPastDecision"/> with validation.
24+
/// </summary>
25+
public static SimilarPastDecision Create(string serial, string title, PastVerdict verdict, string date)
26+
{
27+
if (string.IsNullOrWhiteSpace(serial))
28+
throw new ArgumentException("Serial cannot be empty.", nameof(serial));
29+
if (string.IsNullOrWhiteSpace(title))
30+
throw new ArgumentException("Title cannot be empty.", nameof(title));
31+
if (string.IsNullOrWhiteSpace(date))
32+
throw new ArgumentException("Date cannot be empty.", nameof(date));
33+
34+
var truncatedTitle = title.Length > MaxTitleLength
35+
? title[..MaxTitleLength]
36+
: title;
37+
38+
return new SimilarPastDecision(serial, truncatedTitle, verdict, date);
39+
}
40+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
namespace Taskdeck.Domain.SimilarPast;
2+
3+
/// <summary>
4+
/// Aggregated result of similar past decisions for a proposal, including
5+
/// the top N most recent decisions and the overall apply rate.
6+
/// </summary>
7+
/// <param name="Decisions">The most recent similar decisions (up to 3).</param>
8+
/// <param name="ApplyRate">
9+
/// Ratio of applied to total terminal decisions (applied + rejected).
10+
/// 0.0 when there is no history.
11+
/// </param>
12+
public sealed record SimilarPastResult(
13+
IReadOnlyList<SimilarPastDecision> Decisions,
14+
double ApplyRate)
15+
{
16+
/// <summary>
17+
/// An empty result representing no prior history.
18+
/// </summary>
19+
public static SimilarPastResult Empty { get; } =
20+
new(Array.Empty<SimilarPastDecision>(), 0.0);
21+
22+
/// <summary>
23+
/// Computes the apply rate from applied and rejected counts,
24+
/// returning 0.0 when the denominator is zero.
25+
/// </summary>
26+
public static double ComputeApplyRate(int appliedCount, int rejectedCount)
27+
{
28+
if (appliedCount < 0)
29+
throw new ArgumentOutOfRangeException(nameof(appliedCount), "Applied count cannot be negative.");
30+
if (rejectedCount < 0)
31+
throw new ArgumentOutOfRangeException(nameof(rejectedCount), "Rejected count cannot be negative.");
32+
33+
var total = appliedCount + rejectedCount;
34+
return total == 0 ? 0.0 : (double)appliedCount / total;
35+
}
36+
}

0 commit comments

Comments
 (0)