Skip to content

Commit d4f3463

Browse files
committed
Clean up decide turn policy
1 parent c291c6c commit d4f3463

3 files changed

Lines changed: 170 additions & 148 deletions

File tree

apps/web/src/lib/server/decide-turn-policy.ts

Lines changed: 10 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import {
2+
containsWriteVerb,
23
deriveAmbiguity,
4+
deriveConsentRequirement,
35
type CommitPolicyOutput,
4-
type ConversationEntity,
56
type TurnAmbiguity,
67
type TurnClassifierOutput,
7-
type TurnInterpretationType,
88
type TurnPolicyDecision,
99
type TurnRoutingInput
1010
} from "@atlas/core";
@@ -147,8 +147,6 @@ function deriveStructuredWriteReadiness(
147147
};
148148
}
149149

150-
// Bug 4 fix: if this is a clarification answer and the proposal is already confirmed,
151-
// skip re-presenting and go straight to execution
152150
if (classification.turnType === "clarification_answer") {
153151
const entityRegistry = input.routingContext.entityRegistry ?? [];
154152
const alreadyConfirmed = entityRegistry.some(
@@ -166,13 +164,19 @@ function deriveStructuredWriteReadiness(
166164
}
167165
}
168166

169-
const consentRequirement = deriveConsentRequirement(input);
167+
const consentRequirement = deriveConsentRequirement({
168+
classification,
169+
entityRegistry: input.routingContext.entityRegistry ?? [],
170+
normalizedText: input.routingContext.normalizedText
171+
});
170172

171173
if (consentRequirement.required) {
172174
return {
173175
state: "ready_needs_consent",
174176
reason: consentRequirement.reason,
175-
...(consentRequirement.targetProposalId ? { targetProposalId: consentRequirement.targetProposalId } : {})
177+
...(consentRequirement.required && "targetProposalId" in consentRequirement
178+
? { targetProposalId: consentRequirement.targetProposalId }
179+
: {})
176180
};
177181
}
178182

@@ -226,145 +230,3 @@ function buildPolicyFromStructuredReadiness(
226230
};
227231
}
228232
}
229-
230-
function deriveConsentRequirement(input: DecideTurnPolicyInput) {
231-
const { classification } = input;
232-
// Bug 2 fix: match "presented" status in addition to "active"
233-
const activeProposal = (input.routingContext.entityRegistry ?? []).find(
234-
(entity): entity is Extract<ConversationEntity, { kind: "proposal_option" }> =>
235-
entity.kind === "proposal_option" &&
236-
(entity.status === "active" || entity.status === "presented") &&
237-
entity.id === classification.resolvedProposalId &&
238-
entity.data.confirmationRequired === true
239-
);
240-
241-
if (!activeProposal) {
242-
return {
243-
required: false,
244-
reason: "Deterministic product rules do not require additional consent."
245-
};
246-
}
247-
248-
if (!matchesProposalTarget(activeProposal.data.targetEntityId ?? null, classification.resolvedEntityIds)) {
249-
return {
250-
required: false,
251-
reason: "Deterministic product rules do not require additional consent."
252-
};
253-
}
254-
255-
const compatibility = deriveProposalCompatibility(input, activeProposal);
256-
257-
if (!compatibility.compatible) {
258-
return {
259-
required: true,
260-
reason: compatibility.reason
261-
};
262-
}
263-
264-
return {
265-
required: true,
266-
reason: "Write request is ready, but deterministic product policy still requires user consent.",
267-
targetProposalId: activeProposal.id
268-
};
269-
}
270-
271-
function matchesProposalTarget(targetEntityId: string | null, resolvedEntityIds: string[]) {
272-
if (!targetEntityId || resolvedEntityIds.length === 0) {
273-
return true;
274-
}
275-
276-
return resolvedEntityIds.includes(targetEntityId);
277-
}
278-
279-
function deriveProposalCompatibility(
280-
input: DecideTurnPolicyInput,
281-
proposal: Extract<ConversationEntity, { kind: "proposal_option" }>
282-
) {
283-
if (input.classification.turnType === "clarification_answer") {
284-
return {
285-
compatible: true,
286-
reason: "Clarification answers may continue the same consent-required proposal."
287-
};
288-
}
289-
290-
const currentActionKind = deriveActionKind(input.routingContext.normalizedText, input.classification.turnType);
291-
const proposalActionKind = deriveActionKind(
292-
proposal.data.originatingTurnText ?? proposal.data.replyText,
293-
inferProposalTurnType(proposal)
294-
);
295-
296-
if (currentActionKind !== proposalActionKind) {
297-
return {
298-
compatible: false,
299-
reason: "The new turn changes the action type, so it needs fresh consent."
300-
};
301-
}
302-
303-
const currentFingerprint = deriveParameterFingerprint(input.routingContext.normalizedText);
304-
const proposalFingerprint = deriveParameterFingerprint(proposal.data.originatingTurnText ?? proposal.data.replyText);
305-
306-
if (currentFingerprint.explicit && proposalFingerprint.explicit && currentFingerprint.value !== proposalFingerprint.value) {
307-
return {
308-
compatible: false,
309-
reason: "The new turn changes proposal parameters, so it needs fresh consent."
310-
};
311-
}
312-
313-
return {
314-
compatible: true,
315-
reason: "The pending proposal still matches the current turn."
316-
};
317-
}
318-
319-
function inferProposalTurnType(
320-
proposal: Extract<ConversationEntity, { kind: "proposal_option" }>
321-
): TurnInterpretationType {
322-
const source = (proposal.data.originatingTurnText ?? proposal.data.replyText).toLowerCase();
323-
324-
if (/\b(move|reschedule|shift|push|pull|complete|archive|cancel|delete|update|change|mark)\b/.test(source)) {
325-
return "edit_request";
326-
}
327-
328-
return "planning_request";
329-
}
330-
331-
function deriveActionKind(text: string, turnType: TurnInterpretationType) {
332-
if (turnType === "edit_request") {
333-
return "edit";
334-
}
335-
336-
if (turnType === "planning_request") {
337-
return "plan";
338-
}
339-
340-
const lower = text.toLowerCase();
341-
342-
if (/\b(move|reschedule|shift|push|pull|complete|archive|cancel|delete|update|change|mark)\b/.test(lower)) {
343-
return "edit";
344-
}
345-
346-
return "plan";
347-
}
348-
349-
function deriveParameterFingerprint(text: string) {
350-
const lower = text.toLowerCase();
351-
const dayTokens = lower.match(
352-
/\b(today|tonight|tomorrow|tmr|monday|tuesday|wednesday|thursday|friday|saturday|sunday|weekend|next week|next month|morning|afternoon|evening)\b/g
353-
) ?? [];
354-
const timeTokens =
355-
lower.match(/\b\d{1,2}(?::\d{2})?\s?(?:am|pm)?\b|\bnoon\b|\bmidnight\b/g) ?? [];
356-
const durationTokens =
357-
lower.match(/\bfor\s+\d+\s*(?:minutes?|mins?|hours?|hrs?)\b|\b\d+\s*(?:minutes?|mins?|hours?|hrs?)\b/g) ?? [];
358-
const fingerprintParts = [...dayTokens, ...timeTokens, ...durationTokens].map((part) => part.trim()).sort();
359-
360-
return {
361-
explicit: fingerprintParts.length > 0,
362-
value: fingerprintParts.join("|")
363-
};
364-
}
365-
366-
function containsWriteVerb(text: string) {
367-
return /\b(schedule|plan|move|reschedule|shift|create|add|book|put|mark|complete|archive|cancel|delete|change|update)\b/i.test(
368-
text
369-
);
370-
}

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
export * from "./ambiguity";
1111
export * from "./commit-policy";
1212
export * from "./discourse-state";
13+
export * from "./proposal-rules";
1314
export * from "./slot-normalizer";
1415
export * from "./telegram";
1516

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import type {
2+
ConversationEntity,
3+
TurnClassifierOutput,
4+
TurnInterpretationType
5+
} from "./index";
6+
7+
type ProposalOption = Extract<ConversationEntity, { kind: "proposal_option" }>;
8+
9+
export type ConsentRequirementInput = {
10+
classification: TurnClassifierOutput;
11+
entityRegistry: ConversationEntity[];
12+
normalizedText: string;
13+
};
14+
15+
export function deriveConsentRequirement(input: ConsentRequirementInput) {
16+
const { classification } = input;
17+
const activeProposal = input.entityRegistry.find(
18+
(entity): entity is ProposalOption =>
19+
entity.kind === "proposal_option" &&
20+
(entity.status === "active" || entity.status === "presented") &&
21+
entity.id === classification.resolvedProposalId &&
22+
entity.data.confirmationRequired === true
23+
);
24+
25+
if (!activeProposal) {
26+
return {
27+
required: false as const,
28+
reason: "Deterministic product rules do not require additional consent."
29+
};
30+
}
31+
32+
if (!matchesProposalTarget(activeProposal.data.targetEntityId ?? null, classification.resolvedEntityIds)) {
33+
return {
34+
required: false as const,
35+
reason: "Deterministic product rules do not require additional consent."
36+
};
37+
}
38+
39+
const compatibility = deriveProposalCompatibility(
40+
classification.turnType,
41+
input.normalizedText,
42+
activeProposal
43+
);
44+
45+
if (!compatibility.compatible) {
46+
return {
47+
required: true as const,
48+
reason: compatibility.reason
49+
};
50+
}
51+
52+
return {
53+
required: true as const,
54+
reason: "Write request is ready, but deterministic product policy still requires user consent.",
55+
targetProposalId: activeProposal.id
56+
};
57+
}
58+
59+
export function matchesProposalTarget(targetEntityId: string | null, resolvedEntityIds: string[]) {
60+
if (!targetEntityId || resolvedEntityIds.length === 0) {
61+
return true;
62+
}
63+
64+
return resolvedEntityIds.includes(targetEntityId);
65+
}
66+
67+
export function deriveProposalCompatibility(
68+
turnType: TurnInterpretationType,
69+
normalizedText: string,
70+
proposal: ProposalOption
71+
) {
72+
if (turnType === "clarification_answer") {
73+
return {
74+
compatible: true,
75+
reason: "Clarification answers may continue the same consent-required proposal."
76+
};
77+
}
78+
79+
const currentActionKind = deriveActionKind(normalizedText, turnType);
80+
const proposalActionKind = deriveActionKind(
81+
proposal.data.originatingTurnText ?? proposal.data.replyText,
82+
inferProposalTurnType(proposal)
83+
);
84+
85+
if (currentActionKind !== proposalActionKind) {
86+
return {
87+
compatible: false,
88+
reason: "The new turn changes the action type, so it needs fresh consent."
89+
};
90+
}
91+
92+
const currentFingerprint = deriveParameterFingerprint(normalizedText);
93+
const proposalFingerprint = deriveParameterFingerprint(proposal.data.originatingTurnText ?? proposal.data.replyText);
94+
95+
if (currentFingerprint.explicit && proposalFingerprint.explicit && currentFingerprint.value !== proposalFingerprint.value) {
96+
return {
97+
compatible: false,
98+
reason: "The new turn changes proposal parameters, so it needs fresh consent."
99+
};
100+
}
101+
102+
return {
103+
compatible: true,
104+
reason: "The pending proposal still matches the current turn."
105+
};
106+
}
107+
108+
export function inferProposalTurnType(
109+
proposal: ProposalOption
110+
): TurnInterpretationType {
111+
const source = (proposal.data.originatingTurnText ?? proposal.data.replyText).toLowerCase();
112+
113+
if (/\b(move|reschedule|shift|push|pull|complete|archive|cancel|delete|update|change|mark)\b/.test(source)) {
114+
return "edit_request";
115+
}
116+
117+
return "planning_request";
118+
}
119+
120+
export function deriveActionKind(text: string, turnType: TurnInterpretationType) {
121+
if (turnType === "edit_request") {
122+
return "edit";
123+
}
124+
125+
if (turnType === "planning_request") {
126+
return "plan";
127+
}
128+
129+
const lower = text.toLowerCase();
130+
131+
if (/\b(move|reschedule|shift|push|pull|complete|archive|cancel|delete|update|change|mark)\b/.test(lower)) {
132+
return "edit";
133+
}
134+
135+
return "plan";
136+
}
137+
138+
export function deriveParameterFingerprint(text: string) {
139+
const lower = text.toLowerCase();
140+
const dayTokens = lower.match(
141+
/\b(today|tonight|tomorrow|tmr|monday|tuesday|wednesday|thursday|friday|saturday|sunday|weekend|next week|next month|morning|afternoon|evening)\b/g
142+
) ?? [];
143+
const timeTokens =
144+
lower.match(/\b\d{1,2}(?::\d{2})?\s?(?:am|pm)?\b|\bnoon\b|\bmidnight\b/g) ?? [];
145+
const durationTokens =
146+
lower.match(/\bfor\s+\d+\s*(?:minutes?|mins?|hours?|hrs?)\b|\b\d+\s*(?:minutes?|mins?|hours?|hrs?)\b/g) ?? [];
147+
const fingerprintParts = [...dayTokens, ...timeTokens, ...durationTokens].map((part) => part.trim()).sort();
148+
149+
return {
150+
explicit: fingerprintParts.length > 0,
151+
value: fingerprintParts.join("|")
152+
};
153+
}
154+
155+
export function containsWriteVerb(text: string) {
156+
return /\b(schedule|plan|move|reschedule|shift|create|add|book|put|mark|complete|archive|cancel|delete|change|update)\b/i.test(
157+
text
158+
);
159+
}

0 commit comments

Comments
 (0)