portable-agent-layer 0.85.4 → 0.85.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/hooks/lib/interaction-hint-check.ts +46 -0
- package/src/hooks/lib/interaction-preferences.ts +115 -0
- package/src/hooks/lib/interaction-reaction.ts +26 -4
- package/src/hooks/lib/interaction-samples.ts +1 -0
- package/src/hooks/lib/interaction.ts +51 -9
- package/src/tools/lib/interaction-report.ts +100 -6
package/package.json
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a reply followed the hint it was written under. A label saying the user
|
|
3
|
+
* wants less asks for a reply at most half the user's usual one, the same factor
|
|
4
|
+
* of two the mood rules use to call a message short.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { approvedShare, type Side, type Split } from "./interaction-preferences";
|
|
8
|
+
|
|
9
|
+
const ASKS_FOR_LESS = ["short", "fast", "skimming"];
|
|
10
|
+
|
|
11
|
+
function lengthTarget(usualReplyWords: number): number {
|
|
12
|
+
return Math.round(usualReplyWords / 2);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function asksForLess(moodKey: string | undefined): boolean {
|
|
16
|
+
return (moodKey ?? "").split(",").some((label) => ASKS_FOR_LESS.includes(label));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Null when the reply was not written under a hint for less, or nothing says what usual is. */
|
|
20
|
+
export function followedHint(
|
|
21
|
+
moodKey: string | undefined,
|
|
22
|
+
replyWords: number | undefined,
|
|
23
|
+
usualReplyWords: number | null
|
|
24
|
+
): boolean | null {
|
|
25
|
+
if (replyWords === undefined || usualReplyWords === null) return null;
|
|
26
|
+
if (!asksForLess(moodKey)) return null;
|
|
27
|
+
return replyWords <= lengthTarget(usualReplyWords);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pct(s: Side): string {
|
|
31
|
+
return `${Math.round(approvedShare(s) * 100)}%`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function citedApprovals(evidence: Split | null): string {
|
|
35
|
+
if (!evidence) return "";
|
|
36
|
+
return ` Replies ${evidence.shape} were approved ${pct(evidence.has)} of the time, longer ones ${pct(evidence.lacks)}.`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ignoredHintReminder(
|
|
40
|
+
replyWords: number,
|
|
41
|
+
usualReplyWords: number,
|
|
42
|
+
evidence: Split | null
|
|
43
|
+
): string {
|
|
44
|
+
const target = lengthTarget(usualReplyWords);
|
|
45
|
+
return `<system-reminder>Interaction: the user still wants less. Your last reply was ${replyWords} words (usually ${usualReplyWords}); keep this one under ${target} words.${citedApprovals(evidence)}</system-reminder>`;
|
|
46
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the user's reactions say about the replies they get: for each shape a reply
|
|
3
|
+
* can take, how often replies with it were approved against replies without it.
|
|
4
|
+
* A split counts as evidence only with enough replies on both sides and a gap wider
|
|
5
|
+
* than noise; the data is thin, so most splits are expected to say not yet.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { median } from "./interaction-mood";
|
|
9
|
+
import { approvalIsKnown } from "./interaction-reaction";
|
|
10
|
+
|
|
11
|
+
const MIN_REPLIES_PER_SIDE = 20;
|
|
12
|
+
const MIN_GAP = 0.1;
|
|
13
|
+
|
|
14
|
+
interface ReplyFeatures {
|
|
15
|
+
words: number;
|
|
16
|
+
listItems: number;
|
|
17
|
+
headings: number;
|
|
18
|
+
asked: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ReactedTurn {
|
|
22
|
+
reply: ReplyFeatures | null;
|
|
23
|
+
reaction: string | null;
|
|
24
|
+
reactionRules?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Outcome {
|
|
28
|
+
reply: ReplyFeatures;
|
|
29
|
+
approved: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Side {
|
|
33
|
+
replies: number;
|
|
34
|
+
approved: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Split {
|
|
38
|
+
shape: string;
|
|
39
|
+
opposite: string;
|
|
40
|
+
has: Side;
|
|
41
|
+
lacks: Side;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function outcomes(events: ReactedTurn[]): Outcome[] {
|
|
45
|
+
return events.flatMap((e) =>
|
|
46
|
+
e.reply && e.reaction && approvalIsKnown(e)
|
|
47
|
+
? [{ reply: e.reply, approved: e.reaction === "approved" }]
|
|
48
|
+
: []
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function side(outcomes: Outcome[]): Side {
|
|
53
|
+
return {
|
|
54
|
+
replies: outcomes.length,
|
|
55
|
+
approved: outcomes.filter((o) => o.approved).length,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function splitBy(
|
|
60
|
+
outcomes: Outcome[],
|
|
61
|
+
shape: string,
|
|
62
|
+
opposite: string,
|
|
63
|
+
test: (reply: ReplyFeatures) => boolean
|
|
64
|
+
): Split {
|
|
65
|
+
return {
|
|
66
|
+
shape,
|
|
67
|
+
opposite,
|
|
68
|
+
has: side(outcomes.filter((o) => test(o.reply))),
|
|
69
|
+
lacks: side(outcomes.filter((o) => !test(o.reply))),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function lengthSplit(outcomes: Outcome[]): Split {
|
|
74
|
+
const cut = Math.round(median(outcomes.map((o) => o.reply.words)) ?? 0);
|
|
75
|
+
return splitBy(
|
|
76
|
+
outcomes,
|
|
77
|
+
`under ${cut} words`,
|
|
78
|
+
`${cut} words or more`,
|
|
79
|
+
(r) => r.words < cut
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function splits(events: ReactedTurn[]): Split[] {
|
|
84
|
+
const reacted = outcomes(events);
|
|
85
|
+
return [
|
|
86
|
+
lengthSplit(reacted),
|
|
87
|
+
splitBy(reacted, "with a list", "without a list", (r) => r.listItems > 0),
|
|
88
|
+
splitBy(reacted, "with headings", "without headings", (r) => r.headings > 0),
|
|
89
|
+
splitBy(reacted, "ending with a question", "not ending with one", (r) => r.asked),
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function approvedShare(s: Side): number {
|
|
94
|
+
return s.replies ? s.approved / s.replies : 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function hasEnoughReplies(split: Split): boolean {
|
|
98
|
+
return Math.min(split.has.replies, split.lacks.replies) >= MIN_REPLIES_PER_SIDE;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function isEvidence(split: Split): boolean {
|
|
102
|
+
const gap = Math.abs(approvedShare(split.has) - approvedShare(split.lacks));
|
|
103
|
+
return hasEnoughReplies(split) && gap >= MIN_GAP;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function reactedReplies(events: ReactedTurn[]): number {
|
|
107
|
+
return outcomes(events).length;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The length split, when it is evidence that the user approves shorter replies. */
|
|
111
|
+
export function shorterApprovedMore(events: ReactedTurn[]): Split | null {
|
|
112
|
+
const length = lengthSplit(outcomes(events));
|
|
113
|
+
if (!isEvidence(length)) return null;
|
|
114
|
+
return approvedShare(length.has) > approvedShare(length.lacks) ? length : null;
|
|
115
|
+
}
|
|
@@ -6,7 +6,21 @@
|
|
|
6
6
|
|
|
7
7
|
import { extractKeywords, similarity } from "./text-similarity";
|
|
8
8
|
|
|
9
|
-
export type Reaction =
|
|
9
|
+
export type Reaction =
|
|
10
|
+
| "corrected"
|
|
11
|
+
| "repeated"
|
|
12
|
+
| "approved"
|
|
13
|
+
| "go-ahead"
|
|
14
|
+
| "new-topic"
|
|
15
|
+
| "follow-up";
|
|
16
|
+
|
|
17
|
+
/** Logged with each reaction, so a log read under older rules is not read with today's meaning. */
|
|
18
|
+
export const REACTION_RULES = 2;
|
|
19
|
+
const GO_AHEAD_SPLIT = 2;
|
|
20
|
+
|
|
21
|
+
export function approvalIsKnown(turn: { reactionRules?: number }): boolean {
|
|
22
|
+
return (turn.reactionRules ?? 1) >= GO_AHEAD_SPLIT;
|
|
23
|
+
}
|
|
10
24
|
|
|
11
25
|
const CORRECTION_RE =
|
|
12
26
|
/^(?:(?:no|nope|wrong)(?:[,.!]|$)|(?:that'?s|this is|it'?s) (?:wrong|not)\b|not what i\b|i said\b|i told you\b|you forgot\b|you missed\b|still (?:broken|failing|wrong)\b|i don'?t see (?:the|your) (?!(?:problem|issue|point|harm|difference|need|reason)\b))/i;
|
|
@@ -15,7 +29,10 @@ const CLAIM_DISPUTED_RE =
|
|
|
15
29
|
/\byou(?:'re| are) (?:partially |partly |completely |totally )?wrong\b|\b(?:isn'?t|wasn'?t|aren'?t|weren'?t) (?:added|included|fixed|pushed|committed|applied|merged)\b/i;
|
|
16
30
|
|
|
17
31
|
const APPROVAL_RE =
|
|
18
|
-
/^(?:(?:understood|got it|ok(?:ay)?),?\s+)?(?:good|great|nice|perfect|cool|amazing|awesome|excellent|brilliant|works|it works|thanks|thank you|thx|y+e+s+|yep|yeah|yup|sure|exactly|correct|agreed|lgtm|well done|love it|sounds good|looks good|that'?s it|(?:it|this|that)
|
|
32
|
+
/^(?:(?:understood|got it|ok(?:ay)?),?\s+)?(?:good|great|nice|perfect|cool|amazing|awesome|excellent|brilliant|works|it works|thanks|thank you|thx|y+e+s+|yep|yeah|yup|sure|exactly|correct|agreed|lgtm|well done|love it|sounds good|looks good|that'?s it|(?:it|this|that)(?: is|'s) (?:fine|good|great|right))\b/i;
|
|
33
|
+
|
|
34
|
+
const PRAISE_RE =
|
|
35
|
+
/\b(?:good|great|nice|perfect|cool|amazing|awesome|excellent|brilliant|works|thanks|thank you|thx|exactly|correct|agreed|lgtm|well done|good job|love it|sounds good|looks (?:good|right)|that'?s it|(?:it|this|that)(?: is|'s) (?:fine|good|great|right))\b/i;
|
|
19
36
|
|
|
20
37
|
const GO_AHEAD_RE =
|
|
21
38
|
/^(?:go ahead|go|do it|proceed|continue|merge|push|ship|commit|build|start|fix (?:all|them|both)|approved|let'?s (?:see|do|go|build|start|fix|ship)|(?:add|do|fix) (?:it )?(?:pls|please))\b/i;
|
|
@@ -51,7 +68,7 @@ function sentences(text: string): string[] {
|
|
|
51
68
|
.filter(Boolean);
|
|
52
69
|
}
|
|
53
70
|
|
|
54
|
-
function
|
|
71
|
+
function isAcceptance(text: string): boolean {
|
|
55
72
|
const trimmed = text.trim();
|
|
56
73
|
if (trimmed.includes("?") || FAILURE_RE.test(trimmed)) return false;
|
|
57
74
|
if (HEDGE_RE.test(trimmed.split(/[.!?\n]/)[0])) return false;
|
|
@@ -61,6 +78,11 @@ function isApproval(text: string): boolean {
|
|
|
61
78
|
return isShortGoAhead(all[0] ?? "") && !HEDGE_RE.test(trimmed);
|
|
62
79
|
}
|
|
63
80
|
|
|
81
|
+
/** A yes or a go only gives permission to continue; approval judges the work itself. */
|
|
82
|
+
function praisesTheResult(text: string): boolean {
|
|
83
|
+
return PRAISE_RE.test(text);
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
/** Word overlap was measured against real messages and does not separate topics; only saying so does. */
|
|
65
87
|
function isNewTopic(text: string): boolean {
|
|
66
88
|
return NEW_TOPIC_RE.test(text.trim());
|
|
@@ -69,7 +91,7 @@ function isNewTopic(text: string): boolean {
|
|
|
69
91
|
export function reactionTo(text: string, previousPrompt: string | undefined): Reaction {
|
|
70
92
|
if (isCorrection(text)) return "corrected";
|
|
71
93
|
if (isRepeat(text, previousPrompt)) return "repeated";
|
|
72
|
-
if (
|
|
94
|
+
if (isAcceptance(text)) return praisesTheResult(text) ? "approved" : "go-ahead";
|
|
73
95
|
if (isNewTopic(text)) return "new-topic";
|
|
74
96
|
return "follow-up";
|
|
75
97
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { resolve } from "node:path";
|
|
14
14
|
import { currentAttribution } from "./actor";
|
|
15
15
|
import { hearsPromptContext } from "./agent";
|
|
16
|
+
import { asksForLess, followedHint, ignoredHintReminder } from "./interaction-hint-check";
|
|
16
17
|
import {
|
|
17
18
|
type Baseline,
|
|
18
19
|
DEFAULT_BASELINE,
|
|
@@ -21,9 +22,11 @@ import {
|
|
|
21
22
|
moodReminder,
|
|
22
23
|
readMood,
|
|
23
24
|
} from "./interaction-mood";
|
|
25
|
+
import { shorterApprovedMore } from "./interaction-preferences";
|
|
24
26
|
import {
|
|
25
27
|
isCorrection,
|
|
26
28
|
isRepeat,
|
|
29
|
+
REACTION_RULES,
|
|
27
30
|
type Reaction,
|
|
28
31
|
reactionTo,
|
|
29
32
|
} from "./interaction-reaction";
|
|
@@ -49,8 +52,10 @@ export interface TurnEvent extends MoodTurn {
|
|
|
49
52
|
weekday: string;
|
|
50
53
|
reply: Omit<ReplyShape, "at"> | null;
|
|
51
54
|
reaction: Reaction | null;
|
|
55
|
+
reactionRules?: number;
|
|
52
56
|
mood?: string;
|
|
53
57
|
hinted?: boolean;
|
|
58
|
+
complied?: boolean;
|
|
54
59
|
runtime?: string;
|
|
55
60
|
}
|
|
56
61
|
|
|
@@ -126,6 +131,7 @@ function measureTurn(
|
|
|
126
131
|
corrected: isCorrection(text),
|
|
127
132
|
reply: reply ? replyFeatures(reply) : null,
|
|
128
133
|
reaction: reply ? reactionTo(text, track.lastPrompt) : null,
|
|
134
|
+
...(reply && { reactionRules: REACTION_RULES }),
|
|
129
135
|
};
|
|
130
136
|
}
|
|
131
137
|
|
|
@@ -166,13 +172,21 @@ export function turnsSince(since: Date): TurnEvent[] {
|
|
|
166
172
|
.filter((e) => e.ts >= sinceTs);
|
|
167
173
|
}
|
|
168
174
|
|
|
169
|
-
/**
|
|
170
|
-
function
|
|
175
|
+
/** This month and last, outside the session being judged. */
|
|
176
|
+
function historyOutside(session: string, now: Date): TurnEvent[] {
|
|
171
177
|
const lastMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
178
|
+
return [...readEvents(monthFile(lastMonth)), ...readEvents(monthFile(now))].filter(
|
|
179
|
+
(e) => e.session !== session
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function usualReplyWords(history: TurnEvent[]): number | null {
|
|
184
|
+
const replies = history.flatMap((e) => (e.reply ? [e.reply.words] : []));
|
|
185
|
+
return replies.length >= BASELINE_MIN_SAMPLES ? median(replies) : null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The user's own normal, measured on their history. */
|
|
189
|
+
function baseline(events: TurnEvent[]): Baseline {
|
|
176
190
|
const gaps = events
|
|
177
191
|
.filter((e) => e.gapSec !== null && !e.afterBreak)
|
|
178
192
|
.map((e) => e.gapSec);
|
|
@@ -239,7 +253,25 @@ function sampleReaction(event: TurnEvent, text: string, track: SessionTrack): vo
|
|
|
239
253
|
});
|
|
240
254
|
}
|
|
241
255
|
|
|
242
|
-
/**
|
|
256
|
+
/** Only while the user still wants less: once the mood is back to usual, length is not the point. */
|
|
257
|
+
function reminderForIgnoredHint(
|
|
258
|
+
complied: boolean | null,
|
|
259
|
+
moodKey: string,
|
|
260
|
+
replyWords: number | undefined,
|
|
261
|
+
usualReply: number | null,
|
|
262
|
+
history: TurnEvent[]
|
|
263
|
+
): string | null {
|
|
264
|
+
if (complied !== false || !asksForLess(moodKey)) return null;
|
|
265
|
+
if (replyWords === undefined || usualReply === null) return null;
|
|
266
|
+
return ignoredHintReminder(replyWords, usualReply, shorterApprovedMore(history));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function joinReminders(...reminders: (string | null)[]): string | null {
|
|
270
|
+
const said = reminders.filter((r): r is string => r !== null);
|
|
271
|
+
return said.length ? said.join("\n") : null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Logs the turn and returns a reminder when the session's picture changed, or a hint went unheeded. */
|
|
243
275
|
export function observeTurn(
|
|
244
276
|
prompt: string,
|
|
245
277
|
session: string | undefined,
|
|
@@ -250,14 +282,24 @@ export function observeTurn(
|
|
|
250
282
|
const text = stripInjectedTags(prompt);
|
|
251
283
|
if (!text) return null;
|
|
252
284
|
const track = trackOf(session);
|
|
285
|
+
const history = historyOutside(session, now);
|
|
286
|
+
const usualReply = usualReplyWords(history);
|
|
253
287
|
const event = measureTurn(text, session, track, now, channel);
|
|
288
|
+
const replyWords = event.reply?.words;
|
|
289
|
+
const complied = hearsPromptContext()
|
|
290
|
+
? followedHint(track.mood, replyWords, usualReply)
|
|
291
|
+
: null;
|
|
254
292
|
const recent = [...track.recent, event].slice(-RECENT_KEPT);
|
|
255
|
-
const mood = readMood(recent, baseline(
|
|
256
|
-
const reminder =
|
|
293
|
+
const mood = readMood(recent, baseline(history));
|
|
294
|
+
const reminder = joinReminders(
|
|
295
|
+
moodReminder(mood, track.mood ?? ""),
|
|
296
|
+
reminderForIgnoredHint(complied, mood.key, replyWords, usualReply, history)
|
|
297
|
+
);
|
|
257
298
|
appendEvent({
|
|
258
299
|
...event,
|
|
259
300
|
mood: mood.key,
|
|
260
301
|
hinted: reminder !== null && hearsPromptContext(),
|
|
302
|
+
...(complied !== null && { complied }),
|
|
261
303
|
});
|
|
262
304
|
sampleReaction(event, text, track);
|
|
263
305
|
writeTrack(session, {
|
|
@@ -5,14 +5,30 @@
|
|
|
5
5
|
|
|
6
6
|
import type { TurnEvent } from "../../hooks/lib/interaction";
|
|
7
7
|
import { median } from "../../hooks/lib/interaction-mood";
|
|
8
|
+
import {
|
|
9
|
+
approvedShare,
|
|
10
|
+
hasEnoughReplies,
|
|
11
|
+
isEvidence,
|
|
12
|
+
reactedReplies,
|
|
13
|
+
type Side,
|
|
14
|
+
type Split,
|
|
15
|
+
splits,
|
|
16
|
+
} from "../../hooks/lib/interaction-preferences";
|
|
17
|
+
import { approvalIsKnown } from "../../hooks/lib/interaction-reaction";
|
|
8
18
|
|
|
9
19
|
const LABELS = ["short", "long", "fast", "skimming", "friction"] as const;
|
|
10
20
|
|
|
11
21
|
interface ReplyUnderMood {
|
|
12
22
|
labels: string[];
|
|
13
23
|
words: number;
|
|
14
|
-
approved: boolean;
|
|
24
|
+
approved: boolean | null;
|
|
15
25
|
corrected: boolean;
|
|
26
|
+
complied?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface HintFollowing {
|
|
30
|
+
checked: number;
|
|
31
|
+
followed: number;
|
|
16
32
|
}
|
|
17
33
|
|
|
18
34
|
interface ReplyStats {
|
|
@@ -20,6 +36,7 @@ interface ReplyStats {
|
|
|
20
36
|
medianWords: number | null;
|
|
21
37
|
approvedShare: number;
|
|
22
38
|
correctedShare: number;
|
|
39
|
+
followed: HintFollowing;
|
|
23
40
|
}
|
|
24
41
|
|
|
25
42
|
interface AgentCounts {
|
|
@@ -36,8 +53,20 @@ export interface InteractionSummary {
|
|
|
36
53
|
reactions: Record<string, number>;
|
|
37
54
|
hints: Record<string, number>;
|
|
38
55
|
unlogged: number;
|
|
56
|
+
readBeforeGoAhead: number;
|
|
39
57
|
usual: ReplyStats;
|
|
40
58
|
byLabel: Record<string, ReplyStats>;
|
|
59
|
+
approvals: Approvals;
|
|
60
|
+
approvalsByChannel: Record<string, Approvals>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Approvals {
|
|
64
|
+
reacted: number;
|
|
65
|
+
splits: Split[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function approvals(events: TurnEvent[]): Approvals {
|
|
69
|
+
return { reacted: reactedReplies(events), splits: splits(events) };
|
|
41
70
|
}
|
|
42
71
|
|
|
43
72
|
function tally(values: string[]): Record<string, number> {
|
|
@@ -54,8 +83,15 @@ function stats(replies: ReplyUnderMood[]): ReplyStats {
|
|
|
54
83
|
return {
|
|
55
84
|
replies: replies.length,
|
|
56
85
|
medianWords: median(replies.map((r) => r.words)),
|
|
57
|
-
approvedShare: share(
|
|
86
|
+
approvedShare: share(
|
|
87
|
+
replies.filter((r) => r.approved !== null),
|
|
88
|
+
(r) => r.approved === true
|
|
89
|
+
),
|
|
58
90
|
correctedShare: share(replies, (r) => r.corrected),
|
|
91
|
+
followed: {
|
|
92
|
+
checked: replies.filter((r) => r.complied !== undefined).length,
|
|
93
|
+
followed: replies.filter((r) => r.complied === true).length,
|
|
94
|
+
},
|
|
59
95
|
};
|
|
60
96
|
}
|
|
61
97
|
|
|
@@ -69,14 +105,20 @@ function repliesUnderMood(events: TurnEvent[]): ReplyUnderMood[] {
|
|
|
69
105
|
replies.push({
|
|
70
106
|
labels: mood ? mood.split(",") : [],
|
|
71
107
|
words: e.reply.words,
|
|
72
|
-
approved: e.reaction === "approved",
|
|
108
|
+
approved: approvalIsKnown(e) ? e.reaction === "approved" : null,
|
|
73
109
|
corrected: e.reaction === "corrected" || e.reaction === "repeated",
|
|
110
|
+
complied: e.complied,
|
|
74
111
|
});
|
|
75
112
|
lastMood.set(e.session, e.mood);
|
|
76
113
|
}
|
|
77
114
|
return replies;
|
|
78
115
|
}
|
|
79
116
|
|
|
117
|
+
function reactionName(e: TurnEvent): string {
|
|
118
|
+
if (e.reaction === "approved" && !approvalIsKnown(e)) return "approved or go-ahead";
|
|
119
|
+
return e.reaction ?? "";
|
|
120
|
+
}
|
|
121
|
+
|
|
80
122
|
/** A turn carries the reply that answered the turn before it, so a filed reply shows up there. */
|
|
81
123
|
function byAgent(events: TurnEvent[]): Record<string, AgentCounts> {
|
|
82
124
|
const agents: Record<string, AgentCounts> = {};
|
|
@@ -99,7 +141,8 @@ export function summarize(events: TurnEvent[]): InteractionSummary {
|
|
|
99
141
|
sessions: new Set(events.map((e) => e.session)).size,
|
|
100
142
|
channels: tally(events.map((e) => e.channel ?? "unknown")),
|
|
101
143
|
agents: byAgent(events),
|
|
102
|
-
reactions: tally(events.flatMap((e) => (e.reaction ? [e
|
|
144
|
+
reactions: tally(events.flatMap((e) => (e.reaction ? [reactionName(e)] : []))),
|
|
145
|
+
readBeforeGoAhead: events.filter((e) => e.reaction && !approvalIsKnown(e)).length,
|
|
103
146
|
hints: tally(hinted.map((e) => e.mood || "back to usual")),
|
|
104
147
|
unlogged: events.filter((e) => e.mood === undefined).length,
|
|
105
148
|
usual: stats(replies.filter((r) => r.labels.length === 0)),
|
|
@@ -109,9 +152,20 @@ export function summarize(events: TurnEvent[]): InteractionSummary {
|
|
|
109
152
|
stats(replies.filter((r) => r.labels.includes(label))),
|
|
110
153
|
])
|
|
111
154
|
),
|
|
155
|
+
approvals: approvals(events),
|
|
156
|
+
approvalsByChannel: approvalsByChannel(events),
|
|
112
157
|
};
|
|
113
158
|
}
|
|
114
159
|
|
|
160
|
+
function approvalsByChannel(events: TurnEvent[]): Record<string, Approvals> {
|
|
161
|
+
const channels = Map.groupBy(events, (e) => e.channel ?? "unknown");
|
|
162
|
+
return Object.fromEntries(
|
|
163
|
+
[...channels]
|
|
164
|
+
.map(([channel, turns]) => [channel, approvals(turns)] as const)
|
|
165
|
+
.filter(([, a]) => a.reacted > 0)
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
115
169
|
function pct(n: number): string {
|
|
116
170
|
return `${Math.round(n * 100)}%`;
|
|
117
171
|
}
|
|
@@ -133,18 +187,52 @@ function words(s: ReplyStats): string {
|
|
|
133
187
|
|
|
134
188
|
function labelLine(label: string, s: ReplyStats, usual: ReplyStats): string {
|
|
135
189
|
if (s.replies === 0) return ` ${label.padEnd(9)} no replies yet`;
|
|
136
|
-
|
|
190
|
+
const parts = [
|
|
137
191
|
` ${label.padEnd(9)} ${s.replies} replies`,
|
|
138
192
|
`median ${words(s)} words (usual ${words(usual)})`,
|
|
139
193
|
`approved ${pct(s.approvedShare)} (usual ${pct(usual.approvedShare)})`,
|
|
140
194
|
`corrected ${pct(s.correctedShare)} (usual ${pct(usual.correctedShare)})`,
|
|
141
|
-
]
|
|
195
|
+
];
|
|
196
|
+
if (s.followed.checked)
|
|
197
|
+
parts.push(`followed ${s.followed.followed} of ${s.followed.checked}`);
|
|
198
|
+
return parts.join(" · ");
|
|
142
199
|
}
|
|
143
200
|
|
|
144
201
|
function plural(n: number, noun: string, nouns = `${noun}s`): string {
|
|
145
202
|
return `${n} ${n === 1 ? noun : nouns}`;
|
|
146
203
|
}
|
|
147
204
|
|
|
205
|
+
function approvedOf(s: Side): string {
|
|
206
|
+
return `${pct(approvedShare(s))} of ${s.replies}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function splitLine(split: Split): string {
|
|
210
|
+
const head = ` ${split.shape}:`;
|
|
211
|
+
if (!hasEnoughReplies(split))
|
|
212
|
+
return `${head} too few replies (${split.has.replies} against ${split.lacks.replies})`;
|
|
213
|
+
if (!isEvidence(split))
|
|
214
|
+
return `${head} no real difference (${approvedOf(split.has)} against ${approvedOf(split.lacks)})`;
|
|
215
|
+
return `${head} approved ${approvedOf(split.has)} · ${split.opposite}: ${approvedOf(split.lacks)}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function approvalLines(title: string, a: Approvals): string[] {
|
|
219
|
+
return [
|
|
220
|
+
"",
|
|
221
|
+
`${title}, from ${plural(a.reacted, "reacted reply", "reacted replies")}:`,
|
|
222
|
+
...a.splits.map(splitLine),
|
|
223
|
+
];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function allApprovalLines(summary: InteractionSummary): string[] {
|
|
227
|
+
if (summary.approvals.reacted === 0) return [];
|
|
228
|
+
return [
|
|
229
|
+
...approvalLines("What gets approved", summary.approvals),
|
|
230
|
+
...Object.entries(summary.approvalsByChannel).flatMap(([channel, a]) =>
|
|
231
|
+
approvalLines(`On ${channel}`, a)
|
|
232
|
+
),
|
|
233
|
+
];
|
|
234
|
+
}
|
|
235
|
+
|
|
148
236
|
function agentLines(agents: Record<string, AgentCounts>): string[] {
|
|
149
237
|
return Object.entries(agents)
|
|
150
238
|
.sort(([, a], [, b]) => b.turns - a.turns)
|
|
@@ -171,11 +259,17 @@ export function reportLines(summary: InteractionSummary, days: number): string[]
|
|
|
171
259
|
"",
|
|
172
260
|
"Replies written while a label was active, against replies while none was:",
|
|
173
261
|
...LABELS.map((label) => labelLine(label, summary.byLabel[label], summary.usual)),
|
|
262
|
+
...allApprovalLines(summary),
|
|
174
263
|
];
|
|
175
264
|
if (summary.unlogged)
|
|
176
265
|
lines.push(
|
|
177
266
|
"",
|
|
178
267
|
`${summary.unlogged} older turns were logged before moods were, and are left out of the comparison.`
|
|
179
268
|
);
|
|
269
|
+
if (summary.readBeforeGoAhead)
|
|
270
|
+
lines.push(
|
|
271
|
+
"",
|
|
272
|
+
`${summary.readBeforeGoAhead} older replies were read before go-ahead was split from approval, and are left out of the approval figures.`
|
|
273
|
+
);
|
|
180
274
|
return lines;
|
|
181
275
|
}
|