Skip to content

Commit 2093bc2

Browse files
authored
Merge pull request #1 from HumanSignal/fb-leap-1810/pausing_annotator
LEAP-1810: Pause annotator by behavior pattern
2 parents f560f5b + 2a3c121 commit 2093bc2

File tree

1 file changed

+108
-0
lines changed

1 file changed

+108
-0
lines changed

pausing_annotator/pause.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
const rules = {
2+
timesInARow: (times) => (items, field) => {
3+
if (items.length < times) return false
4+
const last = items.at(-1).values[field]
5+
return items.slice(-times).every((item) => item.values[field] === last)
6+
? `Too many similar values for ${field}`
7+
: false
8+
},
9+
tooSimilar: (deviation = 0.1, max_count = 10) => (items, field) => {
10+
if (items.length < max_count) return false
11+
const values = items.map((item) => item.values[field])
12+
const points = values.map((v) => values.indexOf(v))
13+
return calcDeviation(points) < deviation
14+
? `Too similar values for ${field}`
15+
: false
16+
},
17+
tooFast: (minutes = 10, times = 20) => (items) => {
18+
if (items.length < times) return false
19+
const last = items.at(-1)
20+
const first = items.at(-times)
21+
return last.created_at - first.created_at < minutes * 60
22+
? `Too fast annotations`
23+
: false
24+
}
25+
}
26+
27+
/****** RULES FOR SUBMITTED ANNOTATIONS ******/
28+
const RULES = {
29+
fields: {
30+
comment: [rules.timesInARow(3)],
31+
sentiment: [rules.tooSimilar()],
32+
},
33+
global: [rules.tooFast()],
34+
}
35+
36+
const project = DM.project.id
37+
if (!DM.project) return;
38+
39+
const key = ["__pause_stats", project].join("|")
40+
const fields = Object.keys(RULES.fields)
41+
// { sentiment: ["positive", ...], comment: undefined }
42+
const values = Object.fromEntries(fields.map(
43+
(field) => [field, DM.project.parsed_label_config[field]?.labels],
44+
))
45+
46+
function calcDeviation(data) {
47+
const n = data.length;
48+
// we normalize indices from -n/2 to n/2 so meanX is 0
49+
const mid = n / 2;
50+
const mean = data.reduce((a, b) => a + b) / n;
51+
52+
const k = data.reduce((a, b, i) => a + (b - mean) * (i - mid), 0) / data.reduce((a, b, i) => a + (i - mid) ** 2, 0);
53+
const mse = data.reduce((a, b, i) => a + (b - (k * (i - mid) + mean)) ** 2, 0) / n;
54+
55+
return Math.abs(mse);
56+
}
57+
58+
LSI.on("submitAnnotation", (_store, ann) => {
59+
const results = ann.serializeAnnotation()
60+
// { sentiment: "positive", comment: "good" }
61+
const values = {}
62+
fields.forEach((field) => {
63+
const value = results.find((r) => r.from_name === field)?.value
64+
if (!value) return;
65+
if (value.choices) values[field] = value.choices.join("|")
66+
else if (value.text) values[field] = value.text
67+
})
68+
let stats = []
69+
try {
70+
stats = JSON.parse(localStorage.getItem(key)) ?? []
71+
} catch(e) {}
72+
stats.push({ values, created_at: Date.now() / 1000 })
73+
74+
for (const rule of RULES.global) {
75+
if (rule(stats)) {
76+
localStorage.setItem(key, "[]");
77+
pause("Wow, cowboy, not so fast!");
78+
return;
79+
}
80+
}
81+
82+
for (const field of fields) {
83+
if (!values[field]) continue;
84+
for (const rule of RULES.fields[field]) {
85+
const result = rule(stats, field)
86+
if (result) {
87+
localStorage.setItem(key, "[]");
88+
pause(result);
89+
return;
90+
}
91+
}
92+
}
93+
94+
localStorage.setItem(key, JSON.stringify(stats));
95+
});
96+
97+
function pause(verbose_reason) {
98+
const body = {
99+
reason: "CUSTOM_SCRIPT",
100+
verbose_reason,
101+
}
102+
const options = {
103+
method: "POST",
104+
headers: { "Content-Type": "application/json" },
105+
body: JSON.stringify(body),
106+
}
107+
fetch(`/api/projects/${project}/members/${Htx.user.id}/pauses`, options)
108+
}

0 commit comments

Comments
 (0)