behavior-wrapped 0.9.1 → 0.10.1
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/README.md +1 -1
- package/dist/assets/index-BaiNv0uD.js +11 -0
- package/dist/assets/index-D-wey53w.css +1 -0
- package/dist/index.html +2 -2
- package/docs/privacy.md +5 -1
- package/docs/research-donations.md +4 -2
- package/package.json +1 -1
- package/server/analysis.mjs +2 -2
- package/server/donation-session-integrity.mjs +20 -0
- package/server/encrypted-donation-schema.mjs +7 -3
- package/server/interaction-evidence.mjs +5 -2
- package/server/interaction-feedback.mjs +78 -0
- package/server/interaction-tone.mjs +18 -4
- package/server/launcher.mjs +46 -2
- package/server/research-donation-crypto.mjs +1 -0
- package/server/research-donation-schema.mjs +34 -1
- package/dist/assets/index-ajQ5w6T5.js +0 -11
- package/dist/assets/index-ddSuXgMW.css +0 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { interactionToneCandidateText } from "./interaction-tone.mjs";
|
|
2
|
+
|
|
3
|
+
export const INTERACTION_FEEDBACK_LABELS = new Set(["yelling", "thanking", "neither", "unsure"]);
|
|
4
|
+
|
|
5
|
+
function contentBlocks(record) {
|
|
6
|
+
const content = record?.message?.content ?? record?.content;
|
|
7
|
+
if (Array.isArray(content)) return content;
|
|
8
|
+
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function visibleText(record) {
|
|
13
|
+
return contentBlocks(record).filter((block) => block?.type === "text").map((block) => block.text || "").join("\n").trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function interactionFeedbackId(kind, index) {
|
|
17
|
+
const label = kind === "frustrated" ? "yelling" : kind === "grateful" ? "thanking" : null;
|
|
18
|
+
return label && Number.isInteger(index) && index >= 0 ? `${label}-${index + 1}` : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function feedbackCoordinates(value) {
|
|
22
|
+
const match = String(value || "").match(/^(yelling|thanking)-([1-9][0-9]{0,2})$/);
|
|
23
|
+
return match ? { originalLabel: match[1], index: Number(match[2]) - 1 } : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fallbackJudgedText(reference, records) {
|
|
27
|
+
if (typeof reference?.judgedText === "string" && reference.judgedText) return reference.judgedText;
|
|
28
|
+
const location = reference?.location || {};
|
|
29
|
+
let record = Number.isInteger(location.recordIndex) ? records?.[location.recordIndex] : null;
|
|
30
|
+
if (!record || record.type !== "user") record = records?.find((item) => item?.type === "user" && item?.timestamp === location.timestamp);
|
|
31
|
+
return interactionToneCandidateText(visibleText(record)) || "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveInteractionFeedback(report, feedbackId, recordsById = new Map()) {
|
|
35
|
+
const coordinates = feedbackCoordinates(feedbackId);
|
|
36
|
+
if (!coordinates) return null;
|
|
37
|
+
const kind = coordinates.originalLabel === "yelling" ? "frustrated" : "grateful";
|
|
38
|
+
const reference = report?.interactionReview?.[kind]?.[coordinates.index];
|
|
39
|
+
const sessionId = reference?.location?.sessionId;
|
|
40
|
+
if (!reference || typeof sessionId !== "string" || !(report?.sessionIds || []).includes(sessionId)) return null;
|
|
41
|
+
const review = report.interactionReview || {};
|
|
42
|
+
return {
|
|
43
|
+
id: feedbackId,
|
|
44
|
+
originalLabel: coordinates.originalLabel,
|
|
45
|
+
sessionId,
|
|
46
|
+
candidateId: typeof reference.candidateId === "string" ? reference.candidateId.slice(0, 64) : "unknown",
|
|
47
|
+
judgedText: fallbackJudgedText(reference, recordsById.get(sessionId)).slice(0, 240),
|
|
48
|
+
occurrences: Number.isInteger(reference.occurrences) && reference.occurrences > 0 ? Math.min(reference.occurrences, 1_000_000) : 1,
|
|
49
|
+
confidence: Number.isFinite(reference.confidence) ? Math.max(0, Math.min(1, reference.confidence)) : 1,
|
|
50
|
+
judge: {
|
|
51
|
+
model: typeof review.model === "string" ? review.model.slice(0, 120) : "unknown",
|
|
52
|
+
promptVersion: Number.isInteger(review.promptVersion) && review.promptVersion > 0 ? review.promptVersion : 1,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function publicInteractionFeedback(value) {
|
|
58
|
+
if (!value) return null;
|
|
59
|
+
const { sessionId, ...safe } = value;
|
|
60
|
+
return safe;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function sanitizeInteractionFeedbackSubmission(value, trusted) {
|
|
64
|
+
if (!trusted || value?.feedbackId !== trusted.id || !INTERACTION_FEEDBACK_LABELS.has(value?.correctedLabel)) return null;
|
|
65
|
+
const note = typeof value.note === "string"
|
|
66
|
+
? value.note.normalize("NFKC").replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 1_000)
|
|
67
|
+
: "";
|
|
68
|
+
return {
|
|
69
|
+
originalLabel: trusted.originalLabel,
|
|
70
|
+
correctedLabel: value.correctedLabel,
|
|
71
|
+
candidateId: trusted.candidateId,
|
|
72
|
+
judgedText: trusted.judgedText,
|
|
73
|
+
occurrences: trusted.occurrences,
|
|
74
|
+
confidence: trusted.confidence,
|
|
75
|
+
judge: trusted.judge,
|
|
76
|
+
...(note ? { note } : {}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -6,6 +6,7 @@ import { BEHAVIOR_WRAPPED_ORIGIN } from "./origins.mjs";
|
|
|
6
6
|
export const INTERACTION_TONE_RELAY_URL = `${BEHAVIOR_WRAPPED_ORIGIN}/v1/interaction-tone`;
|
|
7
7
|
export const INTERACTION_TONE_MAX_CANDIDATES = 120;
|
|
8
8
|
export const INTERACTION_TONE_BATCH_SIZE = 30;
|
|
9
|
+
export const INTERACTION_TONE_PROMPT_VERSION = 1;
|
|
9
10
|
const MAX_TEXT_LENGTH = 240;
|
|
10
11
|
const MIN_CONFIDENCE = 0.75;
|
|
11
12
|
const JUDGE_TIMEOUT_MS = 90_000;
|
|
@@ -55,6 +56,10 @@ function safeInteractionExcerpt(value) {
|
|
|
55
56
|
return isShareSafeInteractionText(excerpt) ? excerpt : null;
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
export function interactionToneCandidateText(value) {
|
|
60
|
+
return safeInteractionExcerpt(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
58
63
|
function priority(text, occurrences) {
|
|
59
64
|
const words = text.match(/\p{L}+/gu)?.length || 0;
|
|
60
65
|
return Number(strongFrustrationPattern.test(text)) * 20
|
|
@@ -247,10 +252,16 @@ function resultFromSelection(candidates, selection, { model, provider, latencyMs
|
|
|
247
252
|
occurrences: byId.get(item.candidate_id).occurrences,
|
|
248
253
|
confidence: item.confidence,
|
|
249
254
|
}));
|
|
250
|
-
const reviewRefs = (items) => items.flatMap((item) =>
|
|
251
|
-
|
|
252
|
-
location
|
|
253
|
-
|
|
255
|
+
const reviewRefs = (items) => items.flatMap((item) => {
|
|
256
|
+
const candidate = byId.get(item.candidate_id);
|
|
257
|
+
return (candidate.locations || []).map((location) => ({
|
|
258
|
+
candidateId: item.candidate_id,
|
|
259
|
+
judgedText: candidate.text,
|
|
260
|
+
occurrences: candidate.occurrences,
|
|
261
|
+
confidence: item.confidence,
|
|
262
|
+
location,
|
|
263
|
+
}));
|
|
264
|
+
});
|
|
254
265
|
return {
|
|
255
266
|
frustratedMessages: count(selection.frustrated),
|
|
256
267
|
gratefulMessages: count(selection.grateful),
|
|
@@ -261,6 +272,9 @@ function resultFromSelection(candidates, selection, { model, provider, latencyMs
|
|
|
261
272
|
privateMatches: { frustrated: matches(selection.frustrated), grateful: matches(selection.grateful) },
|
|
262
273
|
review: {
|
|
263
274
|
format: "behavior-wrapped-interaction-review-v1",
|
|
275
|
+
model,
|
|
276
|
+
provider,
|
|
277
|
+
promptVersion: INTERACTION_TONE_PROMPT_VERSION,
|
|
264
278
|
frustrated: reviewRefs(selection.frustrated),
|
|
265
279
|
grateful: reviewRefs(selection.grateful),
|
|
266
280
|
},
|
package/server/launcher.mjs
CHANGED
|
@@ -11,6 +11,8 @@ import { MAX_DONATION_BYTES } from "./research-donation-schema.mjs";
|
|
|
11
11
|
import { APP_VERSION, LOCAL_DONATION_PROTOCOL } from "./runtime-version.mjs";
|
|
12
12
|
import { makeWorkaroundEvidencePreview } from "./workaround-evidence.mjs";
|
|
13
13
|
import { makeInteractionEvidencePreview } from "./interaction-evidence.mjs";
|
|
14
|
+
import { publicInteractionFeedback, resolveInteractionFeedback, sanitizeInteractionFeedbackSubmission } from "./interaction-feedback.mjs";
|
|
15
|
+
import { donationSessionIntegrityError } from "./donation-session-integrity.mjs";
|
|
14
16
|
import { createIdleShutdownController } from "./local-helper-runtime.mjs";
|
|
15
17
|
import { canonicalSessionDirectoryLabels, openExternalUrl, supportedAgentNames } from "./platform.mjs";
|
|
16
18
|
|
|
@@ -162,13 +164,30 @@ const server = http.createServer(async (request, response) => {
|
|
|
162
164
|
const labels = new Map(publicCatalog(availableCatalog).sessions.map((session) => [session.id, session]));
|
|
163
165
|
return json(response, 200, makeInteractionEvidencePreview(report, records, labels));
|
|
164
166
|
}
|
|
167
|
+
const interactionFeedbackMatch = url.pathname.match(/^\/api\/reports\/([A-Za-z0-9_-]{8,32})\/interaction-feedback\/(yelling|thanking)-([1-9][0-9]{0,2})$/);
|
|
168
|
+
if (request.method === "GET" && interactionFeedbackMatch) {
|
|
169
|
+
const report = loadReport(interactionFeedbackMatch[1]);
|
|
170
|
+
if (!report) return json(response, 404, { error: "Saved report not found" });
|
|
171
|
+
const feedbackId = `${interactionFeedbackMatch[2]}-${interactionFeedbackMatch[3]}`;
|
|
172
|
+
const reference = resolveInteractionFeedback(report, feedbackId);
|
|
173
|
+
if (!reference) return json(response, 404, { error: "That interaction classification is no longer available." });
|
|
174
|
+
const availableCatalog = await catalogForRequest();
|
|
175
|
+
if (!availableCatalog.index.has(reference.sessionId)) return json(response, 404, { error: "The source session is no longer available on this device." });
|
|
176
|
+
const records = await chosenRecords([reference.sessionId], {}, availableCatalog);
|
|
177
|
+
const trusted = resolveInteractionFeedback(report, feedbackId, new Map(records.map((session) => [session.sessionId, session.records])));
|
|
178
|
+
return json(response, 200, { sessionIds: [reference.sessionId], feedback: publicInteractionFeedback(trusted), localPrivateSelection: true });
|
|
179
|
+
}
|
|
165
180
|
if (request.method === "POST" && url.pathname === "/api/donation-preview") {
|
|
166
181
|
const body = await readBody(request);
|
|
167
182
|
const report = loadReport(body.reportId);
|
|
168
183
|
if (!report) return json(response, 404, { error: "Saved report not found" });
|
|
169
184
|
const availableCatalog = await catalogForRequest();
|
|
185
|
+
const feedback = body.feedbackId ? resolveInteractionFeedback(report, body.feedbackId) : null;
|
|
186
|
+
if (body.feedbackId && !feedback) return json(response, 400, { error: "Invalid classifier-feedback selection." });
|
|
170
187
|
const allowed = new Set(report.sessionIds || []);
|
|
171
|
-
const ids =
|
|
188
|
+
const ids = feedback
|
|
189
|
+
? [feedback.sessionId].filter((id) => availableCatalog.index.has(id))
|
|
190
|
+
: Array.isArray(body.sessionIds) ? body.sessionIds.filter((id) => allowed.has(id) && availableCatalog.index.has(id)).slice(0, 250) : [];
|
|
172
191
|
const records = await chosenRecords(ids, {}, availableCatalog);
|
|
173
192
|
if (!records.length) return json(response, 400, { error: "Choose at least one available session." });
|
|
174
193
|
const labels = new Map(publicCatalog(availableCatalog).sessions.map((session) => [session.id, session]));
|
|
@@ -181,8 +200,33 @@ const server = http.createServer(async (request, response) => {
|
|
|
181
200
|
const body = await readBody(request, MAX_DONATION_BYTES + 1_000_000);
|
|
182
201
|
const report = loadReport(body?.donation?.reportId);
|
|
183
202
|
if (!report) return json(response, 404, { error: "Saved report not found" });
|
|
203
|
+
let donation = body.donation;
|
|
204
|
+
const suppliedSessions = Array.isArray(donation?.sessions) ? donation.sessions : [];
|
|
205
|
+
const suppliedIds = suppliedSessions.map((session) => session?.sessionId);
|
|
206
|
+
const allowed = new Set(report.sessionIds || []);
|
|
207
|
+
const uniqueIds = new Set(suppliedIds);
|
|
208
|
+
if (!suppliedIds.length || uniqueIds.size !== suppliedIds.length || suppliedIds.some((id) => !allowed.has(id))) return json(response, 400, { error: "Donated sessions must come from this report." });
|
|
209
|
+
const feedbackReference = body.feedback ? resolveInteractionFeedback(report, body.feedback.feedbackId) : null;
|
|
210
|
+
if (body.feedback && (!feedbackReference || suppliedSessions.length !== 1 || suppliedIds[0] !== feedbackReference.sessionId)) return json(response, 400, { error: "Classifier feedback must contain only its original session." });
|
|
211
|
+
const availableCatalog = await catalogForRequest();
|
|
212
|
+
if (suppliedIds.some((id) => !availableCatalog.index.has(id))) return json(response, 404, { error: "A selected source session is no longer available on this device." });
|
|
213
|
+
const records = await chosenRecords(suppliedIds, {}, availableCatalog);
|
|
214
|
+
const sourceSessions = makeDonationPreview(records, new Map(), { unredacted: true }).sessions;
|
|
215
|
+
const integrityError = donationSessionIntegrityError(suppliedSessions, sourceSessions);
|
|
216
|
+
if (integrityError) return json(response, 400, { error: integrityError });
|
|
217
|
+
if (body.feedback) {
|
|
218
|
+
const trusted = resolveInteractionFeedback(report, body.feedback.feedbackId, new Map(records.map((session) => [session.sessionId, session.records])));
|
|
219
|
+
const classifierFeedback = sanitizeInteractionFeedbackSubmission(body.feedback, trusted);
|
|
220
|
+
if (!classifierFeedback) return json(response, 400, { error: "Choose a valid corrected classification before donating." });
|
|
221
|
+
donation = {
|
|
222
|
+
...donation,
|
|
223
|
+
purpose: "classifier_feedback",
|
|
224
|
+
classifierFeedback,
|
|
225
|
+
consent: { ...donation.consent, classifierFeedback: true },
|
|
226
|
+
};
|
|
227
|
+
} else donation = { ...donation, purpose: "general_research", classifierFeedback: undefined };
|
|
184
228
|
if (demo) return json(response, 201, { accepted: true, donation_id: "demo-not-transmitted", demo: true });
|
|
185
|
-
const result = await submitResearchDonation(
|
|
229
|
+
const result = await submitResearchDonation(donation, {
|
|
186
230
|
clientId: getOrCreateClientId(),
|
|
187
231
|
endpoint: process.env.BEHAVIOR_WRAPPED_DONATION_URL || RESEARCH_DONATION_URL,
|
|
188
232
|
});
|
|
@@ -34,6 +34,7 @@ export function encryptResearchDonation(value, publicKey = RESEARCH_DONATION_PUB
|
|
|
34
34
|
encryption: { algorithm: DONATION_ENCRYPTION_ALGORITHM, keyId: DONATION_KEY_ID },
|
|
35
35
|
metadata: {
|
|
36
36
|
reportId: donation.reportId,
|
|
37
|
+
purpose: donation.purpose,
|
|
37
38
|
redactionMode: donation.redactionMode,
|
|
38
39
|
createdAt: donation.createdAt,
|
|
39
40
|
consentedAt: donation.consent.consentedAt,
|
|
@@ -7,10 +7,36 @@ function safeText(value, maximum) {
|
|
|
7
7
|
return typeof value === "string" ? value.normalize("NFKC").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").slice(0, maximum) : "";
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
function normalizeClassifierFeedback(value) {
|
|
11
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
12
|
+
if (!new Set(["yelling", "thanking"]).has(value.originalLabel) || !new Set(["yelling", "thanking", "neither", "unsure"]).has(value.correctedLabel)) return null;
|
|
13
|
+
const judgedText = safeText(value.judgedText, 240).trim();
|
|
14
|
+
const candidateId = safeText(value.candidateId, 64).trim();
|
|
15
|
+
const model = safeText(value.judge?.model, 120).trim();
|
|
16
|
+
const promptVersion = Number(value.judge?.promptVersion);
|
|
17
|
+
const occurrences = Number(value.occurrences);
|
|
18
|
+
const confidence = Number(value.confidence);
|
|
19
|
+
if (!judgedText || !candidateId || !model || !Number.isInteger(promptVersion) || promptVersion < 1 || promptVersion > 10_000) return null;
|
|
20
|
+
if (!Number.isInteger(occurrences) || occurrences < 1 || occurrences > 1_000_000 || !Number.isFinite(confidence) || confidence < 0 || confidence > 1) return null;
|
|
21
|
+
const note = safeText(value.note, 1_000).trim();
|
|
22
|
+
return {
|
|
23
|
+
originalLabel: value.originalLabel,
|
|
24
|
+
correctedLabel: value.correctedLabel,
|
|
25
|
+
candidateId,
|
|
26
|
+
judgedText,
|
|
27
|
+
occurrences,
|
|
28
|
+
confidence,
|
|
29
|
+
judge: { model, promptVersion },
|
|
30
|
+
...(note ? { note } : {}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
10
34
|
function normalizeResearchDonation(value) {
|
|
11
35
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
12
36
|
if (value.consent?.researchDonation !== true) return null;
|
|
13
37
|
if (!/^[A-Za-z0-9_-]{8,32}$/.test(value.reportId || "")) return null;
|
|
38
|
+
if (value.purpose !== undefined && !new Set(["general_research", "classifier_feedback"]).has(value.purpose)) return null;
|
|
39
|
+
const purpose = value.purpose === "classifier_feedback" ? "classifier_feedback" : "general_research";
|
|
14
40
|
if (!new Set(["standard", "custom", "unredacted"]).has(value.redactionMode)) return null;
|
|
15
41
|
const unredacted = value.redactionMode === "unredacted";
|
|
16
42
|
if (unredacted && value.consent?.unredactedData !== true) return null;
|
|
@@ -29,9 +55,12 @@ function normalizeResearchDonation(value) {
|
|
|
29
55
|
return messages.length ? [{ label: `Session ${sessionIndex + 1}`, messages }] : [];
|
|
30
56
|
});
|
|
31
57
|
if (!sessions.length || messageCount > MAX_MESSAGES) return null;
|
|
58
|
+
const classifierFeedback = purpose === "classifier_feedback" ? normalizeClassifierFeedback(value.classifierFeedback) : null;
|
|
59
|
+
if (purpose === "classifier_feedback" && (!classifierFeedback || value.consent?.classifierFeedback !== true || sessions.length !== 1)) return null;
|
|
32
60
|
const donation = {
|
|
33
61
|
format: "behavior-wrapped-research-donation-v1",
|
|
34
62
|
reportId: value.reportId,
|
|
63
|
+
purpose,
|
|
35
64
|
redactionMode: value.redactionMode,
|
|
36
65
|
createdAt: /^\d{4}-\d{2}-\d{2}T/.test(value.createdAt || "") ? value.createdAt : new Date().toISOString(),
|
|
37
66
|
redactionSummary: {
|
|
@@ -39,11 +68,15 @@ function normalizeResearchDonation(value) {
|
|
|
39
68
|
sessions: sessions.length,
|
|
40
69
|
messages: messageCount,
|
|
41
70
|
},
|
|
71
|
+
...(classifierFeedback ? { classifierFeedback } : {}),
|
|
42
72
|
sessions,
|
|
43
73
|
consent: {
|
|
44
74
|
researchDonation: true,
|
|
75
|
+
...(classifierFeedback ? { classifierFeedback: true } : {}),
|
|
45
76
|
...(unredacted ? { unredactedData: true } : {}),
|
|
46
|
-
statement:
|
|
77
|
+
statement: classifierFeedback
|
|
78
|
+
? "I consent for this reviewed session and classification correction to be transmitted to the Susan Calvin Project and used for research and to evaluate and improve Behavior Wrapped under the data policy."
|
|
79
|
+
: unredacted
|
|
47
80
|
? "I understand this donation is not automatically redacted and may contain credentials, personal details, private code, URLs, and file paths. I consent to transmit it to the Susan Calvin Project for research under the data policy."
|
|
48
81
|
: "I consent for this reviewed data to be transmitted to the Susan Calvin Project and used for research under the data policy.",
|
|
49
82
|
consentedAt: /^\d{4}-\d{2}-\d{2}T/.test(value.consent.consentedAt || "") ? value.consent.consentedAt : new Date().toISOString(),
|