forge-openclaw-plugin 0.2.19 → 0.2.21
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 +133 -2
- package/dist/assets/board-_C6oMy5w.js +6 -0
- package/dist/assets/{board-8L3uX7_O.js.map → board-_C6oMy5w.js.map} +1 -1
- package/dist/assets/index-B4A6TooJ.js +63 -0
- package/dist/assets/index-B4A6TooJ.js.map +1 -0
- package/dist/assets/index-D6Xs_2mo.css +1 -0
- package/dist/assets/{motion-1GAqqi8M.js → motion-D4sZgCHd.js} +2 -2
- package/dist/assets/{motion-1GAqqi8M.js.map → motion-D4sZgCHd.js.map} +1 -1
- package/dist/assets/{table-DBGlgRjk.js → table-BWzTaky1.js} +2 -2
- package/dist/assets/{table-DBGlgRjk.js.map → table-BWzTaky1.js.map} +1 -1
- package/dist/assets/{ui-iTluWjC4.js → ui-BzK4azQb.js} +7 -7
- package/dist/assets/{ui-iTluWjC4.js.map → ui-BzK4azQb.js.map} +1 -1
- package/dist/assets/vendor-DT3pnAKJ.css +1 -0
- package/dist/assets/vendor-De38P6YR.js +729 -0
- package/dist/assets/vendor-De38P6YR.js.map +1 -0
- package/dist/assets/viz-C6hfyqzu.js +34 -0
- package/dist/assets/viz-C6hfyqzu.js.map +1 -0
- package/dist/index.html +9 -9
- package/dist/openclaw/parity.d.ts +1 -1
- package/dist/openclaw/parity.js +29 -2
- package/dist/openclaw/routes.js +207 -24
- package/dist/openclaw/tools.js +324 -35
- package/dist/server/app.js +2080 -92
- package/dist/server/db.js +3 -0
- package/dist/server/health.js +1284 -0
- package/dist/server/managers/platform/background-job-manager.js +138 -2
- package/dist/server/managers/platform/llm-manager.js +126 -0
- package/dist/server/managers/platform/openai-responses-provider.js +773 -0
- package/dist/server/managers/runtime.js +6 -1
- package/dist/server/openapi.js +718 -0
- package/dist/server/preferences-seeds.js +409 -0
- package/dist/server/preferences-types.js +368 -0
- package/dist/server/psyche-types.js +42 -18
- package/dist/server/repositories/activity-events.js +53 -4
- package/dist/server/repositories/calendar.js +89 -15
- package/dist/server/repositories/collaboration.js +8 -3
- package/dist/server/repositories/diagnostic-logs.js +243 -0
- package/dist/server/repositories/entity-ownership.js +92 -0
- package/dist/server/repositories/goals.js +7 -2
- package/dist/server/repositories/habits.js +122 -16
- package/dist/server/repositories/notes.js +119 -41
- package/dist/server/repositories/preferences.js +1765 -0
- package/dist/server/repositories/projects.js +18 -7
- package/dist/server/repositories/psyche.js +84 -27
- package/dist/server/repositories/rewards.js +112 -4
- package/dist/server/repositories/strategies.js +450 -0
- package/dist/server/repositories/tags.js +11 -6
- package/dist/server/repositories/task-runs.js +10 -2
- package/dist/server/repositories/tasks.js +99 -17
- package/dist/server/repositories/users.js +417 -0
- package/dist/server/repositories/wiki-memory.js +3366 -0
- package/dist/server/services/context.js +20 -18
- package/dist/server/services/dashboard.js +29 -6
- package/dist/server/services/entity-crud.js +21 -3
- package/dist/server/services/insights.js +9 -7
- package/dist/server/services/projects.js +2 -1
- package/dist/server/services/psyche.js +10 -9
- package/dist/server/types.js +594 -30
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server/migrations/015_multi_user_and_strategies.sql +244 -0
- package/server/migrations/016_health_companion.sql +158 -0
- package/server/migrations/016_strategy_contracts_and_user_graph.sql +22 -0
- package/server/migrations/017_preferences.sql +131 -0
- package/server/migrations/018_preference_catalogs.sql +31 -0
- package/server/migrations/019_wiki_memory.sql +255 -0
- package/server/migrations/020_wiki_page_hierarchy.sql +11 -0
- package/server/migrations/021_hide_evidence_from_wiki_index.sql +3 -0
- package/server/migrations/022_wiki_ingest_background.sql +85 -0
- package/server/migrations/023_diagnostic_logs.sql +28 -0
- package/skills/forge-openclaw/SKILL.md +126 -34
- package/skills/forge-openclaw/entity_conversation_playbooks.md +337 -0
- package/skills/forge-openclaw/psyche_entity_playbooks.md +404 -0
- package/dist/assets/board-8L3uX7_O.js +0 -6
- package/dist/assets/index-Cj1IBH_w.js +0 -36
- package/dist/assets/index-Cj1IBH_w.js.map +0 -1
- package/dist/assets/index-DQT6EbuS.css +0 -1
- package/dist/assets/vendor-BvM2F9Dp.js +0 -503
- package/dist/assets/vendor-BvM2F9Dp.js.map +0 -1
- package/dist/assets/vendor-CRS-psbw.css +0 -1
- package/dist/assets/viz-CNeunkfu.js +0 -34
- package/dist/assets/viz-CNeunkfu.js.map +0 -1
|
@@ -0,0 +1,1765 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getDatabase, runInTransaction } from "../db.js";
|
|
3
|
+
import { HttpError } from "../errors.js";
|
|
4
|
+
import { absoluteSignalSchema, createPreferenceCatalogItemSchema, createPreferenceCatalogSchema, createPreferenceContextSchema, createPreferenceItemSchema, enqueueEntityPreferenceItemSchema, mergePreferenceContextsSchema, pairwiseJudgmentSchema, preferenceCatalogItemSchema, preferenceCatalogSchema, preferenceContextSchema, preferenceDimensionIdSchema, preferenceDimensionSummarySchema, preferenceItemSchema, preferenceItemScoreSchema, preferenceProfileSchema, preferenceCatalogSourceSchema, preferenceSnapshotSchema, preferenceWorkspacePayloadSchema, preferenceWorkspaceQuerySchema, startPreferenceGameSchema, submitAbsoluteSignalSchema, submitPairwiseJudgmentSchema, updatePreferenceCatalogItemSchema, updatePreferenceCatalogSchema, updatePreferenceContextSchema, updatePreferenceItemSchema, updatePreferenceScoreSchema } from "../preferences-types.js";
|
|
5
|
+
import { getPreferenceCatalogSeeds } from "../preferences-seeds.js";
|
|
6
|
+
import { getUserById, getDefaultUser } from "./users.js";
|
|
7
|
+
import { getGoalById } from "./goals.js";
|
|
8
|
+
import { getProjectById } from "./projects.js";
|
|
9
|
+
import { getTaskById } from "./tasks.js";
|
|
10
|
+
import { getStrategyById } from "./strategies.js";
|
|
11
|
+
import { getHabitById } from "./habits.js";
|
|
12
|
+
import { getNoteById } from "./notes.js";
|
|
13
|
+
import { getInsightById } from "./collaboration.js";
|
|
14
|
+
import { getCalendarEventById, getTaskTimeboxById, getWorkBlockTemplateById } from "./calendar.js";
|
|
15
|
+
import { getBehaviorById, getBehaviorPatternById, getBeliefEntryById, getEmotionDefinitionById, getEventTypeById, getModeGuideSessionById, getModeProfileById, getPsycheValueById, getTriggerReportById } from "./psyche.js";
|
|
16
|
+
const PREFERENCE_MODEL_VERSION = "pref-v1-bt-lite";
|
|
17
|
+
const DEFAULT_PREFERENCE_DOMAIN = "projects";
|
|
18
|
+
const DIMENSION_IDS = preferenceDimensionIdSchema.options;
|
|
19
|
+
const DEFAULT_DIMENSIONS = {
|
|
20
|
+
novelty: 0,
|
|
21
|
+
simplicity: 0,
|
|
22
|
+
rigor: 0,
|
|
23
|
+
aesthetics: 0,
|
|
24
|
+
depth: 0,
|
|
25
|
+
structure: 0,
|
|
26
|
+
familiarity: 0,
|
|
27
|
+
surprise: 0
|
|
28
|
+
};
|
|
29
|
+
const DEFAULT_CONTEXT_TEMPLATES = [
|
|
30
|
+
{
|
|
31
|
+
key: "default",
|
|
32
|
+
name: "Default",
|
|
33
|
+
description: "General preference state for this domain.",
|
|
34
|
+
shareMode: "shared",
|
|
35
|
+
active: true,
|
|
36
|
+
isDefault: true,
|
|
37
|
+
decayDays: 90
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
key: "work",
|
|
41
|
+
name: "Work",
|
|
42
|
+
description: "Work-specific tradeoffs and constraints.",
|
|
43
|
+
shareMode: "blended",
|
|
44
|
+
active: true,
|
|
45
|
+
isDefault: false,
|
|
46
|
+
decayDays: 75
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
key: "personal",
|
|
50
|
+
name: "Personal",
|
|
51
|
+
description: "Personal-life preferences outside explicit work mode.",
|
|
52
|
+
shareMode: "blended",
|
|
53
|
+
active: true,
|
|
54
|
+
isDefault: false,
|
|
55
|
+
decayDays: 90
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
key: "discovery",
|
|
59
|
+
name: "Discovery",
|
|
60
|
+
description: "A looser context for sampling and calibration.",
|
|
61
|
+
shareMode: "isolated",
|
|
62
|
+
active: true,
|
|
63
|
+
isDefault: false,
|
|
64
|
+
decayDays: 45
|
|
65
|
+
}
|
|
66
|
+
];
|
|
67
|
+
const SIGNAL_WEIGHTS = {
|
|
68
|
+
favorite: 1.25,
|
|
69
|
+
veto: -1.6,
|
|
70
|
+
must_have: 1.5,
|
|
71
|
+
bookmark: 0.35,
|
|
72
|
+
neutral: 0,
|
|
73
|
+
compare_later: 0.2
|
|
74
|
+
};
|
|
75
|
+
function nowIso() {
|
|
76
|
+
return new Date().toISOString();
|
|
77
|
+
}
|
|
78
|
+
function clamp(value, minimum, maximum) {
|
|
79
|
+
return Math.min(maximum, Math.max(minimum, value));
|
|
80
|
+
}
|
|
81
|
+
function tanhScale(value, divisor) {
|
|
82
|
+
return Math.tanh(value / divisor);
|
|
83
|
+
}
|
|
84
|
+
function ageInDays(dateText) {
|
|
85
|
+
if (!dateText) {
|
|
86
|
+
return Number.POSITIVE_INFINITY;
|
|
87
|
+
}
|
|
88
|
+
const timestamp = Date.parse(dateText);
|
|
89
|
+
if (Number.isNaN(timestamp)) {
|
|
90
|
+
return Number.POSITIVE_INFINITY;
|
|
91
|
+
}
|
|
92
|
+
return Math.max(0, (Date.now() - timestamp) / (1000 * 60 * 60 * 24));
|
|
93
|
+
}
|
|
94
|
+
function timeDecay(ageDays, decayDays) {
|
|
95
|
+
if (!Number.isFinite(ageDays)) {
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
return Math.exp(-ageDays / Math.max(7, decayDays));
|
|
99
|
+
}
|
|
100
|
+
function parseJsonArray(value, fallback = []) {
|
|
101
|
+
try {
|
|
102
|
+
const parsed = JSON.parse(value);
|
|
103
|
+
return Array.isArray(parsed) ? parsed : fallback;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return fallback;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function slugify(value) {
|
|
110
|
+
return value
|
|
111
|
+
.trim()
|
|
112
|
+
.toLowerCase()
|
|
113
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
114
|
+
.replace(/^-+|-+$/g, "")
|
|
115
|
+
.slice(0, 48);
|
|
116
|
+
}
|
|
117
|
+
function parseJsonObject(value, fallback) {
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(value);
|
|
120
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
121
|
+
? parsed
|
|
122
|
+
: fallback;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return fallback;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function normalizeDimensionVector(value) {
|
|
129
|
+
const source = value && typeof value === "object" && !Array.isArray(value)
|
|
130
|
+
? value
|
|
131
|
+
: {};
|
|
132
|
+
return {
|
|
133
|
+
novelty: clamp(Number(source.novelty ?? 0), -1, 1),
|
|
134
|
+
simplicity: clamp(Number(source.simplicity ?? 0), -1, 1),
|
|
135
|
+
rigor: clamp(Number(source.rigor ?? 0), -1, 1),
|
|
136
|
+
aesthetics: clamp(Number(source.aesthetics ?? 0), -1, 1),
|
|
137
|
+
depth: clamp(Number(source.depth ?? 0), -1, 1),
|
|
138
|
+
structure: clamp(Number(source.structure ?? 0), -1, 1),
|
|
139
|
+
familiarity: clamp(Number(source.familiarity ?? 0), -1, 1),
|
|
140
|
+
surprise: clamp(Number(source.surprise ?? 0), -1, 1)
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function vectorDistance(left, right) {
|
|
144
|
+
const squared = DIMENSION_IDS.reduce((sum, dimensionId) => {
|
|
145
|
+
const delta = left[dimensionId] - right[dimensionId];
|
|
146
|
+
return sum + delta * delta;
|
|
147
|
+
}, 0);
|
|
148
|
+
return Math.sqrt(squared / DIMENSION_IDS.length);
|
|
149
|
+
}
|
|
150
|
+
function mapProfile(row) {
|
|
151
|
+
return preferenceProfileSchema.parse({
|
|
152
|
+
id: row.id,
|
|
153
|
+
userId: row.user_id,
|
|
154
|
+
domain: row.domain,
|
|
155
|
+
defaultContextId: row.default_context_id,
|
|
156
|
+
modelVersion: row.model_version,
|
|
157
|
+
createdAt: row.created_at,
|
|
158
|
+
updatedAt: row.updated_at,
|
|
159
|
+
user: getUserById(row.user_id) ?? null
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
function mapContext(row) {
|
|
163
|
+
return preferenceContextSchema.parse({
|
|
164
|
+
id: row.id,
|
|
165
|
+
profileId: row.profile_id,
|
|
166
|
+
name: row.name,
|
|
167
|
+
description: row.description,
|
|
168
|
+
shareMode: row.share_mode,
|
|
169
|
+
active: row.active === 1,
|
|
170
|
+
isDefault: row.is_default === 1,
|
|
171
|
+
decayDays: row.decay_days,
|
|
172
|
+
createdAt: row.created_at,
|
|
173
|
+
updatedAt: row.updated_at
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function mapItem(row) {
|
|
177
|
+
return preferenceItemSchema.parse({
|
|
178
|
+
id: row.id,
|
|
179
|
+
profileId: row.profile_id,
|
|
180
|
+
label: row.label,
|
|
181
|
+
description: row.description,
|
|
182
|
+
tags: parseJsonArray(row.tags_json).filter(Boolean),
|
|
183
|
+
featureWeights: normalizeDimensionVector(parseJsonObject(row.feature_weights_json, {})),
|
|
184
|
+
sourceEntityType: row.source_entity_type,
|
|
185
|
+
sourceEntityId: row.source_entity_id,
|
|
186
|
+
linkedEntity: row.source_entity_type && row.source_entity_id
|
|
187
|
+
? {
|
|
188
|
+
entityType: row.source_entity_type,
|
|
189
|
+
entityId: row.source_entity_id
|
|
190
|
+
}
|
|
191
|
+
: null,
|
|
192
|
+
metadata: parseJsonObject(row.metadata_json, {}),
|
|
193
|
+
createdAt: row.created_at,
|
|
194
|
+
updatedAt: row.updated_at
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
function mapCatalogItem(row) {
|
|
198
|
+
return preferenceCatalogItemSchema.parse({
|
|
199
|
+
id: row.id,
|
|
200
|
+
catalogId: row.catalog_id,
|
|
201
|
+
label: row.label,
|
|
202
|
+
description: row.description,
|
|
203
|
+
tags: parseJsonArray(row.tags_json).filter(Boolean),
|
|
204
|
+
featureWeights: normalizeDimensionVector(parseJsonObject(row.feature_weights_json, {})),
|
|
205
|
+
position: row.position,
|
|
206
|
+
createdAt: row.created_at,
|
|
207
|
+
updatedAt: row.updated_at
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
function mapCatalog(row, items) {
|
|
211
|
+
return preferenceCatalogSchema.parse({
|
|
212
|
+
id: row.id,
|
|
213
|
+
profileId: row.profile_id,
|
|
214
|
+
domain: row.domain,
|
|
215
|
+
slug: row.slug,
|
|
216
|
+
title: row.title,
|
|
217
|
+
description: row.description,
|
|
218
|
+
source: row.source,
|
|
219
|
+
createdAt: row.created_at,
|
|
220
|
+
updatedAt: row.updated_at,
|
|
221
|
+
items
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
function mapJudgment(row) {
|
|
225
|
+
return pairwiseJudgmentSchema.parse({
|
|
226
|
+
id: row.id,
|
|
227
|
+
profileId: row.profile_id,
|
|
228
|
+
contextId: row.context_id,
|
|
229
|
+
userId: row.user_id,
|
|
230
|
+
leftItemId: row.left_item_id,
|
|
231
|
+
rightItemId: row.right_item_id,
|
|
232
|
+
outcome: row.outcome,
|
|
233
|
+
strength: row.strength,
|
|
234
|
+
responseTimeMs: row.response_time_ms,
|
|
235
|
+
source: row.source,
|
|
236
|
+
reasonTags: parseJsonArray(row.reason_tags_json).filter(Boolean),
|
|
237
|
+
createdAt: row.created_at
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
function mapSignal(row) {
|
|
241
|
+
return absoluteSignalSchema.parse({
|
|
242
|
+
id: row.id,
|
|
243
|
+
profileId: row.profile_id,
|
|
244
|
+
contextId: row.context_id,
|
|
245
|
+
userId: row.user_id,
|
|
246
|
+
itemId: row.item_id,
|
|
247
|
+
signalType: row.signal_type,
|
|
248
|
+
strength: row.strength,
|
|
249
|
+
source: row.source,
|
|
250
|
+
createdAt: row.created_at
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
function mapScore(row, item) {
|
|
254
|
+
return preferenceItemScoreSchema.parse({
|
|
255
|
+
id: row.id,
|
|
256
|
+
profileId: row.profile_id,
|
|
257
|
+
contextId: row.context_id,
|
|
258
|
+
itemId: row.item_id,
|
|
259
|
+
latentScore: row.latent_score,
|
|
260
|
+
confidence: row.confidence,
|
|
261
|
+
uncertainty: row.uncertainty,
|
|
262
|
+
evidenceCount: row.evidence_count,
|
|
263
|
+
pairwiseWins: row.pairwise_wins,
|
|
264
|
+
pairwiseLosses: row.pairwise_losses,
|
|
265
|
+
pairwiseTies: row.pairwise_ties,
|
|
266
|
+
signalCount: row.signal_count,
|
|
267
|
+
conflictCount: row.conflict_count,
|
|
268
|
+
status: row.status,
|
|
269
|
+
dominantDimensions: parseJsonArray(row.dominant_dimensions_json),
|
|
270
|
+
explanation: parseJsonArray(row.explanation_json),
|
|
271
|
+
manualStatus: row.manual_status,
|
|
272
|
+
manualScore: row.manual_score,
|
|
273
|
+
confidenceLock: row.confidence_lock,
|
|
274
|
+
bookmarked: row.bookmarked === 1,
|
|
275
|
+
compareLater: row.compare_later === 1,
|
|
276
|
+
frozen: row.frozen === 1,
|
|
277
|
+
lastInferredAt: row.last_inferred_at,
|
|
278
|
+
lastJudgmentAt: row.last_judgment_at,
|
|
279
|
+
updatedAt: row.updated_at,
|
|
280
|
+
item
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
function mapDimension(row) {
|
|
284
|
+
return preferenceDimensionSummarySchema.parse({
|
|
285
|
+
id: row.id,
|
|
286
|
+
profileId: row.profile_id,
|
|
287
|
+
contextId: row.context_id,
|
|
288
|
+
dimensionId: row.dimension_id,
|
|
289
|
+
leaning: row.leaning,
|
|
290
|
+
confidence: row.confidence,
|
|
291
|
+
movement: row.movement,
|
|
292
|
+
contextSensitivity: row.context_sensitivity,
|
|
293
|
+
evidenceCount: row.evidence_count,
|
|
294
|
+
updatedAt: row.updated_at
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
function mapSnapshot(row) {
|
|
298
|
+
return preferenceSnapshotSchema.parse({
|
|
299
|
+
id: row.id,
|
|
300
|
+
profileId: row.profile_id,
|
|
301
|
+
contextId: row.context_id,
|
|
302
|
+
summaryMetrics: parseJsonObject(row.summary_metrics_json, {}),
|
|
303
|
+
serializedModelState: parseJsonObject(row.serialized_model_state_json, {}),
|
|
304
|
+
createdAt: row.created_at
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
function readProfileByUserAndDomain(userId, domain) {
|
|
308
|
+
const row = getDatabase()
|
|
309
|
+
.prepare(`SELECT id, user_id, domain, default_context_id, model_version, created_at, updated_at
|
|
310
|
+
FROM preference_profiles
|
|
311
|
+
WHERE user_id = ? AND domain = ?`)
|
|
312
|
+
.get(userId, domain);
|
|
313
|
+
return row ? mapProfile(row) : null;
|
|
314
|
+
}
|
|
315
|
+
function readProfileById(profileId) {
|
|
316
|
+
const row = getDatabase()
|
|
317
|
+
.prepare(`SELECT id, user_id, domain, default_context_id, model_version, created_at, updated_at
|
|
318
|
+
FROM preference_profiles
|
|
319
|
+
WHERE id = ?`)
|
|
320
|
+
.get(profileId);
|
|
321
|
+
return row ? mapProfile(row) : null;
|
|
322
|
+
}
|
|
323
|
+
function listContexts(profileId) {
|
|
324
|
+
return getDatabase()
|
|
325
|
+
.prepare(`SELECT id, profile_id, name, description, share_mode, active, is_default, decay_days, created_at, updated_at
|
|
326
|
+
FROM preference_contexts
|
|
327
|
+
WHERE profile_id = ?
|
|
328
|
+
ORDER BY is_default DESC, active DESC, name ASC`)
|
|
329
|
+
.all(profileId).map(mapContext);
|
|
330
|
+
}
|
|
331
|
+
function readContext(contextId) {
|
|
332
|
+
const row = getDatabase()
|
|
333
|
+
.prepare(`SELECT id, profile_id, name, description, share_mode, active, is_default, decay_days, created_at, updated_at
|
|
334
|
+
FROM preference_contexts
|
|
335
|
+
WHERE id = ?`)
|
|
336
|
+
.get(contextId);
|
|
337
|
+
return row ? mapContext(row) : null;
|
|
338
|
+
}
|
|
339
|
+
function resolveContext(profile, contextId) {
|
|
340
|
+
const contexts = listContexts(profile.id);
|
|
341
|
+
const context = (contextId ? contexts.find((entry) => entry.id === contextId) : null) ??
|
|
342
|
+
contexts.find((entry) => entry.isDefault) ??
|
|
343
|
+
contexts[0];
|
|
344
|
+
if (!context) {
|
|
345
|
+
throw new HttpError(500, "preferences_missing_context", "Preference profile has no contexts");
|
|
346
|
+
}
|
|
347
|
+
return context;
|
|
348
|
+
}
|
|
349
|
+
function listItems(profileId) {
|
|
350
|
+
return getDatabase()
|
|
351
|
+
.prepare(`SELECT id, profile_id, label, description, tags_json, feature_weights_json, source_entity_type, source_entity_id, metadata_json, created_at, updated_at
|
|
352
|
+
FROM preference_items
|
|
353
|
+
WHERE profile_id = ?
|
|
354
|
+
ORDER BY updated_at DESC, created_at DESC`)
|
|
355
|
+
.all(profileId).map(mapItem);
|
|
356
|
+
}
|
|
357
|
+
function getItemById(itemId) {
|
|
358
|
+
const row = getDatabase()
|
|
359
|
+
.prepare(`SELECT id, profile_id, label, description, tags_json, feature_weights_json, source_entity_type, source_entity_id, metadata_json, created_at, updated_at
|
|
360
|
+
FROM preference_items
|
|
361
|
+
WHERE id = ?`)
|
|
362
|
+
.get(itemId);
|
|
363
|
+
return row ? mapItem(row) : null;
|
|
364
|
+
}
|
|
365
|
+
function listCatalogs(profileId) {
|
|
366
|
+
const catalogRows = getDatabase()
|
|
367
|
+
.prepare(`SELECT id, profile_id, domain, slug, title, description, source, archived, created_at, updated_at
|
|
368
|
+
FROM preference_catalogs
|
|
369
|
+
WHERE profile_id = ? AND archived = 0
|
|
370
|
+
ORDER BY source ASC, title ASC`)
|
|
371
|
+
.all(profileId).filter((row) => row.archived === 0);
|
|
372
|
+
const itemRows = getDatabase()
|
|
373
|
+
.prepare(`SELECT id, catalog_id, label, description, tags_json, feature_weights_json, position, archived, created_at, updated_at
|
|
374
|
+
FROM preference_catalog_items
|
|
375
|
+
WHERE catalog_id IN (
|
|
376
|
+
SELECT id FROM preference_catalogs WHERE profile_id = ? AND archived = 0
|
|
377
|
+
)
|
|
378
|
+
AND archived = 0
|
|
379
|
+
ORDER BY position ASC, label ASC`)
|
|
380
|
+
.all(profileId).filter((row) => row.archived === 0);
|
|
381
|
+
const itemsByCatalogId = new Map();
|
|
382
|
+
for (const row of itemRows) {
|
|
383
|
+
const list = itemsByCatalogId.get(row.catalog_id) ?? [];
|
|
384
|
+
list.push(mapCatalogItem(row));
|
|
385
|
+
itemsByCatalogId.set(row.catalog_id, list);
|
|
386
|
+
}
|
|
387
|
+
return catalogRows.map((row) => mapCatalog(row, itemsByCatalogId.get(row.id) ?? []));
|
|
388
|
+
}
|
|
389
|
+
function readCatalog(catalogId) {
|
|
390
|
+
const row = getDatabase()
|
|
391
|
+
.prepare(`SELECT id, profile_id, domain, slug, title, description, source, archived, created_at, updated_at
|
|
392
|
+
FROM preference_catalogs
|
|
393
|
+
WHERE id = ?`)
|
|
394
|
+
.get(catalogId);
|
|
395
|
+
if (!row || row.archived === 1) {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
const items = getDatabase()
|
|
399
|
+
.prepare(`SELECT id, catalog_id, label, description, tags_json, feature_weights_json, position, archived, created_at, updated_at
|
|
400
|
+
FROM preference_catalog_items
|
|
401
|
+
WHERE catalog_id = ? AND archived = 0
|
|
402
|
+
ORDER BY position ASC, label ASC`)
|
|
403
|
+
.all(catalogId)
|
|
404
|
+
.filter((entry) => entry.archived === 0)
|
|
405
|
+
.map(mapCatalogItem);
|
|
406
|
+
return mapCatalog(row, items);
|
|
407
|
+
}
|
|
408
|
+
function readCatalogItem(catalogItemId) {
|
|
409
|
+
const row = getDatabase()
|
|
410
|
+
.prepare(`SELECT id, catalog_id, label, description, tags_json, feature_weights_json, position, archived, created_at, updated_at
|
|
411
|
+
FROM preference_catalog_items
|
|
412
|
+
WHERE id = ?`)
|
|
413
|
+
.get(catalogItemId);
|
|
414
|
+
return row && row.archived === 0 ? mapCatalogItem(row) : null;
|
|
415
|
+
}
|
|
416
|
+
function listJudgmentsForContexts(contextIds) {
|
|
417
|
+
if (contextIds.length === 0) {
|
|
418
|
+
return [];
|
|
419
|
+
}
|
|
420
|
+
const placeholders = contextIds.map(() => "?").join(", ");
|
|
421
|
+
return getDatabase()
|
|
422
|
+
.prepare(`SELECT id, profile_id, context_id, user_id, left_item_id, right_item_id, outcome, strength, response_time_ms, source, reason_tags_json, created_at
|
|
423
|
+
FROM pairwise_judgments
|
|
424
|
+
WHERE context_id IN (${placeholders})
|
|
425
|
+
ORDER BY created_at DESC`)
|
|
426
|
+
.all(...contextIds).map(mapJudgment);
|
|
427
|
+
}
|
|
428
|
+
function listSignalsForContexts(contextIds) {
|
|
429
|
+
if (contextIds.length === 0) {
|
|
430
|
+
return [];
|
|
431
|
+
}
|
|
432
|
+
const placeholders = contextIds.map(() => "?").join(", ");
|
|
433
|
+
return getDatabase()
|
|
434
|
+
.prepare(`SELECT id, profile_id, context_id, user_id, item_id, signal_type, strength, source, created_at
|
|
435
|
+
FROM absolute_signals
|
|
436
|
+
WHERE context_id IN (${placeholders})
|
|
437
|
+
ORDER BY created_at DESC`)
|
|
438
|
+
.all(...contextIds).map(mapSignal);
|
|
439
|
+
}
|
|
440
|
+
function listStoredScores(contextId) {
|
|
441
|
+
return getDatabase()
|
|
442
|
+
.prepare(`SELECT id, profile_id, context_id, item_id, latent_score, confidence, uncertainty, evidence_count, pairwise_wins, pairwise_losses, pairwise_ties, signal_count, conflict_count, status, dominant_dimensions_json, explanation_json, manual_status, manual_score, confidence_lock, bookmarked, compare_later, frozen, last_inferred_at, last_judgment_at, updated_at
|
|
443
|
+
FROM preference_item_scores
|
|
444
|
+
WHERE context_id = ?`)
|
|
445
|
+
.all(contextId);
|
|
446
|
+
}
|
|
447
|
+
function listStoredDimensions(contextId) {
|
|
448
|
+
return getDatabase()
|
|
449
|
+
.prepare(`SELECT id, profile_id, context_id, dimension_id, leaning, confidence, movement, context_sensitivity, evidence_count, updated_at
|
|
450
|
+
FROM preference_dimension_summaries
|
|
451
|
+
WHERE context_id = ?
|
|
452
|
+
ORDER BY dimension_id ASC`)
|
|
453
|
+
.all(contextId).map(mapDimension);
|
|
454
|
+
}
|
|
455
|
+
function listSnapshots(contextId, limit = 24) {
|
|
456
|
+
return getDatabase()
|
|
457
|
+
.prepare(`SELECT id, profile_id, context_id, summary_metrics_json, serialized_model_state_json, created_at
|
|
458
|
+
FROM preference_snapshots
|
|
459
|
+
WHERE context_id = ?
|
|
460
|
+
ORDER BY created_at DESC
|
|
461
|
+
LIMIT ?`)
|
|
462
|
+
.all(contextId, limit).map(mapSnapshot);
|
|
463
|
+
}
|
|
464
|
+
function ensureUserExists(userId) {
|
|
465
|
+
const user = getUserById(userId);
|
|
466
|
+
if (!user) {
|
|
467
|
+
throw new HttpError(404, "user_not_found", `User ${userId} was not found.`);
|
|
468
|
+
}
|
|
469
|
+
return user;
|
|
470
|
+
}
|
|
471
|
+
function resolveSourceEntity(entityType, entityId) {
|
|
472
|
+
switch (entityType) {
|
|
473
|
+
case "goal": {
|
|
474
|
+
const goal = getGoalById(entityId);
|
|
475
|
+
return goal ? { label: goal.title, description: goal.description } : null;
|
|
476
|
+
}
|
|
477
|
+
case "project": {
|
|
478
|
+
const project = getProjectById(entityId);
|
|
479
|
+
return project
|
|
480
|
+
? { label: project.title, description: project.description }
|
|
481
|
+
: null;
|
|
482
|
+
}
|
|
483
|
+
case "task": {
|
|
484
|
+
const task = getTaskById(entityId);
|
|
485
|
+
return task ? { label: task.title, description: task.description } : null;
|
|
486
|
+
}
|
|
487
|
+
case "strategy": {
|
|
488
|
+
const strategy = getStrategyById(entityId);
|
|
489
|
+
return strategy
|
|
490
|
+
? { label: strategy.title, description: strategy.overview }
|
|
491
|
+
: null;
|
|
492
|
+
}
|
|
493
|
+
case "habit": {
|
|
494
|
+
const habit = getHabitById(entityId);
|
|
495
|
+
return habit ? { label: habit.title, description: habit.description } : null;
|
|
496
|
+
}
|
|
497
|
+
case "note": {
|
|
498
|
+
const note = getNoteById(entityId);
|
|
499
|
+
return note
|
|
500
|
+
? {
|
|
501
|
+
label: note.contentPlain.slice(0, 72) || "Linked note",
|
|
502
|
+
description: note.contentPlain
|
|
503
|
+
}
|
|
504
|
+
: null;
|
|
505
|
+
}
|
|
506
|
+
case "insight": {
|
|
507
|
+
const insight = getInsightById(entityId);
|
|
508
|
+
return insight
|
|
509
|
+
? { label: insight.title, description: insight.summary }
|
|
510
|
+
: null;
|
|
511
|
+
}
|
|
512
|
+
case "calendar_event": {
|
|
513
|
+
const event = getCalendarEventById(entityId);
|
|
514
|
+
return event ? { label: event.title, description: event.description } : null;
|
|
515
|
+
}
|
|
516
|
+
case "work_block_template": {
|
|
517
|
+
const template = getWorkBlockTemplateById(entityId);
|
|
518
|
+
return template
|
|
519
|
+
? { label: template.title, description: template.kind }
|
|
520
|
+
: null;
|
|
521
|
+
}
|
|
522
|
+
case "task_timebox": {
|
|
523
|
+
const timebox = getTaskTimeboxById(entityId);
|
|
524
|
+
return timebox
|
|
525
|
+
? { label: timebox.title, description: timebox.overrideReason ?? "" }
|
|
526
|
+
: null;
|
|
527
|
+
}
|
|
528
|
+
case "psyche_value": {
|
|
529
|
+
const value = getPsycheValueById(entityId);
|
|
530
|
+
return value
|
|
531
|
+
? { label: value.title, description: value.description }
|
|
532
|
+
: null;
|
|
533
|
+
}
|
|
534
|
+
case "behavior_pattern": {
|
|
535
|
+
const pattern = getBehaviorPatternById(entityId);
|
|
536
|
+
return pattern
|
|
537
|
+
? { label: pattern.title, description: pattern.description }
|
|
538
|
+
: null;
|
|
539
|
+
}
|
|
540
|
+
case "behavior": {
|
|
541
|
+
const behavior = getBehaviorById(entityId);
|
|
542
|
+
return behavior
|
|
543
|
+
? { label: behavior.title, description: behavior.description }
|
|
544
|
+
: null;
|
|
545
|
+
}
|
|
546
|
+
case "belief_entry": {
|
|
547
|
+
const belief = getBeliefEntryById(entityId);
|
|
548
|
+
return belief
|
|
549
|
+
? { label: belief.statement, description: belief.flexibleAlternative }
|
|
550
|
+
: null;
|
|
551
|
+
}
|
|
552
|
+
case "mode_profile": {
|
|
553
|
+
const mode = getModeProfileById(entityId);
|
|
554
|
+
return mode ? { label: mode.title, description: mode.persona } : null;
|
|
555
|
+
}
|
|
556
|
+
case "mode_guide_session": {
|
|
557
|
+
const session = getModeGuideSessionById(entityId);
|
|
558
|
+
return session
|
|
559
|
+
? { label: session.summary, description: session.summary }
|
|
560
|
+
: null;
|
|
561
|
+
}
|
|
562
|
+
case "event_type": {
|
|
563
|
+
const eventType = getEventTypeById(entityId);
|
|
564
|
+
return eventType
|
|
565
|
+
? { label: eventType.label, description: eventType.description }
|
|
566
|
+
: null;
|
|
567
|
+
}
|
|
568
|
+
case "emotion_definition": {
|
|
569
|
+
const emotion = getEmotionDefinitionById(entityId);
|
|
570
|
+
return emotion
|
|
571
|
+
? { label: emotion.label, description: emotion.description }
|
|
572
|
+
: null;
|
|
573
|
+
}
|
|
574
|
+
case "trigger_report": {
|
|
575
|
+
const report = getTriggerReportById(entityId);
|
|
576
|
+
return report
|
|
577
|
+
? { label: report.title, description: report.eventSituation }
|
|
578
|
+
: null;
|
|
579
|
+
}
|
|
580
|
+
case "tag":
|
|
581
|
+
default:
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
function ensureProfile(userId, domain) {
|
|
586
|
+
ensureUserExists(userId);
|
|
587
|
+
const existing = readProfileByUserAndDomain(userId, domain);
|
|
588
|
+
if (existing) {
|
|
589
|
+
if (listContexts(existing.id).length === 0) {
|
|
590
|
+
createDefaultContexts(existing.id);
|
|
591
|
+
}
|
|
592
|
+
ensureCatalogs(existing.id, domain);
|
|
593
|
+
return readProfileById(existing.id) ?? existing;
|
|
594
|
+
}
|
|
595
|
+
const now = nowIso();
|
|
596
|
+
const profileId = `pref_profile_${randomUUID().slice(0, 10)}`;
|
|
597
|
+
runInTransaction(() => {
|
|
598
|
+
getDatabase()
|
|
599
|
+
.prepare(`INSERT INTO preference_profiles (id, user_id, domain, default_context_id, model_version, created_at, updated_at)
|
|
600
|
+
VALUES (?, ?, ?, NULL, ?, ?, ?)`)
|
|
601
|
+
.run(profileId, userId, domain, PREFERENCE_MODEL_VERSION, now, now);
|
|
602
|
+
createDefaultContexts(profileId);
|
|
603
|
+
ensureCatalogs(profileId, domain);
|
|
604
|
+
});
|
|
605
|
+
const created = readProfileById(profileId);
|
|
606
|
+
if (!created) {
|
|
607
|
+
throw new HttpError(500, "preferences_profile_missing", "Preference profile could not be created.");
|
|
608
|
+
}
|
|
609
|
+
return created;
|
|
610
|
+
}
|
|
611
|
+
function createDefaultContexts(profileId) {
|
|
612
|
+
const now = nowIso();
|
|
613
|
+
const database = getDatabase();
|
|
614
|
+
const insertedContextIds = [];
|
|
615
|
+
for (const template of DEFAULT_CONTEXT_TEMPLATES) {
|
|
616
|
+
const contextId = `pref_ctx_${template.key}_${randomUUID().slice(0, 8)}`;
|
|
617
|
+
insertedContextIds.push(contextId);
|
|
618
|
+
database
|
|
619
|
+
.prepare(`INSERT INTO preference_contexts (id, profile_id, name, description, share_mode, active, is_default, decay_days, created_at, updated_at)
|
|
620
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
621
|
+
.run(contextId, profileId, template.name, template.description, template.shareMode, template.active ? 1 : 0, template.isDefault ? 1 : 0, template.decayDays, now, now);
|
|
622
|
+
}
|
|
623
|
+
const defaultContextId = insertedContextIds[0] ?? null;
|
|
624
|
+
database
|
|
625
|
+
.prepare(`UPDATE preference_profiles
|
|
626
|
+
SET default_context_id = ?, updated_at = ?
|
|
627
|
+
WHERE id = ?`)
|
|
628
|
+
.run(defaultContextId, now, profileId);
|
|
629
|
+
}
|
|
630
|
+
function createSeededCatalogs(profileId, domain) {
|
|
631
|
+
const seeds = getPreferenceCatalogSeeds(domain);
|
|
632
|
+
if (seeds.length === 0) {
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const database = getDatabase();
|
|
636
|
+
const now = nowIso();
|
|
637
|
+
for (const seed of seeds) {
|
|
638
|
+
const catalogId = `pref_catalog_${randomUUID().slice(0, 10)}`;
|
|
639
|
+
database
|
|
640
|
+
.prepare(`INSERT INTO preference_catalogs (
|
|
641
|
+
id, profile_id, domain, slug, title, description, source, archived, created_at, updated_at
|
|
642
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`)
|
|
643
|
+
.run(catalogId, profileId, domain, seed.slug, seed.title, seed.description, preferenceCatalogSourceSchema.enum.seeded, now, now);
|
|
644
|
+
seed.items.forEach((seedItem, index) => {
|
|
645
|
+
database
|
|
646
|
+
.prepare(`INSERT INTO preference_catalog_items (
|
|
647
|
+
id, catalog_id, label, description, tags_json, feature_weights_json, position, archived, created_at, updated_at
|
|
648
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`)
|
|
649
|
+
.run(`pref_catalog_item_${randomUUID().slice(0, 10)}`, catalogId, seedItem.label, seedItem.description, JSON.stringify(seedItem.tags), JSON.stringify(seedItem.featureWeights), index, now, now);
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
function ensureCatalogs(profileId, domain) {
|
|
654
|
+
const existingCount = getDatabase()
|
|
655
|
+
.prepare(`SELECT COUNT(*) as count
|
|
656
|
+
FROM preference_catalogs
|
|
657
|
+
WHERE profile_id = ?`)
|
|
658
|
+
.get(profileId);
|
|
659
|
+
if (existingCount.count === 0) {
|
|
660
|
+
createSeededCatalogs(profileId, domain);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
function buildEvidenceFactorMap(contexts, selectedContext) {
|
|
664
|
+
const factors = new Map();
|
|
665
|
+
for (const context of contexts.filter((entry) => entry.active)) {
|
|
666
|
+
if (selectedContext.shareMode === "isolated") {
|
|
667
|
+
factors.set(context.id, context.id === selectedContext.id ? 1 : 0);
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (selectedContext.shareMode === "shared") {
|
|
671
|
+
factors.set(context.id, 1);
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
factors.set(context.id, context.id === selectedContext.id ? 1 : 0.45);
|
|
675
|
+
}
|
|
676
|
+
return factors;
|
|
677
|
+
}
|
|
678
|
+
function deriveStatus(options) {
|
|
679
|
+
const { manualStatus, score, confidence, bookmarked, compareLater, signals } = options;
|
|
680
|
+
if (manualStatus) {
|
|
681
|
+
return manualStatus;
|
|
682
|
+
}
|
|
683
|
+
if (signals.includes("veto")) {
|
|
684
|
+
return "vetoed";
|
|
685
|
+
}
|
|
686
|
+
if (signals.includes("must_have")) {
|
|
687
|
+
return "must_have";
|
|
688
|
+
}
|
|
689
|
+
if (signals.includes("favorite")) {
|
|
690
|
+
return "favorite";
|
|
691
|
+
}
|
|
692
|
+
if (bookmarked || compareLater || signals.includes("bookmark")) {
|
|
693
|
+
return "bookmarked";
|
|
694
|
+
}
|
|
695
|
+
if (confidence < 0.42) {
|
|
696
|
+
return "uncertain";
|
|
697
|
+
}
|
|
698
|
+
if (score >= 0.35) {
|
|
699
|
+
return "liked";
|
|
700
|
+
}
|
|
701
|
+
if (score <= -0.35) {
|
|
702
|
+
return "disliked";
|
|
703
|
+
}
|
|
704
|
+
return "neutral";
|
|
705
|
+
}
|
|
706
|
+
function computeDimensionSummaries(options) {
|
|
707
|
+
const { contexts, selectedContext, itemsById, judgments, signals } = options;
|
|
708
|
+
const evidenceFactors = buildEvidenceFactorMap(contexts, selectedContext);
|
|
709
|
+
const leaning = new Map(DIMENSION_IDS.map((dimensionId) => [dimensionId, 0]));
|
|
710
|
+
const recent = new Map(DIMENSION_IDS.map((dimensionId) => [dimensionId, 0]));
|
|
711
|
+
const counts = new Map(DIMENSION_IDS.map((dimensionId) => [dimensionId, 0]));
|
|
712
|
+
for (const signal of signals) {
|
|
713
|
+
const item = itemsById.get(signal.itemId);
|
|
714
|
+
const factor = evidenceFactors.get(signal.contextId) ?? 0;
|
|
715
|
+
if (!item || factor <= 0) {
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
const weight = SIGNAL_WEIGHTS[signal.signalType] *
|
|
719
|
+
signal.strength *
|
|
720
|
+
factor *
|
|
721
|
+
timeDecay(ageInDays(signal.createdAt), selectedContext.decayDays);
|
|
722
|
+
const recentFactor = ageInDays(signal.createdAt) <= 21 ? 1 : 0;
|
|
723
|
+
for (const dimensionId of DIMENSION_IDS) {
|
|
724
|
+
const contribution = item.featureWeights[dimensionId] * weight;
|
|
725
|
+
leaning.set(dimensionId, (leaning.get(dimensionId) ?? 0) + contribution);
|
|
726
|
+
recent.set(dimensionId, (recent.get(dimensionId) ?? 0) + contribution * recentFactor);
|
|
727
|
+
if (Math.abs(item.featureWeights[dimensionId]) > 0.01) {
|
|
728
|
+
counts.set(dimensionId, (counts.get(dimensionId) ?? 0) + 1);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
for (const judgment of judgments) {
|
|
733
|
+
const left = itemsById.get(judgment.leftItemId);
|
|
734
|
+
const right = itemsById.get(judgment.rightItemId);
|
|
735
|
+
const factor = evidenceFactors.get(judgment.contextId) ?? 0;
|
|
736
|
+
if (!left || !right || factor <= 0 || judgment.outcome === "skip") {
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
const outcomeSign = judgment.outcome === "left" ? 1 : judgment.outcome === "right" ? -1 : 0;
|
|
740
|
+
const weight = judgment.strength *
|
|
741
|
+
factor *
|
|
742
|
+
timeDecay(ageInDays(judgment.createdAt), selectedContext.decayDays);
|
|
743
|
+
const recentFactor = ageInDays(judgment.createdAt) <= 21 ? 1 : 0;
|
|
744
|
+
for (const dimensionId of DIMENSION_IDS) {
|
|
745
|
+
const contribution = (left.featureWeights[dimensionId] - right.featureWeights[dimensionId]) *
|
|
746
|
+
outcomeSign *
|
|
747
|
+
weight;
|
|
748
|
+
leaning.set(dimensionId, (leaning.get(dimensionId) ?? 0) + contribution);
|
|
749
|
+
recent.set(dimensionId, (recent.get(dimensionId) ?? 0) + contribution * recentFactor);
|
|
750
|
+
if (Math.abs(left.featureWeights[dimensionId]) > 0.01 ||
|
|
751
|
+
Math.abs(right.featureWeights[dimensionId]) > 0.01) {
|
|
752
|
+
counts.set(dimensionId, (counts.get(dimensionId) ?? 0) + 1);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return DIMENSION_IDS.map((dimensionId) => ({
|
|
757
|
+
id: `pref_dim_${selectedContext.id}_${dimensionId}`,
|
|
758
|
+
profileId: selectedContext.profileId,
|
|
759
|
+
contextId: selectedContext.id,
|
|
760
|
+
dimensionId,
|
|
761
|
+
leaning: clamp(tanhScale(leaning.get(dimensionId) ?? 0, 3), -1, 1),
|
|
762
|
+
confidence: clamp(1 - Math.exp(-(counts.get(dimensionId) ?? 0) / 3), 0, 1),
|
|
763
|
+
movement: clamp(tanhScale(recent.get(dimensionId) ?? 0, 2), -1, 1),
|
|
764
|
+
contextSensitivity: 0,
|
|
765
|
+
evidenceCount: counts.get(dimensionId) ?? 0,
|
|
766
|
+
updatedAt: nowIso()
|
|
767
|
+
}));
|
|
768
|
+
}
|
|
769
|
+
function computeScores(options) {
|
|
770
|
+
const { profile, contexts, selectedContext, items, judgments, signals, existingScores } = options;
|
|
771
|
+
const itemsById = new Map(items.map((item) => [item.id, item]));
|
|
772
|
+
const evidenceFactors = buildEvidenceFactorMap(contexts, selectedContext);
|
|
773
|
+
const dimensionSummaries = computeDimensionSummaries({
|
|
774
|
+
contexts,
|
|
775
|
+
selectedContext,
|
|
776
|
+
itemsById,
|
|
777
|
+
judgments,
|
|
778
|
+
signals
|
|
779
|
+
});
|
|
780
|
+
const dimensionLeanings = new Map(dimensionSummaries.map((summary) => [summary.dimensionId, summary.leaning]));
|
|
781
|
+
const manualByItemId = new Map(existingScores.map((score) => [score.item_id, score]));
|
|
782
|
+
const perItem = new Map(items.map((item) => [
|
|
783
|
+
item.id,
|
|
784
|
+
{
|
|
785
|
+
raw: 0,
|
|
786
|
+
wins: 0,
|
|
787
|
+
losses: 0,
|
|
788
|
+
ties: 0,
|
|
789
|
+
signalCount: 0,
|
|
790
|
+
evidenceCount: 0,
|
|
791
|
+
lastJudgmentAt: null,
|
|
792
|
+
lastEvidenceAt: null,
|
|
793
|
+
signals: []
|
|
794
|
+
}
|
|
795
|
+
]));
|
|
796
|
+
const pairDirections = new Map();
|
|
797
|
+
for (const signal of signals) {
|
|
798
|
+
const itemStats = perItem.get(signal.itemId);
|
|
799
|
+
const factor = evidenceFactors.get(signal.contextId) ?? 0;
|
|
800
|
+
if (!itemStats || factor <= 0) {
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
const weight = SIGNAL_WEIGHTS[signal.signalType] *
|
|
804
|
+
signal.strength *
|
|
805
|
+
factor *
|
|
806
|
+
timeDecay(ageInDays(signal.createdAt), selectedContext.decayDays);
|
|
807
|
+
itemStats.raw += weight;
|
|
808
|
+
itemStats.signalCount += 1;
|
|
809
|
+
itemStats.evidenceCount += 1;
|
|
810
|
+
itemStats.signals.push(signal.signalType);
|
|
811
|
+
itemStats.lastEvidenceAt =
|
|
812
|
+
!itemStats.lastEvidenceAt || signal.createdAt > itemStats.lastEvidenceAt
|
|
813
|
+
? signal.createdAt
|
|
814
|
+
: itemStats.lastEvidenceAt;
|
|
815
|
+
}
|
|
816
|
+
for (const judgment of judgments) {
|
|
817
|
+
const leftStats = perItem.get(judgment.leftItemId);
|
|
818
|
+
const rightStats = perItem.get(judgment.rightItemId);
|
|
819
|
+
const factor = evidenceFactors.get(judgment.contextId) ?? 0;
|
|
820
|
+
if (!leftStats ||
|
|
821
|
+
!rightStats ||
|
|
822
|
+
factor <= 0 ||
|
|
823
|
+
judgment.outcome === "skip") {
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
const weight = judgment.strength *
|
|
827
|
+
factor *
|
|
828
|
+
timeDecay(ageInDays(judgment.createdAt), selectedContext.decayDays);
|
|
829
|
+
const pairKey = [judgment.leftItemId, judgment.rightItemId].sort().join("::");
|
|
830
|
+
const pairState = pairDirections.get(pairKey) ?? { leftWins: 0, rightWins: 0 };
|
|
831
|
+
if (judgment.outcome === "left") {
|
|
832
|
+
leftStats.raw += weight;
|
|
833
|
+
rightStats.raw -= weight;
|
|
834
|
+
leftStats.wins += 1;
|
|
835
|
+
rightStats.losses += 1;
|
|
836
|
+
pairState.leftWins += 1;
|
|
837
|
+
}
|
|
838
|
+
else if (judgment.outcome === "right") {
|
|
839
|
+
leftStats.raw -= weight;
|
|
840
|
+
rightStats.raw += weight;
|
|
841
|
+
leftStats.losses += 1;
|
|
842
|
+
rightStats.wins += 1;
|
|
843
|
+
pairState.rightWins += 1;
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
leftStats.ties += 1;
|
|
847
|
+
rightStats.ties += 1;
|
|
848
|
+
}
|
|
849
|
+
pairDirections.set(pairKey, pairState);
|
|
850
|
+
leftStats.evidenceCount += 1;
|
|
851
|
+
rightStats.evidenceCount += 1;
|
|
852
|
+
leftStats.lastJudgmentAt =
|
|
853
|
+
!leftStats.lastJudgmentAt || judgment.createdAt > leftStats.lastJudgmentAt
|
|
854
|
+
? judgment.createdAt
|
|
855
|
+
: leftStats.lastJudgmentAt;
|
|
856
|
+
rightStats.lastJudgmentAt =
|
|
857
|
+
!rightStats.lastJudgmentAt || judgment.createdAt > rightStats.lastJudgmentAt
|
|
858
|
+
? judgment.createdAt
|
|
859
|
+
: rightStats.lastJudgmentAt;
|
|
860
|
+
leftStats.lastEvidenceAt =
|
|
861
|
+
!leftStats.lastEvidenceAt || judgment.createdAt > leftStats.lastEvidenceAt
|
|
862
|
+
? judgment.createdAt
|
|
863
|
+
: leftStats.lastEvidenceAt;
|
|
864
|
+
rightStats.lastEvidenceAt =
|
|
865
|
+
!rightStats.lastEvidenceAt || judgment.createdAt > rightStats.lastEvidenceAt
|
|
866
|
+
? judgment.createdAt
|
|
867
|
+
: rightStats.lastEvidenceAt;
|
|
868
|
+
}
|
|
869
|
+
const conflictCountByItem = new Map();
|
|
870
|
+
for (const [pairKey, pairState] of pairDirections) {
|
|
871
|
+
if (pairState.leftWins === 0 || pairState.rightWins === 0) {
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
const [leftItemId, rightItemId] = pairKey.split("::");
|
|
875
|
+
conflictCountByItem.set(leftItemId, (conflictCountByItem.get(leftItemId) ?? 0) + 1);
|
|
876
|
+
conflictCountByItem.set(rightItemId, (conflictCountByItem.get(rightItemId) ?? 0) + 1);
|
|
877
|
+
}
|
|
878
|
+
const scores = items.map((item) => {
|
|
879
|
+
const existing = manualByItemId.get(item.id);
|
|
880
|
+
const stats = perItem.get(item.id);
|
|
881
|
+
const dominantDimensions = [...DIMENSION_IDS]
|
|
882
|
+
.map((dimensionId) => ({
|
|
883
|
+
dimensionId,
|
|
884
|
+
weight: Math.abs(item.featureWeights[dimensionId]) *
|
|
885
|
+
Math.abs(dimensionLeanings.get(dimensionId) ?? 0)
|
|
886
|
+
}))
|
|
887
|
+
.filter((entry) => entry.weight > 0)
|
|
888
|
+
.sort((left, right) => right.weight - left.weight)
|
|
889
|
+
.slice(0, 3)
|
|
890
|
+
.map((entry) => entry.dimensionId);
|
|
891
|
+
const score = clamp(existing?.manual_score ?? tanhScale(stats.raw, 4), -1, 1);
|
|
892
|
+
const freshness = Math.exp(-Math.max(0, ageInDays(stats.lastEvidenceAt) - selectedContext.decayDays) /
|
|
893
|
+
Math.max(14, selectedContext.decayDays));
|
|
894
|
+
const conflictPenalty = 1 -
|
|
895
|
+
Math.min(0.55, (conflictCountByItem.get(item.id) ?? 0) /
|
|
896
|
+
Math.max(1, stats.evidenceCount));
|
|
897
|
+
const confidence = existing?.confidence_lock ??
|
|
898
|
+
clamp((1 - Math.exp(-stats.evidenceCount / 4)) *
|
|
899
|
+
conflictPenalty *
|
|
900
|
+
(0.55 + 0.45 * freshness), 0.04, 1);
|
|
901
|
+
const bookmarked = (existing?.bookmarked ?? 0) === 1 ||
|
|
902
|
+
stats.signals.includes("bookmark");
|
|
903
|
+
const compareLater = (existing?.compare_later ?? 0) === 1 ||
|
|
904
|
+
stats.signals.includes("compare_later");
|
|
905
|
+
const status = deriveStatus({
|
|
906
|
+
manualStatus: existing?.manual_status ?? null,
|
|
907
|
+
score,
|
|
908
|
+
confidence,
|
|
909
|
+
bookmarked,
|
|
910
|
+
compareLater,
|
|
911
|
+
signals: stats.signals
|
|
912
|
+
});
|
|
913
|
+
const explanation = [
|
|
914
|
+
stats.wins > 0 ? `Preferred over peers ${stats.wins} time${stats.wins === 1 ? "" : "s"}.` : null,
|
|
915
|
+
stats.losses > 0 ? `Lost against peers ${stats.losses} time${stats.losses === 1 ? "" : "s"}.` : null,
|
|
916
|
+
stats.signalCount > 0
|
|
917
|
+
? `Direct signals recorded: ${stats.signals.join(", ")}.`
|
|
918
|
+
: null,
|
|
919
|
+
dominantDimensions.length > 0
|
|
920
|
+
? `Dominant dimensions: ${dominantDimensions.join(", ")}.`
|
|
921
|
+
: null,
|
|
922
|
+
(conflictCountByItem.get(item.id) ?? 0) > 0
|
|
923
|
+
? "Conflicting pairwise evidence lowers confidence."
|
|
924
|
+
: null,
|
|
925
|
+
ageInDays(stats.lastEvidenceAt) > selectedContext.decayDays
|
|
926
|
+
? "Evidence is getting stale and should be recalibrated."
|
|
927
|
+
: null
|
|
928
|
+
].filter((value) => Boolean(value));
|
|
929
|
+
return {
|
|
930
|
+
itemId: item.id,
|
|
931
|
+
latentScore: score,
|
|
932
|
+
confidence,
|
|
933
|
+
uncertainty: clamp(1 - confidence, 0, 1),
|
|
934
|
+
evidenceCount: stats.evidenceCount,
|
|
935
|
+
pairwiseWins: stats.wins,
|
|
936
|
+
pairwiseLosses: stats.losses,
|
|
937
|
+
pairwiseTies: stats.ties,
|
|
938
|
+
signalCount: stats.signalCount,
|
|
939
|
+
conflictCount: conflictCountByItem.get(item.id) ?? 0,
|
|
940
|
+
status,
|
|
941
|
+
dominantDimensions,
|
|
942
|
+
explanation,
|
|
943
|
+
manualStatus: existing?.manual_status ?? null,
|
|
944
|
+
manualScore: existing?.manual_score ?? null,
|
|
945
|
+
confidenceLock: existing?.confidence_lock ?? null,
|
|
946
|
+
bookmarked,
|
|
947
|
+
compareLater,
|
|
948
|
+
frozen: (existing?.frozen ?? 0) === 1,
|
|
949
|
+
lastInferredAt: nowIso(),
|
|
950
|
+
lastJudgmentAt: stats.lastJudgmentAt,
|
|
951
|
+
updatedAt: nowIso()
|
|
952
|
+
};
|
|
953
|
+
});
|
|
954
|
+
const averageSensitivityByDimension = new Map(dimensionSummaries.map((summary) => [summary.dimensionId, 0]));
|
|
955
|
+
const contextOnlyDimensionsByContext = new Map();
|
|
956
|
+
for (const context of contexts.filter((entry) => entry.active)) {
|
|
957
|
+
const isolatedDimensions = computeDimensionSummaries({
|
|
958
|
+
contexts: contexts.map((entry) => entry.id === context.id
|
|
959
|
+
? { ...entry, shareMode: "isolated" }
|
|
960
|
+
: { ...entry, active: false }),
|
|
961
|
+
selectedContext: { ...context, shareMode: "isolated" },
|
|
962
|
+
itemsById,
|
|
963
|
+
judgments,
|
|
964
|
+
signals
|
|
965
|
+
});
|
|
966
|
+
contextOnlyDimensionsByContext.set(context.id, new Map(isolatedDimensions.map((summary) => [summary.dimensionId, summary.leaning])));
|
|
967
|
+
}
|
|
968
|
+
const selectedIsolated = contextOnlyDimensionsByContext.get(selectedContext.id) ?? new Map();
|
|
969
|
+
for (const summary of dimensionSummaries) {
|
|
970
|
+
const otherLeanings = [...contextOnlyDimensionsByContext.entries()]
|
|
971
|
+
.filter(([contextId]) => contextId !== selectedContext.id)
|
|
972
|
+
.map(([, leaningByDimension]) => leaningByDimension.get(summary.dimensionId) ?? 0);
|
|
973
|
+
const averageOther = otherLeanings.length === 0
|
|
974
|
+
? 0
|
|
975
|
+
: otherLeanings.reduce((sum, value) => sum + value, 0) /
|
|
976
|
+
otherLeanings.length;
|
|
977
|
+
averageSensitivityByDimension.set(summary.dimensionId, clamp(Math.abs((selectedIsolated.get(summary.dimensionId) ?? 0) - averageOther), 0, 1));
|
|
978
|
+
}
|
|
979
|
+
return {
|
|
980
|
+
scores,
|
|
981
|
+
dimensions: dimensionSummaries.map((summary) => ({
|
|
982
|
+
...summary,
|
|
983
|
+
contextSensitivity: averageSensitivityByDimension.get(summary.dimensionId) ?? 0
|
|
984
|
+
}))
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
function buildNextPair(options) {
|
|
988
|
+
const { selectedContext, items, scores, judgments } = options;
|
|
989
|
+
const scoreByItemId = new Map(scores.map((score) => [score.itemId, score]));
|
|
990
|
+
const pairHistory = new Map();
|
|
991
|
+
for (const judgment of judgments.filter((entry) => entry.contextId === selectedContext.id)) {
|
|
992
|
+
const pairKey = [judgment.leftItemId, judgment.rightItemId].sort().join("::");
|
|
993
|
+
const current = pairHistory.get(pairKey) ?? { count: 0, lastCreatedAt: null };
|
|
994
|
+
pairHistory.set(pairKey, {
|
|
995
|
+
count: current.count + 1,
|
|
996
|
+
lastCreatedAt: !current.lastCreatedAt || judgment.createdAt > current.lastCreatedAt
|
|
997
|
+
? judgment.createdAt
|
|
998
|
+
: current.lastCreatedAt
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
let best = null;
|
|
1002
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
1003
|
+
for (let innerIndex = index + 1; innerIndex < items.length; innerIndex += 1) {
|
|
1004
|
+
const left = items[index];
|
|
1005
|
+
const right = items[innerIndex];
|
|
1006
|
+
const leftScore = scoreByItemId.get(left.id);
|
|
1007
|
+
const rightScore = scoreByItemId.get(right.id);
|
|
1008
|
+
if (!leftScore || !rightScore) {
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
if (leftScore.status === "vetoed" || rightScore.status === "vetoed") {
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
const pairKey = [left.id, right.id].sort().join("::");
|
|
1015
|
+
const history = pairHistory.get(pairKey);
|
|
1016
|
+
const uncertaintyGain = (leftScore.uncertainty + rightScore.uncertainty) / 2;
|
|
1017
|
+
const boundaryValue = 1 - Math.min(1, Math.abs(leftScore.latentScore - rightScore.latentScore));
|
|
1018
|
+
const diversityBonus = clamp(vectorDistance(left.featureWeights, right.featureWeights), 0, 1);
|
|
1019
|
+
const contextNeed = (leftScore.evidenceCount + rightScore.evidenceCount) < 6 ? 0.35 : 0.1;
|
|
1020
|
+
const driftProbe = !history?.lastCreatedAt || ageInDays(history.lastCreatedAt) > 45 ? 0.25 : 0;
|
|
1021
|
+
const repetitionPenalty = !history
|
|
1022
|
+
? 0
|
|
1023
|
+
: ageInDays(history.lastCreatedAt) < 7
|
|
1024
|
+
? 0.7 + history.count * 0.08
|
|
1025
|
+
: history.count * 0.08;
|
|
1026
|
+
const queueBias = (leftScore.compareLater || leftScore.bookmarked ? 0.15 : 0) +
|
|
1027
|
+
(rightScore.compareLater || rightScore.bookmarked ? 0.15 : 0);
|
|
1028
|
+
const candidateScore = uncertaintyGain +
|
|
1029
|
+
boundaryValue +
|
|
1030
|
+
diversityBonus +
|
|
1031
|
+
contextNeed +
|
|
1032
|
+
driftProbe +
|
|
1033
|
+
queueBias -
|
|
1034
|
+
repetitionPenalty;
|
|
1035
|
+
if (!best || candidateScore > best.score) {
|
|
1036
|
+
best = {
|
|
1037
|
+
left,
|
|
1038
|
+
right,
|
|
1039
|
+
score: candidateScore,
|
|
1040
|
+
rationale: [
|
|
1041
|
+
uncertaintyGain > 0.45
|
|
1042
|
+
? "Both items still carry meaningful uncertainty."
|
|
1043
|
+
: "These items are close enough to refine the boundary.",
|
|
1044
|
+
boundaryValue > 0.5
|
|
1045
|
+
? "Their current scores are close enough to be informative."
|
|
1046
|
+
: "This pair helps bridge different regions of the map.",
|
|
1047
|
+
driftProbe > 0
|
|
1048
|
+
? "This pair also checks for drift in older assumptions."
|
|
1049
|
+
: "This pair improves the current local ordering."
|
|
1050
|
+
]
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
if (!best) {
|
|
1056
|
+
return null;
|
|
1057
|
+
}
|
|
1058
|
+
return {
|
|
1059
|
+
left: best.left,
|
|
1060
|
+
right: best.right,
|
|
1061
|
+
rationale: best.rationale,
|
|
1062
|
+
score: best.score
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
function buildMap(items, scores) {
|
|
1066
|
+
const scoreByItemId = new Map(scores.map((score) => [score.itemId, score]));
|
|
1067
|
+
return items.map((item) => {
|
|
1068
|
+
const score = scoreByItemId.get(item.id);
|
|
1069
|
+
const x = item.featureWeights.novelty -
|
|
1070
|
+
item.featureWeights.familiarity +
|
|
1071
|
+
item.featureWeights.surprise * 0.5;
|
|
1072
|
+
const y = item.featureWeights.rigor * 0.7 +
|
|
1073
|
+
item.featureWeights.depth * 0.7 +
|
|
1074
|
+
item.featureWeights.structure * 0.5 -
|
|
1075
|
+
item.featureWeights.simplicity * 0.25;
|
|
1076
|
+
return {
|
|
1077
|
+
itemId: item.id,
|
|
1078
|
+
label: item.label,
|
|
1079
|
+
x: clamp(x, -2, 2),
|
|
1080
|
+
y: clamp(y, -2, 2),
|
|
1081
|
+
score: score?.latentScore ?? 0,
|
|
1082
|
+
confidence: score?.confidence ?? 0,
|
|
1083
|
+
uncertainty: score?.uncertainty ?? 1,
|
|
1084
|
+
status: score?.status ?? "uncertain",
|
|
1085
|
+
clusterKey: item.tags[0] ?? item.sourceEntityType ?? "untagged",
|
|
1086
|
+
tags: item.tags,
|
|
1087
|
+
sourceEntityType: item.sourceEntityType ?? null,
|
|
1088
|
+
sourceEntityId: item.sourceEntityId ?? null
|
|
1089
|
+
};
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
function persistScoresAndDimensions(options) {
|
|
1093
|
+
const { profile, selectedContext, scores, dimensions, snapshotsSummary } = options;
|
|
1094
|
+
const database = getDatabase();
|
|
1095
|
+
const timestamp = nowIso();
|
|
1096
|
+
database
|
|
1097
|
+
.prepare(`DELETE FROM preference_item_scores WHERE context_id = ?`)
|
|
1098
|
+
.run(selectedContext.id);
|
|
1099
|
+
for (const score of scores) {
|
|
1100
|
+
database
|
|
1101
|
+
.prepare(`INSERT INTO preference_item_scores (
|
|
1102
|
+
id, profile_id, context_id, item_id, latent_score, confidence, uncertainty, evidence_count,
|
|
1103
|
+
pairwise_wins, pairwise_losses, pairwise_ties, signal_count, conflict_count, status,
|
|
1104
|
+
dominant_dimensions_json, explanation_json, manual_status, manual_score, confidence_lock,
|
|
1105
|
+
bookmarked, compare_later, frozen, last_inferred_at, last_judgment_at, updated_at
|
|
1106
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1107
|
+
.run(`pref_score_${randomUUID().slice(0, 10)}`, profile.id, selectedContext.id, score.itemId, score.latentScore, score.confidence, score.uncertainty, score.evidenceCount, score.pairwiseWins, score.pairwiseLosses, score.pairwiseTies, score.signalCount, score.conflictCount, score.status, JSON.stringify(score.dominantDimensions), JSON.stringify(score.explanation), score.manualStatus, score.manualScore, score.confidenceLock, score.bookmarked ? 1 : 0, score.compareLater ? 1 : 0, score.frozen ? 1 : 0, score.lastInferredAt, score.lastJudgmentAt, score.updatedAt);
|
|
1108
|
+
}
|
|
1109
|
+
database
|
|
1110
|
+
.prepare(`DELETE FROM preference_dimension_summaries WHERE context_id = ?`)
|
|
1111
|
+
.run(selectedContext.id);
|
|
1112
|
+
for (const summary of dimensions) {
|
|
1113
|
+
database
|
|
1114
|
+
.prepare(`INSERT INTO preference_dimension_summaries (
|
|
1115
|
+
id, profile_id, context_id, dimension_id, leaning, confidence, movement, context_sensitivity, evidence_count, updated_at
|
|
1116
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1117
|
+
.run(summary.id, profile.id, selectedContext.id, summary.dimensionId, summary.leaning, summary.confidence, summary.movement, summary.contextSensitivity, summary.evidenceCount, summary.updatedAt);
|
|
1118
|
+
}
|
|
1119
|
+
database
|
|
1120
|
+
.prepare(`INSERT INTO preference_snapshots (id, profile_id, context_id, summary_metrics_json, serialized_model_state_json, created_at)
|
|
1121
|
+
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
1122
|
+
.run(`pref_snapshot_${randomUUID().slice(0, 10)}`, profile.id, selectedContext.id, JSON.stringify(snapshotsSummary), JSON.stringify({
|
|
1123
|
+
topScores: scores
|
|
1124
|
+
.slice()
|
|
1125
|
+
.sort((left, right) => right.latentScore - left.latentScore)
|
|
1126
|
+
.slice(0, 12)
|
|
1127
|
+
.map((score) => ({
|
|
1128
|
+
itemId: score.itemId,
|
|
1129
|
+
latentScore: score.latentScore,
|
|
1130
|
+
confidence: score.confidence,
|
|
1131
|
+
status: score.status
|
|
1132
|
+
})),
|
|
1133
|
+
dimensions: dimensions.map((dimension) => ({
|
|
1134
|
+
dimensionId: dimension.dimensionId,
|
|
1135
|
+
leaning: dimension.leaning,
|
|
1136
|
+
confidence: dimension.confidence
|
|
1137
|
+
}))
|
|
1138
|
+
}), timestamp);
|
|
1139
|
+
database
|
|
1140
|
+
.prepare(`DELETE FROM preference_snapshots
|
|
1141
|
+
WHERE context_id = ?
|
|
1142
|
+
AND id NOT IN (
|
|
1143
|
+
SELECT id
|
|
1144
|
+
FROM preference_snapshots
|
|
1145
|
+
WHERE context_id = ?
|
|
1146
|
+
ORDER BY created_at DESC
|
|
1147
|
+
LIMIT 48
|
|
1148
|
+
)`)
|
|
1149
|
+
.run(selectedContext.id, selectedContext.id);
|
|
1150
|
+
}
|
|
1151
|
+
function recomputeContext(profile, selectedContext) {
|
|
1152
|
+
const contexts = listContexts(profile.id);
|
|
1153
|
+
const items = listItems(profile.id);
|
|
1154
|
+
const judgments = listJudgmentsForContexts(contexts.map((context) => context.id));
|
|
1155
|
+
const signals = listSignalsForContexts(contexts.map((context) => context.id));
|
|
1156
|
+
const existingScores = listStoredScores(selectedContext.id);
|
|
1157
|
+
const { scores, dimensions } = computeScores({
|
|
1158
|
+
profile,
|
|
1159
|
+
contexts,
|
|
1160
|
+
selectedContext,
|
|
1161
|
+
items,
|
|
1162
|
+
judgments,
|
|
1163
|
+
signals,
|
|
1164
|
+
existingScores
|
|
1165
|
+
});
|
|
1166
|
+
persistScoresAndDimensions({
|
|
1167
|
+
profile,
|
|
1168
|
+
selectedContext,
|
|
1169
|
+
scores,
|
|
1170
|
+
dimensions,
|
|
1171
|
+
snapshotsSummary: {
|
|
1172
|
+
averageConfidence: scores.length === 0
|
|
1173
|
+
? 0
|
|
1174
|
+
: scores.reduce((sum, score) => sum + score.confidence, 0) /
|
|
1175
|
+
scores.length,
|
|
1176
|
+
likedCount: scores.filter((score) => score.status === "liked").length,
|
|
1177
|
+
dislikedCount: scores.filter((score) => score.status === "disliked").length,
|
|
1178
|
+
uncertainCount: scores.filter((score) => score.status === "uncertain").length,
|
|
1179
|
+
totalItems: scores.length
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
return { items, judgments, signals, contexts, selectedContext, scores, dimensions };
|
|
1183
|
+
}
|
|
1184
|
+
function buildWorkspace(profile, selectedContext, items, judgments, signals, scores, dimensions) {
|
|
1185
|
+
const catalogs = listCatalogs(profile.id);
|
|
1186
|
+
const storedScores = listStoredScores(selectedContext.id);
|
|
1187
|
+
const itemsById = new Map(items.map((item) => [item.id, item]));
|
|
1188
|
+
const mappedScores = storedScores
|
|
1189
|
+
.map((score) => {
|
|
1190
|
+
const item = itemsById.get(score.item_id);
|
|
1191
|
+
return item ? mapScore(score, item) : null;
|
|
1192
|
+
})
|
|
1193
|
+
.filter((score) => Boolean(score))
|
|
1194
|
+
.sort((left, right) => {
|
|
1195
|
+
if (right.confidence !== left.confidence) {
|
|
1196
|
+
return right.confidence - left.confidence;
|
|
1197
|
+
}
|
|
1198
|
+
return right.latentScore - left.latentScore;
|
|
1199
|
+
});
|
|
1200
|
+
const mappedDimensions = listStoredDimensions(selectedContext.id);
|
|
1201
|
+
const nextPair = buildNextPair({
|
|
1202
|
+
selectedContext,
|
|
1203
|
+
items,
|
|
1204
|
+
scores,
|
|
1205
|
+
judgments
|
|
1206
|
+
});
|
|
1207
|
+
const snapshots = listSnapshots(selectedContext.id, 24);
|
|
1208
|
+
const staleItemIds = mappedScores
|
|
1209
|
+
.filter((score) => ageInDays(score.lastJudgmentAt ?? score.updatedAt) >
|
|
1210
|
+
selectedContext.decayDays)
|
|
1211
|
+
.map((score) => score.itemId);
|
|
1212
|
+
const flippedItemIds = (() => {
|
|
1213
|
+
const recentSnapshots = [...snapshots].reverse();
|
|
1214
|
+
const signsByItemId = new Map();
|
|
1215
|
+
for (const snapshot of recentSnapshots) {
|
|
1216
|
+
const topScores = Array.isArray(snapshot.serializedModelState.topScores)
|
|
1217
|
+
? snapshot.serializedModelState.topScores
|
|
1218
|
+
: [];
|
|
1219
|
+
for (const entry of topScores) {
|
|
1220
|
+
if (!entry.itemId || typeof entry.latentScore !== "number") {
|
|
1221
|
+
continue;
|
|
1222
|
+
}
|
|
1223
|
+
signsByItemId.set(entry.itemId, [
|
|
1224
|
+
...(signsByItemId.get(entry.itemId) ?? []),
|
|
1225
|
+
Math.sign(entry.latentScore)
|
|
1226
|
+
]);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
return [...signsByItemId.entries()]
|
|
1230
|
+
.filter(([, signs]) => {
|
|
1231
|
+
const filtered = signs.filter((sign) => sign !== 0);
|
|
1232
|
+
return filtered.length >= 2 && new Set(filtered).size > 1;
|
|
1233
|
+
})
|
|
1234
|
+
.map(([itemId]) => itemId);
|
|
1235
|
+
})();
|
|
1236
|
+
const workspace = preferenceWorkspacePayloadSchema.parse({
|
|
1237
|
+
profile,
|
|
1238
|
+
selectedContext,
|
|
1239
|
+
contexts: listContexts(profile.id),
|
|
1240
|
+
catalogs,
|
|
1241
|
+
dimensions: mappedDimensions,
|
|
1242
|
+
scores: mappedScores,
|
|
1243
|
+
map: buildMap(items, scores),
|
|
1244
|
+
history: {
|
|
1245
|
+
judgments: judgments.filter((judgment) => judgment.contextId === selectedContext.id),
|
|
1246
|
+
signals: signals.filter((signal) => signal.contextId === selectedContext.id),
|
|
1247
|
+
snapshots,
|
|
1248
|
+
staleItemIds,
|
|
1249
|
+
flippedItemIds
|
|
1250
|
+
},
|
|
1251
|
+
compare: {
|
|
1252
|
+
nextPair,
|
|
1253
|
+
pendingCount: mappedScores.filter((score) => score.uncertainty >= 0.5 || score.compareLater).length,
|
|
1254
|
+
candidateCount: items.length
|
|
1255
|
+
},
|
|
1256
|
+
summary: {
|
|
1257
|
+
totalItems: mappedScores.length,
|
|
1258
|
+
likedCount: mappedScores.filter((score) => score.status === "liked").length,
|
|
1259
|
+
dislikedCount: mappedScores.filter((score) => score.status === "disliked")
|
|
1260
|
+
.length,
|
|
1261
|
+
uncertainCount: mappedScores.filter((score) => score.status === "uncertain")
|
|
1262
|
+
.length,
|
|
1263
|
+
bookmarkedCount: mappedScores.filter((score) => score.bookmarked).length,
|
|
1264
|
+
vetoedCount: mappedScores.filter((score) => score.status === "vetoed").length,
|
|
1265
|
+
averageConfidence: mappedScores.length === 0
|
|
1266
|
+
? 0
|
|
1267
|
+
: mappedScores.reduce((sum, score) => sum + score.confidence, 0) /
|
|
1268
|
+
mappedScores.length,
|
|
1269
|
+
pendingComparisons: mappedScores.filter((score) => score.uncertainty >= 0.5 || score.compareLater).length
|
|
1270
|
+
},
|
|
1271
|
+
libraries: {
|
|
1272
|
+
totalCatalogs: catalogs.length,
|
|
1273
|
+
totalCatalogItems: catalogs.reduce((sum, catalog) => sum + catalog.items.length, 0),
|
|
1274
|
+
seededCatalogCount: catalogs.filter((catalog) => catalog.source === "seeded")
|
|
1275
|
+
.length,
|
|
1276
|
+
customCatalogCount: catalogs.filter((catalog) => catalog.source === "custom")
|
|
1277
|
+
.length
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
return workspace;
|
|
1281
|
+
}
|
|
1282
|
+
function resolveWorkspaceQuery(query) {
|
|
1283
|
+
const parsed = preferenceWorkspaceQuerySchema.parse(query);
|
|
1284
|
+
const userId = parsed.userId ?? getDefaultUser().id;
|
|
1285
|
+
const domain = parsed.domain ?? DEFAULT_PREFERENCE_DOMAIN;
|
|
1286
|
+
return { userId, domain, contextId: parsed.contextId ?? null };
|
|
1287
|
+
}
|
|
1288
|
+
export function getPreferenceWorkspace(query) {
|
|
1289
|
+
const { userId, domain, contextId } = resolveWorkspaceQuery(query);
|
|
1290
|
+
const profile = ensureProfile(userId, domain);
|
|
1291
|
+
const selectedContext = resolveContext(profile, contextId);
|
|
1292
|
+
const recomputed = recomputeContext(profile, selectedContext);
|
|
1293
|
+
return buildWorkspace(profile, selectedContext, recomputed.items, recomputed.judgments, recomputed.signals, recomputed.scores, recomputed.dimensions);
|
|
1294
|
+
}
|
|
1295
|
+
export function createPreferenceCatalog(input) {
|
|
1296
|
+
const parsed = createPreferenceCatalogSchema.parse(input);
|
|
1297
|
+
const profile = ensureProfile(parsed.userId, parsed.domain);
|
|
1298
|
+
const timestamp = nowIso();
|
|
1299
|
+
const baseSlug = slugify(parsed.slug || parsed.title) || "concept-list";
|
|
1300
|
+
const existingSlugs = new Set(listCatalogs(profile.id).map((catalog) => catalog.slug));
|
|
1301
|
+
let slug = baseSlug;
|
|
1302
|
+
let index = 2;
|
|
1303
|
+
while (existingSlugs.has(slug)) {
|
|
1304
|
+
slug = `${baseSlug}-${index}`;
|
|
1305
|
+
index += 1;
|
|
1306
|
+
}
|
|
1307
|
+
const catalogId = `pref_catalog_${randomUUID().slice(0, 10)}`;
|
|
1308
|
+
getDatabase()
|
|
1309
|
+
.prepare(`INSERT INTO preference_catalogs (
|
|
1310
|
+
id, profile_id, domain, slug, title, description, source, archived, created_at, updated_at
|
|
1311
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`)
|
|
1312
|
+
.run(catalogId, profile.id, parsed.domain, slug, parsed.title, parsed.description, preferenceCatalogSourceSchema.enum.custom, timestamp, timestamp);
|
|
1313
|
+
return readCatalog(catalogId);
|
|
1314
|
+
}
|
|
1315
|
+
export function updatePreferenceCatalog(catalogId, patch) {
|
|
1316
|
+
const current = readCatalog(catalogId);
|
|
1317
|
+
if (!current) {
|
|
1318
|
+
throw new HttpError(404, "preferences_catalog_not_found", `Preference catalog ${catalogId} was not found.`);
|
|
1319
|
+
}
|
|
1320
|
+
const parsed = updatePreferenceCatalogSchema.parse(patch);
|
|
1321
|
+
const timestamp = nowIso();
|
|
1322
|
+
const nextTitle = parsed.title ?? current.title;
|
|
1323
|
+
const desiredSlug = slugify(parsed.slug || nextTitle) || current.slug;
|
|
1324
|
+
const siblingSlugs = new Set(listCatalogs(current.profileId)
|
|
1325
|
+
.filter((catalog) => catalog.id !== current.id)
|
|
1326
|
+
.map((catalog) => catalog.slug));
|
|
1327
|
+
let nextSlug = desiredSlug;
|
|
1328
|
+
let index = 2;
|
|
1329
|
+
while (siblingSlugs.has(nextSlug)) {
|
|
1330
|
+
nextSlug = `${desiredSlug}-${index}`;
|
|
1331
|
+
index += 1;
|
|
1332
|
+
}
|
|
1333
|
+
getDatabase()
|
|
1334
|
+
.prepare(`UPDATE preference_catalogs
|
|
1335
|
+
SET slug = ?, title = ?, description = ?, updated_at = ?
|
|
1336
|
+
WHERE id = ?`)
|
|
1337
|
+
.run(nextSlug, nextTitle, parsed.description ?? current.description, timestamp, catalogId);
|
|
1338
|
+
return readCatalog(catalogId);
|
|
1339
|
+
}
|
|
1340
|
+
export function deletePreferenceCatalog(catalogId) {
|
|
1341
|
+
const current = readCatalog(catalogId);
|
|
1342
|
+
if (!current) {
|
|
1343
|
+
throw new HttpError(404, "preferences_catalog_not_found", `Preference catalog ${catalogId} was not found.`);
|
|
1344
|
+
}
|
|
1345
|
+
const timestamp = nowIso();
|
|
1346
|
+
runInTransaction(() => {
|
|
1347
|
+
getDatabase()
|
|
1348
|
+
.prepare(`UPDATE preference_catalogs
|
|
1349
|
+
SET archived = 1, updated_at = ?
|
|
1350
|
+
WHERE id = ?`)
|
|
1351
|
+
.run(timestamp, catalogId);
|
|
1352
|
+
getDatabase()
|
|
1353
|
+
.prepare(`UPDATE preference_catalog_items
|
|
1354
|
+
SET archived = 1, updated_at = ?
|
|
1355
|
+
WHERE catalog_id = ?`)
|
|
1356
|
+
.run(timestamp, catalogId);
|
|
1357
|
+
});
|
|
1358
|
+
return current;
|
|
1359
|
+
}
|
|
1360
|
+
export function createPreferenceCatalogItem(input) {
|
|
1361
|
+
const parsed = createPreferenceCatalogItemSchema.parse(input);
|
|
1362
|
+
const catalog = readCatalog(parsed.catalogId);
|
|
1363
|
+
if (!catalog) {
|
|
1364
|
+
throw new HttpError(404, "preferences_catalog_not_found", `Preference catalog ${parsed.catalogId} was not found.`);
|
|
1365
|
+
}
|
|
1366
|
+
const timestamp = nowIso();
|
|
1367
|
+
const position = parsed.position ??
|
|
1368
|
+
(catalog.items.reduce((max, item) => Math.max(max, item.position), -1) + 1);
|
|
1369
|
+
const itemId = `pref_catalog_item_${randomUUID().slice(0, 10)}`;
|
|
1370
|
+
getDatabase()
|
|
1371
|
+
.prepare(`INSERT INTO preference_catalog_items (
|
|
1372
|
+
id, catalog_id, label, description, tags_json, feature_weights_json, position, archived, created_at, updated_at
|
|
1373
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`)
|
|
1374
|
+
.run(itemId, catalog.id, parsed.label, parsed.description, JSON.stringify(parsed.tags), JSON.stringify(normalizeDimensionVector(parsed.featureWeights)), position, timestamp, timestamp);
|
|
1375
|
+
return readCatalogItem(itemId);
|
|
1376
|
+
}
|
|
1377
|
+
export function updatePreferenceCatalogItem(catalogItemId, patch) {
|
|
1378
|
+
const current = readCatalogItem(catalogItemId);
|
|
1379
|
+
if (!current) {
|
|
1380
|
+
throw new HttpError(404, "preferences_catalog_item_not_found", `Preference catalog item ${catalogItemId} was not found.`);
|
|
1381
|
+
}
|
|
1382
|
+
const parsed = updatePreferenceCatalogItemSchema.parse(patch);
|
|
1383
|
+
const timestamp = nowIso();
|
|
1384
|
+
getDatabase()
|
|
1385
|
+
.prepare(`UPDATE preference_catalog_items
|
|
1386
|
+
SET label = ?, description = ?, tags_json = ?, feature_weights_json = ?, position = ?, updated_at = ?
|
|
1387
|
+
WHERE id = ?`)
|
|
1388
|
+
.run(parsed.label ?? current.label, parsed.description ?? current.description, JSON.stringify(parsed.tags ?? current.tags), JSON.stringify(parsed.featureWeights !== undefined
|
|
1389
|
+
? normalizeDimensionVector(parsed.featureWeights)
|
|
1390
|
+
: current.featureWeights), parsed.position ?? current.position, timestamp, catalogItemId);
|
|
1391
|
+
return readCatalogItem(catalogItemId);
|
|
1392
|
+
}
|
|
1393
|
+
export function deletePreferenceCatalogItem(catalogItemId) {
|
|
1394
|
+
const current = readCatalogItem(catalogItemId);
|
|
1395
|
+
if (!current) {
|
|
1396
|
+
throw new HttpError(404, "preferences_catalog_item_not_found", `Preference catalog item ${catalogItemId} was not found.`);
|
|
1397
|
+
}
|
|
1398
|
+
getDatabase()
|
|
1399
|
+
.prepare(`UPDATE preference_catalog_items
|
|
1400
|
+
SET archived = 1, updated_at = ?
|
|
1401
|
+
WHERE id = ?`)
|
|
1402
|
+
.run(nowIso(), catalogItemId);
|
|
1403
|
+
return current;
|
|
1404
|
+
}
|
|
1405
|
+
export function startPreferenceGame(input) {
|
|
1406
|
+
const parsed = startPreferenceGameSchema.parse(input);
|
|
1407
|
+
const profile = ensureProfile(parsed.userId, parsed.domain);
|
|
1408
|
+
const selectedContext = resolveContext(profile, parsed.contextId ?? null);
|
|
1409
|
+
if (parsed.catalogId) {
|
|
1410
|
+
const catalog = readCatalog(parsed.catalogId);
|
|
1411
|
+
if (!catalog || catalog.profileId !== profile.id) {
|
|
1412
|
+
throw new HttpError(404, "preferences_catalog_not_found", `Preference catalog ${parsed.catalogId} was not found for this profile.`);
|
|
1413
|
+
}
|
|
1414
|
+
const existingItems = listItems(profile.id);
|
|
1415
|
+
for (const catalogItem of catalog.items) {
|
|
1416
|
+
const matched = existingItems.find((item) => {
|
|
1417
|
+
const seedCatalogId = typeof item.metadata.seedCatalogId === "string"
|
|
1418
|
+
? item.metadata.seedCatalogId
|
|
1419
|
+
: null;
|
|
1420
|
+
const seedCatalogItemId = typeof item.metadata.seedCatalogItemId === "string"
|
|
1421
|
+
? item.metadata.seedCatalogItemId
|
|
1422
|
+
: null;
|
|
1423
|
+
return (seedCatalogId === catalog.id && seedCatalogItemId === catalogItem.id);
|
|
1424
|
+
});
|
|
1425
|
+
if (matched) {
|
|
1426
|
+
updatePreferenceItem(matched.id, {
|
|
1427
|
+
label: catalogItem.label,
|
|
1428
|
+
description: catalogItem.description,
|
|
1429
|
+
tags: catalogItem.tags,
|
|
1430
|
+
featureWeights: catalogItem.featureWeights,
|
|
1431
|
+
metadata: {
|
|
1432
|
+
...matched.metadata,
|
|
1433
|
+
seedCatalogId: catalog.id,
|
|
1434
|
+
seedCatalogItemId: catalogItem.id,
|
|
1435
|
+
seedCatalogTitle: catalog.title
|
|
1436
|
+
}
|
|
1437
|
+
});
|
|
1438
|
+
upsertPreferenceScoreState(matched.id, selectedContext.id, {
|
|
1439
|
+
compareLater: true,
|
|
1440
|
+
bookmarked: true
|
|
1441
|
+
});
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
const createdItem = createPreferenceItem({
|
|
1445
|
+
userId: parsed.userId,
|
|
1446
|
+
domain: parsed.domain,
|
|
1447
|
+
label: catalogItem.label,
|
|
1448
|
+
description: catalogItem.description,
|
|
1449
|
+
tags: catalogItem.tags,
|
|
1450
|
+
featureWeights: catalogItem.featureWeights,
|
|
1451
|
+
metadata: {
|
|
1452
|
+
seedCatalogId: catalog.id,
|
|
1453
|
+
seedCatalogItemId: catalogItem.id,
|
|
1454
|
+
seedCatalogTitle: catalog.title
|
|
1455
|
+
},
|
|
1456
|
+
queueForCompare: true
|
|
1457
|
+
});
|
|
1458
|
+
upsertPreferenceScoreState(createdItem.id, selectedContext.id, {
|
|
1459
|
+
compareLater: true,
|
|
1460
|
+
bookmarked: true
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
return getPreferenceWorkspace({
|
|
1465
|
+
userId: parsed.userId,
|
|
1466
|
+
domain: parsed.domain,
|
|
1467
|
+
contextId: selectedContext.id
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
export function createPreferenceContext(input) {
|
|
1471
|
+
const parsed = createPreferenceContextSchema.parse(input);
|
|
1472
|
+
const profile = ensureProfile(parsed.userId, parsed.domain);
|
|
1473
|
+
const contextId = `pref_ctx_${randomUUID().slice(0, 10)}`;
|
|
1474
|
+
const timestamp = nowIso();
|
|
1475
|
+
runInTransaction(() => {
|
|
1476
|
+
if (parsed.isDefault) {
|
|
1477
|
+
getDatabase()
|
|
1478
|
+
.prepare(`UPDATE preference_contexts
|
|
1479
|
+
SET is_default = 0, updated_at = ?
|
|
1480
|
+
WHERE profile_id = ?`)
|
|
1481
|
+
.run(timestamp, profile.id);
|
|
1482
|
+
}
|
|
1483
|
+
getDatabase()
|
|
1484
|
+
.prepare(`INSERT INTO preference_contexts (id, profile_id, name, description, share_mode, active, is_default, decay_days, created_at, updated_at)
|
|
1485
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1486
|
+
.run(contextId, profile.id, parsed.name, parsed.description, parsed.shareMode, parsed.active ? 1 : 0, parsed.isDefault ? 1 : 0, parsed.decayDays, timestamp, timestamp);
|
|
1487
|
+
if (parsed.isDefault) {
|
|
1488
|
+
getDatabase()
|
|
1489
|
+
.prepare(`UPDATE preference_profiles
|
|
1490
|
+
SET default_context_id = ?, updated_at = ?
|
|
1491
|
+
WHERE id = ?`)
|
|
1492
|
+
.run(contextId, timestamp, profile.id);
|
|
1493
|
+
}
|
|
1494
|
+
});
|
|
1495
|
+
return readContext(contextId);
|
|
1496
|
+
}
|
|
1497
|
+
export function updatePreferenceContext(contextId, patch) {
|
|
1498
|
+
const current = readContext(contextId);
|
|
1499
|
+
if (!current) {
|
|
1500
|
+
throw new HttpError(404, "preferences_context_not_found", `Preference context ${contextId} was not found.`);
|
|
1501
|
+
}
|
|
1502
|
+
const parsed = updatePreferenceContextSchema.parse(patch);
|
|
1503
|
+
const next = {
|
|
1504
|
+
name: parsed.name ?? current.name,
|
|
1505
|
+
description: parsed.description ?? current.description,
|
|
1506
|
+
shareMode: parsed.shareMode ?? current.shareMode,
|
|
1507
|
+
active: parsed.active ?? current.active,
|
|
1508
|
+
isDefault: parsed.isDefault ?? current.isDefault,
|
|
1509
|
+
decayDays: parsed.decayDays ?? current.decayDays
|
|
1510
|
+
};
|
|
1511
|
+
const timestamp = nowIso();
|
|
1512
|
+
runInTransaction(() => {
|
|
1513
|
+
if (next.isDefault) {
|
|
1514
|
+
getDatabase()
|
|
1515
|
+
.prepare(`UPDATE preference_contexts
|
|
1516
|
+
SET is_default = 0, updated_at = ?
|
|
1517
|
+
WHERE profile_id = ?`)
|
|
1518
|
+
.run(timestamp, current.profileId);
|
|
1519
|
+
getDatabase()
|
|
1520
|
+
.prepare(`UPDATE preference_profiles
|
|
1521
|
+
SET default_context_id = ?, updated_at = ?
|
|
1522
|
+
WHERE id = ?`)
|
|
1523
|
+
.run(contextId, timestamp, current.profileId);
|
|
1524
|
+
}
|
|
1525
|
+
getDatabase()
|
|
1526
|
+
.prepare(`UPDATE preference_contexts
|
|
1527
|
+
SET name = ?, description = ?, share_mode = ?, active = ?, is_default = ?, decay_days = ?, updated_at = ?
|
|
1528
|
+
WHERE id = ?`)
|
|
1529
|
+
.run(next.name, next.description, next.shareMode, next.active ? 1 : 0, next.isDefault ? 1 : 0, next.decayDays, timestamp, contextId);
|
|
1530
|
+
});
|
|
1531
|
+
const profile = readProfileById(current.profileId);
|
|
1532
|
+
const updated = readContext(contextId);
|
|
1533
|
+
if (profile) {
|
|
1534
|
+
recomputeContext(profile, updated);
|
|
1535
|
+
}
|
|
1536
|
+
return updated;
|
|
1537
|
+
}
|
|
1538
|
+
export function mergePreferenceContexts(input) {
|
|
1539
|
+
const parsed = mergePreferenceContextsSchema.parse(input);
|
|
1540
|
+
const source = readContext(parsed.sourceContextId);
|
|
1541
|
+
const target = readContext(parsed.targetContextId);
|
|
1542
|
+
if (!source || !target || source.profileId !== target.profileId) {
|
|
1543
|
+
throw new HttpError(400, "preferences_invalid_context_merge", "Preference contexts must exist on the same profile before merging.");
|
|
1544
|
+
}
|
|
1545
|
+
const timestamp = nowIso();
|
|
1546
|
+
runInTransaction(() => {
|
|
1547
|
+
getDatabase()
|
|
1548
|
+
.prepare(`UPDATE pairwise_judgments
|
|
1549
|
+
SET context_id = ?
|
|
1550
|
+
WHERE context_id = ?`)
|
|
1551
|
+
.run(target.id, source.id);
|
|
1552
|
+
getDatabase()
|
|
1553
|
+
.prepare(`UPDATE absolute_signals
|
|
1554
|
+
SET context_id = ?
|
|
1555
|
+
WHERE context_id = ?`)
|
|
1556
|
+
.run(target.id, source.id);
|
|
1557
|
+
getDatabase()
|
|
1558
|
+
.prepare(`DELETE FROM preference_item_scores
|
|
1559
|
+
WHERE context_id = ?`)
|
|
1560
|
+
.run(source.id);
|
|
1561
|
+
getDatabase()
|
|
1562
|
+
.prepare(`DELETE FROM preference_dimension_summaries
|
|
1563
|
+
WHERE context_id = ?`)
|
|
1564
|
+
.run(source.id);
|
|
1565
|
+
getDatabase()
|
|
1566
|
+
.prepare(`UPDATE preference_contexts
|
|
1567
|
+
SET active = 0, updated_at = ?
|
|
1568
|
+
WHERE id = ?`)
|
|
1569
|
+
.run(timestamp, source.id);
|
|
1570
|
+
});
|
|
1571
|
+
const profile = readProfileById(source.profileId);
|
|
1572
|
+
if (profile) {
|
|
1573
|
+
recomputeContext(profile, target);
|
|
1574
|
+
}
|
|
1575
|
+
return {
|
|
1576
|
+
target: readContext(target.id),
|
|
1577
|
+
source: readContext(source.id)
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
export function createPreferenceItem(input) {
|
|
1581
|
+
const parsed = createPreferenceItemSchema.parse(input);
|
|
1582
|
+
const profile = ensureProfile(parsed.userId, parsed.domain);
|
|
1583
|
+
const itemId = `pref_item_${randomUUID().slice(0, 10)}`;
|
|
1584
|
+
const timestamp = nowIso();
|
|
1585
|
+
getDatabase()
|
|
1586
|
+
.prepare(`INSERT INTO preference_items (id, profile_id, label, description, tags_json, feature_weights_json, source_entity_type, source_entity_id, metadata_json, created_at, updated_at)
|
|
1587
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1588
|
+
.run(itemId, profile.id, parsed.label, parsed.description, JSON.stringify(parsed.tags), JSON.stringify(normalizeDimensionVector(parsed.featureWeights)), parsed.sourceEntityType ?? null, parsed.sourceEntityId ?? null, JSON.stringify(parsed.metadata ?? {}), timestamp, timestamp);
|
|
1589
|
+
const created = getItemById(itemId);
|
|
1590
|
+
const selectedContext = resolveContext(profile, null);
|
|
1591
|
+
if (parsed.queueForCompare) {
|
|
1592
|
+
upsertPreferenceScoreState(itemId, selectedContext.id, {
|
|
1593
|
+
compareLater: true,
|
|
1594
|
+
bookmarked: true
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
recomputeContext(profile, selectedContext);
|
|
1598
|
+
return created;
|
|
1599
|
+
}
|
|
1600
|
+
function upsertPreferenceScoreState(itemId, contextId, patch) {
|
|
1601
|
+
const item = getItemById(itemId);
|
|
1602
|
+
if (!item) {
|
|
1603
|
+
throw new HttpError(404, "preferences_item_not_found", `Preference item ${itemId} was not found.`);
|
|
1604
|
+
}
|
|
1605
|
+
const profile = readProfileById(item.profileId);
|
|
1606
|
+
const context = readContext(contextId);
|
|
1607
|
+
if (!profile || !context || context.profileId !== profile.id) {
|
|
1608
|
+
throw new HttpError(400, "preferences_invalid_score_context", "Preference score context is invalid for this item.");
|
|
1609
|
+
}
|
|
1610
|
+
const existing = listStoredScores(contextId).find((score) => score.item_id === itemId);
|
|
1611
|
+
const timestamp = nowIso();
|
|
1612
|
+
if (!existing) {
|
|
1613
|
+
getDatabase()
|
|
1614
|
+
.prepare(`INSERT INTO preference_item_scores (
|
|
1615
|
+
id, profile_id, context_id, item_id, latent_score, confidence, uncertainty, evidence_count, pairwise_wins, pairwise_losses, pairwise_ties, signal_count, conflict_count, status, dominant_dimensions_json, explanation_json, manual_status, manual_score, confidence_lock, bookmarked, compare_later, frozen, last_inferred_at, last_judgment_at, updated_at
|
|
1616
|
+
) VALUES (?, ?, ?, ?, 0, 0, 1, 0, 0, 0, 0, 0, 0, 'uncertain', '[]', '[]', ?, ?, ?, ?, ?, ?, ?, NULL, ?)`)
|
|
1617
|
+
.run(`pref_score_${randomUUID().slice(0, 10)}`, profile.id, contextId, itemId, patch.manualStatus ?? null, patch.manualScore ?? null, patch.confidenceLock ?? null, patch.bookmarked ? 1 : 0, patch.compareLater ? 1 : 0, patch.frozen ? 1 : 0, timestamp, timestamp);
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
getDatabase()
|
|
1621
|
+
.prepare(`UPDATE preference_item_scores
|
|
1622
|
+
SET manual_status = ?, manual_score = ?, confidence_lock = ?, bookmarked = ?, compare_later = ?, frozen = ?, updated_at = ?
|
|
1623
|
+
WHERE context_id = ? AND item_id = ?`)
|
|
1624
|
+
.run(patch.manualStatus ?? existing.manual_status, patch.manualScore ?? existing.manual_score, patch.confidenceLock ?? existing.confidence_lock, (patch.bookmarked ?? (existing.bookmarked === 1)) ? 1 : 0, (patch.compareLater ?? (existing.compare_later === 1)) ? 1 : 0, (patch.frozen ?? (existing.frozen === 1)) ? 1 : 0, timestamp, contextId, itemId);
|
|
1625
|
+
}
|
|
1626
|
+
export function updatePreferenceItem(itemId, patch) {
|
|
1627
|
+
const item = getItemById(itemId);
|
|
1628
|
+
if (!item) {
|
|
1629
|
+
throw new HttpError(404, "preferences_item_not_found", `Preference item ${itemId} was not found.`);
|
|
1630
|
+
}
|
|
1631
|
+
const parsed = updatePreferenceItemSchema.parse(patch);
|
|
1632
|
+
const next = {
|
|
1633
|
+
label: parsed.label ?? item.label,
|
|
1634
|
+
description: parsed.description ?? item.description,
|
|
1635
|
+
tags: parsed.tags ?? item.tags,
|
|
1636
|
+
featureWeights: parsed.featureWeights !== undefined
|
|
1637
|
+
? normalizeDimensionVector(parsed.featureWeights)
|
|
1638
|
+
: item.featureWeights,
|
|
1639
|
+
sourceEntityType: parsed.sourceEntityType !== undefined
|
|
1640
|
+
? parsed.sourceEntityType
|
|
1641
|
+
: item.sourceEntityType ?? null,
|
|
1642
|
+
sourceEntityId: parsed.sourceEntityId !== undefined
|
|
1643
|
+
? parsed.sourceEntityId
|
|
1644
|
+
: item.sourceEntityId ?? null,
|
|
1645
|
+
metadata: parsed.metadata !== undefined
|
|
1646
|
+
? parsed.metadata
|
|
1647
|
+
: item.metadata
|
|
1648
|
+
};
|
|
1649
|
+
const timestamp = nowIso();
|
|
1650
|
+
getDatabase()
|
|
1651
|
+
.prepare(`UPDATE preference_items
|
|
1652
|
+
SET label = ?, description = ?, tags_json = ?, feature_weights_json = ?, source_entity_type = ?, source_entity_id = ?, metadata_json = ?, updated_at = ?
|
|
1653
|
+
WHERE id = ?`)
|
|
1654
|
+
.run(next.label, next.description, JSON.stringify(next.tags), JSON.stringify(next.featureWeights), next.sourceEntityType, next.sourceEntityId, JSON.stringify(next.metadata ?? {}), timestamp, itemId);
|
|
1655
|
+
const updated = getItemById(itemId);
|
|
1656
|
+
const profile = readProfileById(item.profileId);
|
|
1657
|
+
if (profile) {
|
|
1658
|
+
for (const context of listContexts(profile.id).filter((entry) => entry.active)) {
|
|
1659
|
+
recomputeContext(profile, context);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
return updated;
|
|
1663
|
+
}
|
|
1664
|
+
export function createPreferenceItemFromEntity(input) {
|
|
1665
|
+
const parsed = enqueueEntityPreferenceItemSchema.parse(input);
|
|
1666
|
+
const profile = ensureProfile(parsed.userId, parsed.domain);
|
|
1667
|
+
const existing = listItems(profile.id).find((item) => item.sourceEntityType === parsed.entityType &&
|
|
1668
|
+
item.sourceEntityId === parsed.entityId);
|
|
1669
|
+
const source = resolveSourceEntity(parsed.entityType, parsed.entityId);
|
|
1670
|
+
if (!source) {
|
|
1671
|
+
throw new HttpError(404, "preferences_source_entity_not_found", `${parsed.entityType} ${parsed.entityId} was not found.`);
|
|
1672
|
+
}
|
|
1673
|
+
const item = existing ??
|
|
1674
|
+
createPreferenceItem({
|
|
1675
|
+
userId: parsed.userId,
|
|
1676
|
+
domain: parsed.domain,
|
|
1677
|
+
label: parsed.label?.trim() || source.label,
|
|
1678
|
+
description: parsed.description?.trim() || source.description,
|
|
1679
|
+
tags: parsed.tags,
|
|
1680
|
+
sourceEntityType: parsed.entityType,
|
|
1681
|
+
sourceEntityId: parsed.entityId,
|
|
1682
|
+
metadata: { seededFromEntity: true },
|
|
1683
|
+
queueForCompare: true,
|
|
1684
|
+
featureWeights: DEFAULT_DIMENSIONS
|
|
1685
|
+
});
|
|
1686
|
+
const selectedContext = resolveContext(profile, null);
|
|
1687
|
+
upsertPreferenceScoreState(item.id, selectedContext.id, {
|
|
1688
|
+
bookmarked: true,
|
|
1689
|
+
compareLater: true
|
|
1690
|
+
});
|
|
1691
|
+
recomputeContext(profile, selectedContext);
|
|
1692
|
+
return item;
|
|
1693
|
+
}
|
|
1694
|
+
export function submitPairwiseJudgment(input) {
|
|
1695
|
+
const parsed = submitPairwiseJudgmentSchema.parse(input);
|
|
1696
|
+
const profile = ensureProfile(parsed.userId, parsed.domain);
|
|
1697
|
+
const context = readContext(parsed.contextId);
|
|
1698
|
+
if (!context || context.profileId !== profile.id) {
|
|
1699
|
+
throw new HttpError(400, "preferences_invalid_context", "Preference judgment context does not belong to the selected profile.");
|
|
1700
|
+
}
|
|
1701
|
+
if (parsed.leftItemId === parsed.rightItemId) {
|
|
1702
|
+
throw new HttpError(400, "preferences_invalid_pair", "Preference comparisons require two distinct items.");
|
|
1703
|
+
}
|
|
1704
|
+
if (!getItemById(parsed.leftItemId) || !getItemById(parsed.rightItemId)) {
|
|
1705
|
+
throw new HttpError(404, "preferences_item_not_found", "One or both preference items do not exist.");
|
|
1706
|
+
}
|
|
1707
|
+
const judgmentId = `pref_judgment_${randomUUID().slice(0, 10)}`;
|
|
1708
|
+
const timestamp = nowIso();
|
|
1709
|
+
getDatabase()
|
|
1710
|
+
.prepare(`INSERT INTO pairwise_judgments (id, profile_id, context_id, user_id, left_item_id, right_item_id, outcome, strength, response_time_ms, source, reason_tags_json, created_at)
|
|
1711
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'ui', ?, ?)`)
|
|
1712
|
+
.run(judgmentId, profile.id, context.id, parsed.userId, parsed.leftItemId, parsed.rightItemId, parsed.outcome, parsed.strength, parsed.responseTimeMs ?? null, JSON.stringify(parsed.reasonTags), timestamp);
|
|
1713
|
+
recomputeContext(profile, context);
|
|
1714
|
+
return mapJudgment(getDatabase()
|
|
1715
|
+
.prepare(`SELECT id, profile_id, context_id, user_id, left_item_id, right_item_id, outcome, strength, response_time_ms, source, reason_tags_json, created_at
|
|
1716
|
+
FROM pairwise_judgments
|
|
1717
|
+
WHERE id = ?`)
|
|
1718
|
+
.get(judgmentId));
|
|
1719
|
+
}
|
|
1720
|
+
export function submitAbsoluteSignal(input) {
|
|
1721
|
+
const parsed = submitAbsoluteSignalSchema.parse(input);
|
|
1722
|
+
const profile = ensureProfile(parsed.userId, parsed.domain);
|
|
1723
|
+
const context = readContext(parsed.contextId);
|
|
1724
|
+
if (!context || context.profileId !== profile.id) {
|
|
1725
|
+
throw new HttpError(400, "preferences_invalid_context", "Preference signal context does not belong to the selected profile.");
|
|
1726
|
+
}
|
|
1727
|
+
if (!getItemById(parsed.itemId)) {
|
|
1728
|
+
throw new HttpError(404, "preferences_item_not_found", `Preference item ${parsed.itemId} was not found.`);
|
|
1729
|
+
}
|
|
1730
|
+
const signalId = `pref_signal_${randomUUID().slice(0, 10)}`;
|
|
1731
|
+
const timestamp = nowIso();
|
|
1732
|
+
getDatabase()
|
|
1733
|
+
.prepare(`INSERT INTO absolute_signals (id, profile_id, context_id, user_id, item_id, signal_type, strength, source, created_at)
|
|
1734
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'ui', ?)`)
|
|
1735
|
+
.run(signalId, profile.id, context.id, parsed.userId, parsed.itemId, parsed.signalType, parsed.strength, timestamp);
|
|
1736
|
+
recomputeContext(profile, context);
|
|
1737
|
+
return mapSignal(getDatabase()
|
|
1738
|
+
.prepare(`SELECT id, profile_id, context_id, user_id, item_id, signal_type, strength, source, created_at
|
|
1739
|
+
FROM absolute_signals
|
|
1740
|
+
WHERE id = ?`)
|
|
1741
|
+
.get(signalId));
|
|
1742
|
+
}
|
|
1743
|
+
export function updatePreferenceScore(itemId, input) {
|
|
1744
|
+
const parsed = updatePreferenceScoreSchema.parse(input);
|
|
1745
|
+
const profile = ensureProfile(parsed.userId, parsed.domain);
|
|
1746
|
+
const context = readContext(parsed.contextId);
|
|
1747
|
+
if (!context || context.profileId !== profile.id) {
|
|
1748
|
+
throw new HttpError(400, "preferences_invalid_context", "Preference score context does not belong to the selected profile.");
|
|
1749
|
+
}
|
|
1750
|
+
upsertPreferenceScoreState(itemId, context.id, {
|
|
1751
|
+
manualStatus: parsed.manualStatus !== undefined ? parsed.manualStatus ?? null : undefined,
|
|
1752
|
+
manualScore: parsed.manualScore !== undefined ? parsed.manualScore ?? null : undefined,
|
|
1753
|
+
confidenceLock: parsed.confidenceLock !== undefined
|
|
1754
|
+
? parsed.confidenceLock ?? null
|
|
1755
|
+
: undefined,
|
|
1756
|
+
bookmarked: parsed.bookmarked,
|
|
1757
|
+
compareLater: parsed.compareLater,
|
|
1758
|
+
frozen: parsed.frozen
|
|
1759
|
+
});
|
|
1760
|
+
return getPreferenceWorkspace({
|
|
1761
|
+
userId: parsed.userId,
|
|
1762
|
+
domain: parsed.domain,
|
|
1763
|
+
contextId: context.id
|
|
1764
|
+
});
|
|
1765
|
+
}
|