behavior-wrapped 0.9.0 → 0.10.0
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-ddSuXgMW.css → index-CcqvXEU2.css} +1 -1
- package/dist/assets/index-D2k7FNoP.js +11 -0
- package/dist/index.html +2 -2
- package/docs/privacy.md +5 -1
- package/docs/research-donations.md +3 -1
- package/package.json +1 -1
- package/server/cli.mjs +31 -10
- 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 +81 -22
- package/server/phrase-card.mjs +8 -0
- package/server/research-donation-crypto.mjs +1 -0
- package/server/research-donation-schema.mjs +34 -1
- package/dist/assets/index-DtJbmHbC.js +0 -11
package/dist/index.html
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
<meta name="theme-color" content="#0d0b1b" />
|
|
7
7
|
<meta name="description" content="A local-first behavior report for you and your AI agents." />
|
|
8
8
|
<title>Behavior Wrapped</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-D2k7FNoP.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CcqvXEU2.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<div id="root"></div>
|
package/docs/privacy.md
CHANGED
|
@@ -26,7 +26,11 @@ Local-only reports omit AI-judged interaction tone, topics, and workarounds. The
|
|
|
26
26
|
|
|
27
27
|
## Private evidence
|
|
28
28
|
|
|
29
|
-
Confirmed workaround cards can link to a localhost-only evidence page.
|
|
29
|
+
Confirmed workaround cards can link to a localhost-only evidence page. Interaction-tone cards can likewise show the exact locally reconstructed yelling, thanking, and apology excerpts. These excerpts do not enter the public report, share-card export, or remote payload merely by opening the page.
|
|
30
|
+
|
|
31
|
+
Yelling and thanking occurrences include an optional **Is this inaccurate?** path. It resolves the occurrence to its original session on localhost and opens the normal research-donation review restricted to that one session. The user chooses a corrected label, can edit or exclude messages and customize redactions, then provides purpose-specific consent. Clicking the feedback link alone transmits nothing.
|
|
32
|
+
|
|
33
|
+
Accepted classifier feedback receives the same local encryption, private ciphertext storage, deletion receipt, and deletion controls as an ordinary research donation. Its encrypted contents include the reviewed session, original verdict, correction, judged excerpt, judge version, and optional note. Public reports, leaderboard payloads, share-card exports, and operational notifications never include this material.
|
|
30
34
|
|
|
31
35
|
## Boundaries
|
|
32
36
|
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
Research donation is optional and separate from creating or publishing a Wrapped report. No donation data is transmitted until the user selects a mode, reviews the resulting material, checks the final research-consent box, and presses **Donate**.
|
|
4
4
|
|
|
5
|
+
Classifier feedback uses this same donation tier. From a private yelling or thanking occurrence, the review is locked to the single source session and includes a corrected label plus an optional explanation. The purpose-specific consent states that the reviewed session will be used for research and to evaluate and improve Behavior Wrapped. Opening the review or marking a correction does not itself transmit data.
|
|
6
|
+
|
|
5
7
|
The localhost helper can construct a standard-redacted preview, a customizable redaction review, or a deliberately unredacted copy. Detailed modes let users exclude sessions and messages, edit text, and keep timestamps off by default. The unredacted path shows every included line and requires a separate warning and explicit acknowledgement that credentials and private details may be transmitted.
|
|
6
8
|
|
|
7
9
|
Donation discovery, default redaction, preview, exclusions, editing, schema validation, compression, and authenticated AES-256-GCM encryption happen on localhost. Compression is applied before encryption so substantial reviewed transcripts can be transmitted without weakening confidentiality. Each donation receives a fresh content key, wrapped with a rotation-versioned RSA-OAEP public key. The private key is absent from the npm package, Worker, D1, and R2.
|
|
8
10
|
|
|
9
|
-
The receiving Worker accepts only encrypted protocol-2 envelopes. A private R2 bucket stores ciphertext. A separate D1 database stores pseudonymous consent, size, count, encryption-key, and object-location metadata—never transcript text. No automatic retention policy is currently configured. A locally retained deletion receipt lets the donor delete both records.
|
|
11
|
+
The receiving Worker accepts only encrypted protocol-2 envelopes. A private R2 bucket stores ciphertext, separating general donations and classifier-feedback donations by object prefix while applying the same access controls. A separate D1 database stores pseudonymous consent, size, count, encryption-key, and object-location metadata—never transcript text or classifier corrections. No automatic retention policy is currently configured. A locally retained deletion receipt lets the donor delete both records.
|
|
10
12
|
|
|
11
13
|
## Maintainer operations
|
|
12
14
|
|
package/package.json
CHANGED
package/server/cli.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { discoverAllSessionsAsync, readRecordsAsync, sessionsInDefaultWindow, DEFAULT_WINDOW_DAYS } from "./discovery.mjs";
|
|
7
7
|
import { analyzeSessions } from "./analysis.mjs";
|
|
8
|
-
import { buildLocalPhraseCard, buildPhraseCandidates, judgePhraseCard, judgePhraseCardViaRelay, PHRASE_JUDGE_NAME, PHRASE_JUDGE_RELAY_URL } from "./phrase-card.mjs";
|
|
8
|
+
import { buildLocalPhraseCard, buildPhraseCandidates, judgePhraseCard, judgePhraseCardViaRelay, phraseCardWithLocalFallback, PHRASE_JUDGE_NAME, PHRASE_JUDGE_RELAY_URL } from "./phrase-card.mjs";
|
|
9
9
|
import { requestAnalysisMode } from "./consent.mjs";
|
|
10
10
|
import { applyInteractionToneJudgment, buildInteractionToneCandidates, emptyInteractionToneJudgment, INTERACTION_TONE_RELAY_URL, judgeInteractionTone, judgeInteractionToneViaRelay } from "./interaction-tone.mjs";
|
|
11
11
|
import { applySessionTopicJudgment, buildSessionTopicCandidates, emptySessionTopicJudgment, judgeSessionTopics, judgeSessionTopicsViaRelay, SESSION_TOPIC_RELAY_URL } from "./session-topics.mjs";
|
|
@@ -28,6 +28,10 @@ const baseUrl = `http://localhost:${port}`;
|
|
|
28
28
|
const loopbackUrl = `http://127.0.0.1:${port}`;
|
|
29
29
|
const command = process.argv[2];
|
|
30
30
|
const verbose = process.argv.includes("--verbose") || process.argv.includes("--debug") || process.env.BEHAVIOR_WRAPPED_DEBUG === "1";
|
|
31
|
+
const HELPER_HEALTH_TIMEOUT_MS = 500;
|
|
32
|
+
const HELPER_POLL_MS = 100;
|
|
33
|
+
const HELPER_STOP_TIMEOUT_MS = 5_000;
|
|
34
|
+
const HELPER_START_TIMEOUT_MS = 10_000;
|
|
31
35
|
const muted = "\x1b[2m"; const bright = "\x1b[1m"; const lime = "\x1b[38;2;201;242;75m"; const purple = "\x1b[38;2;141;92;255m"; const reset = "\x1b[0m";
|
|
32
36
|
const progress = createCliProgress();
|
|
33
37
|
|
|
@@ -78,7 +82,7 @@ function printJudgeDebug(label, error) {
|
|
|
78
82
|
|
|
79
83
|
async function helperStatus(expectedDemo = false) {
|
|
80
84
|
try {
|
|
81
|
-
const response = await fetch(`${loopbackUrl}/api/health
|
|
85
|
+
const response = await fetch(`${loopbackUrl}/api/health`, { signal: AbortSignal.timeout(HELPER_HEALTH_TIMEOUT_MS) });
|
|
82
86
|
const body = await response.json();
|
|
83
87
|
const recognized = response.ok && body.app === "behavior-wrapped" && body.local === true && body.purpose === "research-donation";
|
|
84
88
|
return { recognized, compatible: recognized && helperHealthMatches(body, { version: APP_VERSION, protocol: LOCAL_DONATION_PROTOCOL, demo: expectedDemo }), pid: Number(body.pid) || null };
|
|
@@ -91,18 +95,30 @@ async function ensureServer(demo = false) {
|
|
|
91
95
|
if (current.recognized) {
|
|
92
96
|
const stopped = await stopVerifiedStaleHelper(port, current.pid);
|
|
93
97
|
if (!stopped) throw new Error(`An older Behavior Wrapped helper is using port ${port}. Stop it, then run this command again.`);
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
const stopDeadline = Date.now() + HELPER_STOP_TIMEOUT_MS;
|
|
99
|
+
while (Date.now() < stopDeadline) {
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, HELPER_POLL_MS));
|
|
96
101
|
if (!(await helperStatus(demo)).recognized) break;
|
|
97
102
|
}
|
|
98
103
|
}
|
|
99
104
|
const child = spawn(process.execPath, [path.join(here, "launcher.mjs"), `--port=${port}`, "--no-open", ...(demo ? ["--demo"] : [])], { detached: true, stdio: "ignore", env: { ...process.env, BEHAVIOR_WRAPPED_DAEMON: "1" } });
|
|
105
|
+
let childFailure = null;
|
|
106
|
+
child.once("error", (error) => { childFailure = error; });
|
|
107
|
+
child.once("exit", (code, signal) => { childFailure = new Error(`helper exited with ${signal ? `signal ${signal}` : `code ${code}`}`); });
|
|
100
108
|
child.unref();
|
|
101
|
-
|
|
102
|
-
|
|
109
|
+
const startDeadline = Date.now() + HELPER_START_TIMEOUT_MS;
|
|
110
|
+
while (Date.now() < startDeadline) {
|
|
111
|
+
await new Promise((resolve) => setTimeout(resolve, HELPER_POLL_MS));
|
|
103
112
|
if ((await helperStatus(demo)).compatible) return;
|
|
113
|
+
if (childFailure) break;
|
|
104
114
|
}
|
|
105
|
-
|
|
115
|
+
if ((await helperStatus(demo)).compatible) return;
|
|
116
|
+
if (!childFailure && child.pid && child.exitCode === null) {
|
|
117
|
+
try { process.kill(child.pid, "SIGTERM"); }
|
|
118
|
+
catch (error) { if (error?.code !== "ESRCH") throw error; }
|
|
119
|
+
}
|
|
120
|
+
const reason = childFailure ? `${childFailure.message}.` : `It did not become ready within ${HELPER_START_TIMEOUT_MS / 1_000} seconds and was stopped.`;
|
|
121
|
+
throw new Error(`Could not start the local donation helper on port ${port}. ${reason} Another application may already be using that port.`);
|
|
106
122
|
}
|
|
107
123
|
|
|
108
124
|
function openUrl(url) {
|
|
@@ -229,7 +245,9 @@ async function createWrapped() {
|
|
|
229
245
|
: judgeWorkaroundsViaRelay(workaroundBundle, { endpoint: process.env.BEHAVIOR_WRAPPED_WORKAROUND_URL || WORKAROUND_RELAY_URL, clientId: getOrCreateClientId(), onProgress: workaroundProgress })
|
|
230
246
|
: Promise.resolve(emptyWorkaroundJudgment(workaroundBundle.coverage));
|
|
231
247
|
[phraseCard, interactionTone, sessionTopics, workarounds] = await Promise.all([
|
|
232
|
-
trackJudge("phrase", phraseCardPromise),
|
|
248
|
+
phraseCardWithLocalFallback(candidates, trackJudge("phrase", phraseCardPromise), {
|
|
249
|
+
onFallback(error) { analysisWarnings.push({ label: "favorite-phrase judge", error, localPhraseFallback: true }); },
|
|
250
|
+
}),
|
|
233
251
|
optionalAnalysis(trackJudge("tone", interactionTonePromise), "interaction card", analysisWarnings),
|
|
234
252
|
optionalAnalysis(trackJudge("topics", sessionTopicsPromise), "usage-topic card", analysisWarnings),
|
|
235
253
|
optionalAnalysis(trackJudge("workarounds", workaroundsPromise), "instrumental-workaround card", analysisWarnings),
|
|
@@ -253,7 +271,9 @@ async function createWrapped() {
|
|
|
253
271
|
delete analyzed.workaroundReview;
|
|
254
272
|
}
|
|
255
273
|
for (const warning of analysisWarnings) {
|
|
256
|
-
console.log(
|
|
274
|
+
console.log(warning.localPhraseFallback
|
|
275
|
+
? `◇ ${muted}The favorite-phrase judge was unavailable; used the deterministic local phrase instead.${reset} `
|
|
276
|
+
: `◇ ${muted}Skipped the ${warning.label}; the judge request failed or its response could not be validated.${reset} `);
|
|
257
277
|
printJudgeDebug(warning.label, warning.error);
|
|
258
278
|
}
|
|
259
279
|
const id = createReportId();
|
|
@@ -278,8 +298,9 @@ async function createWrapped() {
|
|
|
278
298
|
const localUrl = `${baseUrl}/w/${id}`;
|
|
279
299
|
const url = localOnly ? localUrl : report.managementUrl || publicUrl || localUrl;
|
|
280
300
|
const tokenLabel = formatNumber(report.stats.tokens || 0);
|
|
301
|
+
const localPhrasePick = localOnly || report.phraseCard?.provider === "Local deterministic analysis";
|
|
281
302
|
console.log(`◇ ${bright}Wrapped ready${reset} · ${tokenLabel} tokens across ${report.stats.sessions} sessions `);
|
|
282
|
-
if (report.phraseCard) console.log(`◇ ${
|
|
303
|
+
if (report.phraseCard) console.log(`◇ ${localPhrasePick ? "Local pick" : `${PHRASE_JUDGE_NAME}'s pick`} · “${report.phraseCard.phrase}” × ${report.phraseCard.occurrences}${localPhrasePick ? "" : ` · ${(report.phraseCard.latencyMs / 1000).toFixed(1)}s`} `);
|
|
283
304
|
console.log(`│\n◇ Your wrapped is ${publicUrl ? "live" : "ready locally"} ───────────────────────────────╮`);
|
|
284
305
|
console.log(`│ │`);
|
|
285
306
|
console.log(`│ ${purple}${bright}${publicUrl || localUrl}${reset}`);
|
|
@@ -36,12 +36,16 @@ export function sanitizeEncryptedDonationEnvelope(value) {
|
|
|
36
36
|
if (typeof value.encryption.iv !== "string" || value.encryption.iv.length !== 16 || !base64url.test(value.encryption.iv)) return null;
|
|
37
37
|
if (typeof value.encryption.authTag !== "string" || value.encryption.authTag.length !== 22 || !base64url.test(value.encryption.authTag)) return null;
|
|
38
38
|
if (typeof value.ciphertext !== "string" || !value.ciphertext.length || !base64url.test(value.ciphertext)) return null;
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
|
|
39
|
+
const legacyMetadataKeys = ["automatedDetections", "consentVersion", "consentedAt", "createdAt", "messages", "redactionMode", "reportId", "sessions", "unredactedData"];
|
|
40
|
+
const baseMetadataKeys = [...legacyMetadataKeys, "purpose"];
|
|
41
|
+
const compressed = exactKeys(value.metadata, [...baseMetadataKeys, "contentEncoding"])
|
|
42
|
+
|| exactKeys(value.metadata, [...legacyMetadataKeys, "contentEncoding"]);
|
|
43
|
+
if (!compressed && !exactKeys(value.metadata, baseMetadataKeys) && !exactKeys(value.metadata, legacyMetadataKeys)) return null;
|
|
42
44
|
const metadata = value.metadata;
|
|
43
45
|
if (compressed && metadata.contentEncoding !== DONATION_CONTENT_ENCODING) return null;
|
|
44
46
|
if (!/^[A-Za-z0-9_-]{8,32}$/.test(metadata.reportId || "")) return null;
|
|
47
|
+
if ("purpose" in metadata && !new Set(["general_research", "classifier_feedback"]).has(metadata.purpose)) return null;
|
|
48
|
+
if (metadata.purpose === "classifier_feedback" && metadata.sessions !== 1) return null;
|
|
45
49
|
if (!new Set(["standard", "custom", "unredacted"]).has(metadata.redactionMode)) return null;
|
|
46
50
|
if (!timestamp.test(metadata.createdAt || "") || !timestamp.test(metadata.consentedAt || "")) return null;
|
|
47
51
|
if (!new Set([1, DONATION_CONSENT_VERSION]).has(metadata.consentVersion) || typeof metadata.unredactedData !== "boolean" || metadata.unredactedData !== (metadata.redactionMode === "unredacted")) return null;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { interactionFeedbackId } from "./interaction-feedback.mjs";
|
|
2
|
+
|
|
1
3
|
const MAX_OCCURRENCES_PER_KIND = 100;
|
|
2
4
|
|
|
3
5
|
function contentBlocks(record) {
|
|
@@ -33,7 +35,7 @@ function adjacentMessage(records, fromIndex, direction) {
|
|
|
33
35
|
return null;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
function exactOccurrence(reference, index, records, metadata, expectedRole) {
|
|
38
|
+
function exactOccurrence(reference, index, records, metadata, expectedRole, feedbackId = null) {
|
|
37
39
|
const recordIndex = matchingRecordIndex(reference, records, expectedRole);
|
|
38
40
|
if (recordIndex === null) return null;
|
|
39
41
|
const record = records[recordIndex];
|
|
@@ -44,6 +46,7 @@ function exactOccurrence(reference, index, records, metadata, expectedRole) {
|
|
|
44
46
|
return {
|
|
45
47
|
index: index + 1,
|
|
46
48
|
candidateId: reference.candidateId,
|
|
49
|
+
...(feedbackId ? { feedbackId } : {}),
|
|
47
50
|
session: {
|
|
48
51
|
label: metadata?.label || `Session ${index + 1}`,
|
|
49
52
|
agentName: metadata?.agentName || "AI agent",
|
|
@@ -59,7 +62,7 @@ function buildKind(review, kind, expectedRole, recordsById, metadataById) {
|
|
|
59
62
|
const sessionId = reference?.location?.sessionId;
|
|
60
63
|
const records = recordsById.get(sessionId);
|
|
61
64
|
if (!records) return [];
|
|
62
|
-
const occurrence = exactOccurrence(reference, index, records, metadataById.get(sessionId), expectedRole);
|
|
65
|
+
const occurrence = exactOccurrence(reference, index, records, metadataById.get(sessionId), expectedRole, interactionFeedbackId(kind, index));
|
|
63
66
|
return occurrence ? [occurrence] : [];
|
|
64
67
|
});
|
|
65
68
|
}
|
|
@@ -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,7 @@ 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";
|
|
14
15
|
import { createIdleShutdownController } from "./local-helper-runtime.mjs";
|
|
15
16
|
import { canonicalSessionDirectoryLabels, openExternalUrl, supportedAgentNames } from "./platform.mjs";
|
|
16
17
|
|
|
@@ -25,14 +26,32 @@ const portArg = process.argv.find((arg) => arg.startsWith("--port="));
|
|
|
25
26
|
const port = Number(portArg?.split("=")[1] || 4317);
|
|
26
27
|
const configuredIdleMs = Number(process.env.BEHAVIOR_WRAPPED_HELPER_IDLE_MS);
|
|
27
28
|
const helperIdleMs = Number.isFinite(configuredIdleMs) && configuredIdleMs >= 100 ? configuredIdleMs : 5 * 60 * 1_000;
|
|
28
|
-
|
|
29
|
+
const configuredTestCatalogDelayMs = process.env.NODE_ENV === "test" ? Number(process.env.BEHAVIOR_WRAPPED_TEST_CATALOG_DELAY_MS) : 0;
|
|
30
|
+
const testCatalogDelayMs = Number.isFinite(configuredTestCatalogDelayMs) && configuredTestCatalogDelayMs > 0 ? configuredTestCatalogDelayMs : 0;
|
|
31
|
+
let catalog = null;
|
|
32
|
+
let catalogPromise = null;
|
|
29
33
|
|
|
30
34
|
async function loadCatalog() {
|
|
35
|
+
if (testCatalogDelayMs) await new Promise((resolve) => setTimeout(resolve, testCatalogDelayMs));
|
|
31
36
|
const found = await discoverAllSessionsAsync(demo ? { claudeRoot: fixtureRoot, coworkRoot: coworkFixtureRoot, codexRoots: [codexFixtureRoot], cache: false } : undefined);
|
|
32
37
|
if (demo) found.sessions = found.sessions.map((session, index) => ({ ...session, synthetic: true, label: `Demo session ${index + 1}` }));
|
|
33
38
|
return found;
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
async function catalogForRequest({ refresh = false } = {}) {
|
|
42
|
+
if (refresh || !catalogPromise) {
|
|
43
|
+
const loading = loadCatalog();
|
|
44
|
+
catalogPromise = loading;
|
|
45
|
+
try {
|
|
46
|
+
catalog = await loading;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (catalogPromise === loading) catalogPromise = null;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return catalogPromise;
|
|
53
|
+
}
|
|
54
|
+
|
|
36
55
|
function securityHeaders(extra = {}) {
|
|
37
56
|
return {
|
|
38
57
|
"X-Content-Type-Options": "nosniff",
|
|
@@ -71,23 +90,24 @@ function readBody(request, maximumBytes = 1_000_000) {
|
|
|
71
90
|
});
|
|
72
91
|
}
|
|
73
92
|
|
|
74
|
-
async function chosenRecords(ids, options = {}) {
|
|
93
|
+
async function chosenRecords(ids, options = {}, activeCatalog = null) {
|
|
94
|
+
const availableCatalog = activeCatalog || await catalogForRequest();
|
|
75
95
|
const selected = [];
|
|
76
96
|
for (const id of ids) {
|
|
77
|
-
const session =
|
|
97
|
+
const session = availableCatalog.index.get(id);
|
|
78
98
|
if (session) selected.push({ sessionId: id, agent: session.agent, records: await readRecordsAsync(session.file, session.agent, options) });
|
|
79
99
|
}
|
|
80
100
|
return selected;
|
|
81
101
|
}
|
|
82
102
|
|
|
83
|
-
function publicCatalog() {
|
|
103
|
+
function publicCatalog(availableCatalog) {
|
|
84
104
|
const agentNames = supportedAgentNames(process.platform, { includeCowork: demo || process.platform === "darwin" });
|
|
85
105
|
return {
|
|
86
|
-
rootAvailable:
|
|
106
|
+
rootAvailable: availableCatalog.rootAvailable,
|
|
87
107
|
demo,
|
|
88
|
-
projects:
|
|
89
|
-
sessions:
|
|
90
|
-
defaultRange: defaultDateRange(
|
|
108
|
+
projects: availableCatalog.projects,
|
|
109
|
+
sessions: availableCatalog.sessions.map((session, index) => ({ ...session, label: session.label || `Session ${index + 1}` })),
|
|
110
|
+
defaultRange: defaultDateRange(availableCatalog.sessions, { days: DEFAULT_WINDOW_DAYS, anchorLatest: demo }),
|
|
91
111
|
agentNames,
|
|
92
112
|
privacy: { canonicalDirectories: canonicalSessionDirectoryLabels(), networkRequests: "only-after-final-donation-consent" },
|
|
93
113
|
};
|
|
@@ -99,10 +119,10 @@ const server = http.createServer(async (request, response) => {
|
|
|
99
119
|
if (!new Set([`127.0.0.1:${port}`, `localhost:${port}`]).has(request.headers.host || "")) return json(response, 403, { error: "Local access only" });
|
|
100
120
|
idleShutdown.touch();
|
|
101
121
|
const url = new URL(request.url || "/", `http://${request.headers.host}`);
|
|
102
|
-
if (request.method === "GET" && url.pathname === "/api/health") return json(response, 200, { app: "behavior-wrapped", version: APP_VERSION, local: true, purpose: "research-donation", donationProtocol: LOCAL_DONATION_PROTOCOL, pid: process.pid, demo });
|
|
122
|
+
if (request.method === "GET" && url.pathname === "/api/health") return json(response, 200, { app: "behavior-wrapped", version: APP_VERSION, local: true, purpose: "research-donation", donationProtocol: LOCAL_DONATION_PROTOCOL, pid: process.pid, demo, catalogState: catalog ? "ready" : catalogPromise ? "loading" : "not-loaded" });
|
|
103
123
|
if (request.method === "GET" && url.pathname === "/api/discover") {
|
|
104
|
-
|
|
105
|
-
return json(response, 200, publicCatalog());
|
|
124
|
+
const availableCatalog = await catalogForRequest({ refresh: true });
|
|
125
|
+
return json(response, 200, publicCatalog(availableCatalog));
|
|
106
126
|
}
|
|
107
127
|
const reportMatch = url.pathname.match(/^\/api\/reports\/([A-Za-z0-9_-]{8,32})$/);
|
|
108
128
|
if (request.method === "GET" && reportMatch) {
|
|
@@ -116,39 +136,60 @@ const server = http.createServer(async (request, response) => {
|
|
|
116
136
|
if (request.method === "GET" && selectionMatch) {
|
|
117
137
|
const report = loadReport(selectionMatch[1]);
|
|
118
138
|
if (!report) return json(response, 404, { error: "Saved report not found" });
|
|
119
|
-
const
|
|
139
|
+
const availableCatalog = await catalogForRequest();
|
|
140
|
+
const available = new Set(availableCatalog.sessions.map((session) => session.id));
|
|
120
141
|
return json(response, 200, { sessionIds: (report.sessionIds || []).filter((id) => available.has(id)), localPrivateSelection: true });
|
|
121
142
|
}
|
|
122
143
|
const workaroundEvidenceMatch = url.pathname.match(/^\/api\/reports\/([A-Za-z0-9_-]{8,32})\/workarounds$/);
|
|
123
144
|
if (request.method === "GET" && workaroundEvidenceMatch) {
|
|
124
145
|
const report = loadReport(workaroundEvidenceMatch[1]);
|
|
125
146
|
if (!report) return json(response, 404, { error: "Saved report not found" });
|
|
147
|
+
const availableCatalog = await catalogForRequest();
|
|
126
148
|
const allowed = new Set(report.sessionIds || []);
|
|
127
|
-
const ids = [...new Set((report.workaroundReview?.occurrences || []).map((occurrence) => occurrence?.location?.sessionId).filter((id) => allowed.has(id) &&
|
|
128
|
-
const records = await chosenRecords(ids, { includePrivateToolDetails: true });
|
|
129
|
-
const labels = new Map(publicCatalog().sessions.map((session) => [session.id, session]));
|
|
149
|
+
const ids = [...new Set((report.workaroundReview?.occurrences || []).map((occurrence) => occurrence?.location?.sessionId).filter((id) => allowed.has(id) && availableCatalog.index.has(id)))].slice(0, 100);
|
|
150
|
+
const records = await chosenRecords(ids, { includePrivateToolDetails: true }, availableCatalog);
|
|
151
|
+
const labels = new Map(publicCatalog(availableCatalog).sessions.map((session) => [session.id, session]));
|
|
130
152
|
return json(response, 200, makeWorkaroundEvidencePreview(report, records, labels));
|
|
131
153
|
}
|
|
132
154
|
const interactionEvidenceMatch = url.pathname.match(/^\/api\/reports\/([A-Za-z0-9_-]{8,32})\/interactions$/);
|
|
133
155
|
if (request.method === "GET" && interactionEvidenceMatch) {
|
|
134
156
|
const report = loadReport(interactionEvidenceMatch[1]);
|
|
135
157
|
if (!report) return json(response, 404, { error: "Saved report not found" });
|
|
158
|
+
const availableCatalog = await catalogForRequest();
|
|
136
159
|
const allowed = new Set(report.sessionIds || []);
|
|
137
160
|
const references = [...(report.interactionReview?.frustrated || []), ...(report.interactionReview?.grateful || []), ...(report.apologyReview?.user || []), ...(report.apologyReview?.agent || [])];
|
|
138
|
-
const ids = [...new Set(references.map((reference) => reference?.location?.sessionId).filter((id) => allowed.has(id) &&
|
|
139
|
-
const records = await chosenRecords(ids);
|
|
140
|
-
const labels = new Map(publicCatalog().sessions.map((session) => [session.id, session]));
|
|
161
|
+
const ids = [...new Set(references.map((reference) => reference?.location?.sessionId).filter((id) => allowed.has(id) && availableCatalog.index.has(id)))].slice(0, 200);
|
|
162
|
+
const records = await chosenRecords(ids, {}, availableCatalog);
|
|
163
|
+
const labels = new Map(publicCatalog(availableCatalog).sessions.map((session) => [session.id, session]));
|
|
141
164
|
return json(response, 200, makeInteractionEvidencePreview(report, records, labels));
|
|
142
165
|
}
|
|
166
|
+
const interactionFeedbackMatch = url.pathname.match(/^\/api\/reports\/([A-Za-z0-9_-]{8,32})\/interaction-feedback\/(yelling|thanking)-([1-9][0-9]{0,2})$/);
|
|
167
|
+
if (request.method === "GET" && interactionFeedbackMatch) {
|
|
168
|
+
const report = loadReport(interactionFeedbackMatch[1]);
|
|
169
|
+
if (!report) return json(response, 404, { error: "Saved report not found" });
|
|
170
|
+
const feedbackId = `${interactionFeedbackMatch[2]}-${interactionFeedbackMatch[3]}`;
|
|
171
|
+
const reference = resolveInteractionFeedback(report, feedbackId);
|
|
172
|
+
if (!reference) return json(response, 404, { error: "That interaction classification is no longer available." });
|
|
173
|
+
const availableCatalog = await catalogForRequest();
|
|
174
|
+
if (!availableCatalog.index.has(reference.sessionId)) return json(response, 404, { error: "The source session is no longer available on this device." });
|
|
175
|
+
const records = await chosenRecords([reference.sessionId], {}, availableCatalog);
|
|
176
|
+
const trusted = resolveInteractionFeedback(report, feedbackId, new Map(records.map((session) => [session.sessionId, session.records])));
|
|
177
|
+
return json(response, 200, { sessionIds: [reference.sessionId], feedback: publicInteractionFeedback(trusted), localPrivateSelection: true });
|
|
178
|
+
}
|
|
143
179
|
if (request.method === "POST" && url.pathname === "/api/donation-preview") {
|
|
144
180
|
const body = await readBody(request);
|
|
145
181
|
const report = loadReport(body.reportId);
|
|
146
182
|
if (!report) return json(response, 404, { error: "Saved report not found" });
|
|
183
|
+
const availableCatalog = await catalogForRequest();
|
|
184
|
+
const feedback = body.feedbackId ? resolveInteractionFeedback(report, body.feedbackId) : null;
|
|
185
|
+
if (body.feedbackId && !feedback) return json(response, 400, { error: "Invalid classifier-feedback selection." });
|
|
147
186
|
const allowed = new Set(report.sessionIds || []);
|
|
148
|
-
const ids =
|
|
149
|
-
|
|
187
|
+
const ids = feedback
|
|
188
|
+
? [feedback.sessionId].filter((id) => availableCatalog.index.has(id))
|
|
189
|
+
: Array.isArray(body.sessionIds) ? body.sessionIds.filter((id) => allowed.has(id) && availableCatalog.index.has(id)).slice(0, 250) : [];
|
|
190
|
+
const records = await chosenRecords(ids, {}, availableCatalog);
|
|
150
191
|
if (!records.length) return json(response, 400, { error: "Choose at least one available session." });
|
|
151
|
-
const labels = new Map(publicCatalog().sessions.map((session) => [session.id, session]));
|
|
192
|
+
const labels = new Map(publicCatalog(availableCatalog).sessions.map((session) => [session.id, session]));
|
|
152
193
|
const disabledRedactions = Array.isArray(body.disabledRedactions) ? body.disabledRedactions.filter((kind) => typeof kind === "string" && /^[a-z0-9-]{1,64}$/.test(kind)).slice(0, 20) : [];
|
|
153
194
|
const disabledMatches = Array.isArray(body.disabledMatches) ? body.disabledMatches.filter((id) => typeof id === "string" && /^[a-f0-9]{24}$/.test(id)).slice(0, 5_000) : [];
|
|
154
195
|
const unredacted = body.previewMode === "unredacted";
|
|
@@ -158,8 +199,26 @@ const server = http.createServer(async (request, response) => {
|
|
|
158
199
|
const body = await readBody(request, MAX_DONATION_BYTES + 1_000_000);
|
|
159
200
|
const report = loadReport(body?.donation?.reportId);
|
|
160
201
|
if (!report) return json(response, 404, { error: "Saved report not found" });
|
|
202
|
+
let donation = body.donation;
|
|
203
|
+
if (body.feedback) {
|
|
204
|
+
const reference = resolveInteractionFeedback(report, body.feedback.feedbackId);
|
|
205
|
+
const suppliedSession = donation?.sessions?.[0]?.sessionId;
|
|
206
|
+
if (!reference || donation?.sessions?.length !== 1 || suppliedSession !== reference.sessionId) return json(response, 400, { error: "Classifier feedback must contain only its original session." });
|
|
207
|
+
const availableCatalog = await catalogForRequest();
|
|
208
|
+
if (!availableCatalog.index.has(reference.sessionId)) return json(response, 404, { error: "The source session is no longer available on this device." });
|
|
209
|
+
const records = await chosenRecords([reference.sessionId], {}, availableCatalog);
|
|
210
|
+
const trusted = resolveInteractionFeedback(report, body.feedback.feedbackId, new Map(records.map((session) => [session.sessionId, session.records])));
|
|
211
|
+
const classifierFeedback = sanitizeInteractionFeedbackSubmission(body.feedback, trusted);
|
|
212
|
+
if (!classifierFeedback) return json(response, 400, { error: "Choose a valid corrected classification before donating." });
|
|
213
|
+
donation = {
|
|
214
|
+
...donation,
|
|
215
|
+
purpose: "classifier_feedback",
|
|
216
|
+
classifierFeedback,
|
|
217
|
+
consent: { ...donation.consent, classifierFeedback: true },
|
|
218
|
+
};
|
|
219
|
+
} else donation = { ...donation, purpose: "general_research", classifierFeedback: undefined };
|
|
161
220
|
if (demo) return json(response, 201, { accepted: true, donation_id: "demo-not-transmitted", demo: true });
|
|
162
|
-
const result = await submitResearchDonation(
|
|
221
|
+
const result = await submitResearchDonation(donation, {
|
|
163
222
|
clientId: getOrCreateClientId(),
|
|
164
223
|
endpoint: process.env.BEHAVIOR_WRAPPED_DONATION_URL || RESEARCH_DONATION_URL,
|
|
165
224
|
});
|
package/server/phrase-card.mjs
CHANGED
|
@@ -208,6 +208,14 @@ export function buildLocalPhraseCard(candidates) {
|
|
|
208
208
|
});
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
export async function phraseCardWithLocalFallback(candidates, judgedCard, { onFallback } = {}) {
|
|
212
|
+
try { return await judgedCard; }
|
|
213
|
+
catch (error) {
|
|
214
|
+
onFallback?.(error);
|
|
215
|
+
return buildLocalPhraseCard(candidates);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
211
219
|
function timeoutMessage(error, timeoutMs) {
|
|
212
220
|
if (error?.name === "TimeoutError" || error?.name === "AbortError") return new Error(`${PHRASE_JUDGE_NAME} timed out after ${Math.round(timeoutMs / 1000)} seconds.`);
|
|
213
221
|
return error;
|
|
@@ -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(),
|