forked from votrongdao/FlowX
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAssistant.cs
More file actions
226 lines (203 loc) · 9.14 KB
/
Copy pathAssistant.cs
File metadata and controls
226 lines (203 loc) · 9.14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
using FlowX;
using Npgsql;
using NpgsqlTypes;
namespace Crm;
/// <summary>Asks for a plain-language summary of an account.</summary>
/// <param name="AccountId">Whose account.</param>
public sealed record SummariseAccount(Guid AccountId);
/// <summary>What is going on with an account, in numbers a person or a model can read.</summary>
/// <param name="AccountId">The account.</param>
/// <param name="Name">Its name.</param>
/// <param name="Lifecycle">Where it is in its life.</param>
/// <param name="OpenOpportunities">How many deals are still open.</param>
/// <param name="OpenValue">What they are worth together.</param>
/// <param name="Currency">What that is in, or the empty string when there are none.</param>
/// <param name="OpenTasks">How many tasks somebody still owes.</param>
/// <param name="LastActivityAt">When anything last happened, or null.</param>
public sealed record AccountSummary(
Guid AccountId,
string Name,
Lifecycle Lifecycle,
int OpenOpportunities,
decimal OpenValue,
string Currency,
int OpenTasks,
DateTimeOffset? LastActivityAt);
/// <summary>Refusals the assistant can produce.</summary>
public static class AssistantErrors
{
/// <summary>That account is not in this tenant, or is gone.</summary>
/// <param name="accountId">What was named.</param>
/// <remarks>
/// <strong>The same refusal an agent gets and a person gets.</strong> It says nothing about
/// whether the account exists somewhere else, because a not-found that could be told apart
/// from a forbidden is a way to enumerate another tenant's accounts one guess at a time.
/// </remarks>
public static Error AccountNotFound(Guid accountId) =>
new Error(
"crm.account_not_found",
"That account is not in this tenant.",
ErrorCategory.NotFound)
.With("accountId", accountId);
}
/// <summary>Reads what an account has open.</summary>
public sealed class AssistantStore
{
private const string SelectSummary = """
SELECT a.name,
a.lifecycle,
coalesce(o.open_count, 0),
coalesce(o.open_value, 0),
coalesce(o.currency, ''),
coalesce(t.open_tasks, 0),
t.last_activity_at
FROM account a
LEFT JOIN (
SELECT account_id,
count(*) AS open_count,
sum(amount) AS open_value,
min(currency) AS currency
FROM opportunity
WHERE outcome IS NULL
GROUP BY account_id
) o ON o.account_id = a.account_id
LEFT JOIN (
SELECT relates_to_id,
count(*) FILTER (WHERE status IN ('Open', 'Escalated')) AS open_tasks,
max(due_at) AS last_activity_at
FROM activity
WHERE relates_to_kind = 'Account'
GROUP BY relates_to_id
) t ON t.relates_to_id = a.account_id
WHERE a.account_id = @account
""";
private readonly NpgsqlDataSource _source;
/// <summary>Builds the store over the application's data source.</summary>
/// <param name="source">The pool <c>AddFlowXPostgres</c> built.</param>
/// <exception cref="ArgumentNullException"><paramref name="source"/> is null.</exception>
public AssistantStore(NpgsqlDataSource source)
{
ArgumentNullException.ThrowIfNull(source);
_source = source;
}
/// <summary>Summarises one account.</summary>
/// <param name="tenantId">The caller's tenant.</param>
/// <param name="accountId">The account.</param>
/// <param name="cancellationToken">Cancels the call.</param>
/// <returns>The summary, or null when this tenant cannot see the account.</returns>
/// <remarks>
/// <strong>One statement, on a connection narrowed to the caller's tenant.</strong> The two
/// sub-selects see only what the policies of migration <c>0002</c> let them see, so an
/// account this tenant does not have is not a filtered row — it is a row that is not there.
/// </remarks>
public async ValueTask<AccountSummary?> SummariseAsync(
string? tenantId,
Guid accountId,
CancellationToken cancellationToken)
{
var connection = await _source.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var closing = connection.ConfigureAwait(false);
await CrmTenantScope.ApplyAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
var command = connection.CreateCommand();
await using var closingCommand = command.ConfigureAwait(false);
command.CommandText = SelectSummary;
command.Parameters.Add(new NpgsqlParameter("account", NpgsqlDbType.Uuid) { Value = accountId });
var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await using var closingReader = reader.ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
var lastActivity = await reader.IsDBNullAsync(6, cancellationToken).ConfigureAwait(false)
? (DateTimeOffset?)null
: await reader.GetFieldValueAsync<DateTimeOffset>(6, cancellationToken).ConfigureAwait(false);
return new AccountSummary(
accountId,
reader.GetString(0),
Enum.Parse<Lifecycle>(reader.GetString(1)),
(int)reader.GetInt64(2),
reader.GetDecimal(3),
reader.GetString(4),
(int)reader.GetInt64(5),
lastActivity);
}
}
/// <summary>
/// Summarises an account for whoever asked — a person over HTTP or a model over MCP.
/// </summary>
/// <remarks>
/// <strong>It holds one stance and both transports meet it.</strong> <c>crm.read</c> is declared
/// here and checked in the step loop against the caller's claims, whichever transport carried
/// them. Nothing in this class knows which one did, and there is no second check on the agent
/// path to fall out of step with this one — which is docs/15 §7's claim written as a capability
/// rather than as a sentence.
/// </remarks>
[Capability("crm.account.summarise", Version = "1.0.0",
Authorization = Authorization.Permission, Permission = "crm.read",
Idempotent = true)]
public sealed class SummariseAccountForCaller : ICapability<SummariseAccount, AccountSummary>
{
private readonly AssistantStore _store;
/// <summary>Creates the capability.</summary>
/// <param name="store">Reads the account.</param>
/// <exception cref="ArgumentNullException"><paramref name="store"/> is null.</exception>
public SummariseAccountForCaller(AssistantStore store)
{
ArgumentNullException.ThrowIfNull(store);
_store = store;
}
/// <inheritdoc />
public async ValueTask<Result<AccountSummary>> ExecuteAsync(
SummariseAccount input,
CapabilityContext ctx,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(ctx);
return await _store.SummariseAsync(ctx.TenantId, input.AccountId, ct).ConfigureAwait(false)
is { } summary
? Result.Ok(summary)
: Result.Fail<AccountSummary>(AssistantErrors.AccountNotFound(input.AccountId));
}
}
/// <summary>
/// The one flow this sample publishes to an agent.
/// </summary>
/// <remarks>
/// <para>
/// <strong>Reads only, so <c>Confirmation = Never</c> is a fact rather than a preference.</strong>
/// The capability declares no side effects, so there is nothing a human would be confirming.
/// The flows that do write — a quote, an order, a discount approval — carry no
/// <c>[AgentTrigger]</c> at all, which is a stronger statement than a confirmation prompt: what
/// is not published cannot be called.
/// </para>
/// <para>
/// <strong><c>Ephemeral</c>, because it has nothing to unwind.</strong> One step, one read, no
/// emit. A journaled instance per question a model asks would be a row per question and no
/// property gained.
/// </para>
/// <para>
/// <strong>Both triggers, deliberately.</strong> The point of the package is that the two
/// surfaces are one surface: the same plan, the same capability, the same stance, and the same
/// refusal for the same caller.
/// </para>
/// </remarks>
[Flow("crm.account.summary", Version = "1.0.0", Profile = ExecutionProfile.Ephemeral, Owner = "crm-platform")]
[FlowDeadline("PT10S")]
[HttpTrigger("POST", "/api/v1/crm/account-summaries")]
[AgentTrigger(
Description =
"Summarise a CRM account: its lifecycle, how many opportunities are open and what they " +
"are worth, how many tasks are outstanding, and when anything last happened. Reads only.",
Confirmation = ConfirmationMode.Never)]
public sealed partial class SummariseAccountFlow : Flow<SummariseAccount, AccountSummary>
{
/// <inheritdoc />
protected override void Define(IFlowBuilder<SummariseAccount, AccountSummary> flow)
{
ArgumentNullException.ThrowIfNull(flow);
flow
.Step<SummariseAccountForCaller>()
.Return(ctx => ctx.Get<AccountSummary>());
}
}