@geraldmaron/construct 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/bin/construct +81 -10
- package/lib/audit-trail.mjs +77 -10
- package/lib/cli-commands.mjs +108 -1
- package/lib/demo.mjs +245 -0
- package/lib/diagram.mjs +300 -0
- package/lib/doctor/index.mjs +1 -1
- package/lib/doctor/watchers/process-pressure.mjs +1 -1
- package/lib/doctor/watchers/service-health.mjs +1 -1
- package/lib/document-extract.mjs +48 -0
- package/lib/document-ingest.mjs +7 -2
- package/lib/embed/semantic.mjs +5 -2
- package/lib/handoffs/cleanup.mjs +1 -1
- package/lib/hooks/audit-trail.mjs +14 -28
- package/lib/model-router.mjs +52 -1
- package/lib/persona-sections.mjs +66 -0
- package/lib/prompt-composer.js +2 -1
- package/lib/server/index.mjs +1 -1
- package/lib/server/langfuse-login.mjs +3 -3
- package/lib/service-manager.mjs +1 -1
- package/lib/session-store.mjs +1 -1
- package/lib/setup.mjs +39 -2
- package/lib/storage/embeddings-local.mjs +6 -3
- package/lib/storage/file-lock.mjs +18 -11
- package/package.json +4 -2
- package/personas/construct.md +11 -11
- package/scripts/sync-specialists.mjs +149 -35
- package/skills/docs/init-project.md +1 -1
- package/templates/docs/construct_guide.md +2 -2
- package/lib/ingest/chunker.mjs +0 -94
- package/lib/ingest/pipeline.mjs +0 -53
- package/lib/ingest/store.mjs +0 -82
- package/lib/mode-commands.mjs +0 -122
- package/lib/policy/unified-gates.mjs +0 -96
- package/lib/profiles/validate-custom.mjs +0 -114
- package/lib/services/telemetry-backend.mjs +0 -177
- package/lib/storage/fusion.mjs +0 -95
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
// lib/policy/unified-gates.mjs
|
|
2
|
-
// Single policy declaration for all enforcement layers
|
|
3
|
-
|
|
4
|
-
export const POLICIES = {
|
|
5
|
-
commentStyle: {
|
|
6
|
-
id: 'comment-style',
|
|
7
|
-
description: 'Comments must follow project standards',
|
|
8
|
-
layers: ['write', 'commit', 'ci'],
|
|
9
|
-
bypass: 'CONSTRUCT_SKIP_COMMENT_STYLE',
|
|
10
|
-
critical: false,
|
|
11
|
-
},
|
|
12
|
-
docCoupling: {
|
|
13
|
-
id: 'doc-coupling',
|
|
14
|
-
description: 'Code changes require documentation updates',
|
|
15
|
-
layers: ['commit', 'push'],
|
|
16
|
-
bypass: 'CONSTRUCT_SKIP_DOCS',
|
|
17
|
-
critical: false,
|
|
18
|
-
},
|
|
19
|
-
secretScan: {
|
|
20
|
-
id: 'secret-scan',
|
|
21
|
-
description: 'No secrets in committed code',
|
|
22
|
-
layers: ['commit', 'push', 'ci'],
|
|
23
|
-
bypass: 'CONSTRUCT_SKIP_SECRET_SCAN',
|
|
24
|
-
critical: true,
|
|
25
|
-
},
|
|
26
|
-
ciStatus: {
|
|
27
|
-
id: 'ci-status',
|
|
28
|
-
description: 'CI must be green before push',
|
|
29
|
-
layers: ['push', 'session-end'],
|
|
30
|
-
bypass: 'CONSTRUCT_SKIP_CI_CHECK',
|
|
31
|
-
critical: true,
|
|
32
|
-
},
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
export class UnifiedGateEngine {
|
|
36
|
-
constructor(options = {}) {
|
|
37
|
-
this.env = options.env || process.env;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
isBypassed(policyId) {
|
|
41
|
-
const policy = POLICIES[policyId];
|
|
42
|
-
if (!policy) return { bypassed: false };
|
|
43
|
-
|
|
44
|
-
if (policy.bypass && this.env[policy.bypass] === '1') {
|
|
45
|
-
return { bypassed: true, envVar: policy.bypass };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
if (this.env.CONSTRUCT_SKIP_ALL_GATES === '1') {
|
|
49
|
-
return { bypassed: true, envVar: 'CONSTRUCT_SKIP_ALL_GATES' };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return { bypassed: false };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async evaluateLayer(layer, context = {}) {
|
|
56
|
-
const results = [];
|
|
57
|
-
|
|
58
|
-
for (const [id, policy] of Object.entries(POLICIES)) {
|
|
59
|
-
if (policy.layers.includes(layer)) {
|
|
60
|
-
const bypass = this.isBypassed(id);
|
|
61
|
-
results.push({
|
|
62
|
-
policy: id,
|
|
63
|
-
passed: bypass.bypassed || true,
|
|
64
|
-
bypassed: bypass.bypassed,
|
|
65
|
-
bypassEnvVar: bypass.envVar,
|
|
66
|
-
critical: policy.critical,
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const failed = results.filter(r => !r.passed && r.critical);
|
|
72
|
-
|
|
73
|
-
return {
|
|
74
|
-
layer,
|
|
75
|
-
passed: failed.length === 0,
|
|
76
|
-
canProceed: failed.length === 0,
|
|
77
|
-
results,
|
|
78
|
-
summary: `${results.filter(r => r.passed).length}/${results.length} passed`,
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export async function checkGates(layer, context = {}, options = {}) {
|
|
84
|
-
const engine = new UnifiedGateEngine(options);
|
|
85
|
-
return await engine.evaluateLayer(layer, context);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function listPolicies() {
|
|
89
|
-
return Object.entries(POLICIES).map(([id, policy]) => ({
|
|
90
|
-
id,
|
|
91
|
-
description: policy.description,
|
|
92
|
-
layers: policy.layers,
|
|
93
|
-
bypass: policy.bypass,
|
|
94
|
-
critical: policy.critical,
|
|
95
|
-
}));
|
|
96
|
-
}
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* lib/profiles/validate-custom.mjs — Schema validator for user-defined profiles.
|
|
3
|
-
*
|
|
4
|
-
* Custom profiles live at `<project>/.cx/profile.json` with `custom: true`.
|
|
5
|
-
* They are validated on `construct sync` and via the pre-push gate. Rejected
|
|
6
|
-
* profiles never get loaded; resolveActiveProfile falls back to the default
|
|
7
|
-
* so a malformed escape-hatch file never breaks a project.
|
|
8
|
-
*
|
|
9
|
-
* Hard limits the validator enforces (kept in sync with schemas/profile.schema.json
|
|
10
|
-
* and scripts/lint-profiles.mjs):
|
|
11
|
-
* - max 12 stages
|
|
12
|
-
* - max 24 intake types
|
|
13
|
-
* - max 80 roles per profile
|
|
14
|
-
* - max 12 departments per profile
|
|
15
|
-
* - max 20 roles per department
|
|
16
|
-
* - classificationTable path must stay inside .cx/ (no repo escapes)
|
|
17
|
-
*
|
|
18
|
-
* Rationale for each cap lives in docs/concepts/persona-research.md.
|
|
19
|
-
*/
|
|
20
|
-
import fs from 'node:fs';
|
|
21
|
-
import path from 'node:path';
|
|
22
|
-
|
|
23
|
-
const MAX_STAGES = 12;
|
|
24
|
-
const MAX_INTAKE_TYPES = 24;
|
|
25
|
-
const MAX_ROLES = 80;
|
|
26
|
-
const MAX_DEPARTMENTS = 12;
|
|
27
|
-
const MAX_ROLES_PER_DEPARTMENT = 20;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* @returns {string[]} array of error strings; empty means valid.
|
|
31
|
-
*/
|
|
32
|
-
export function validateCustomProfile(profile, { cwd } = {}) {
|
|
33
|
-
const errors = [];
|
|
34
|
-
if (!profile || typeof profile !== 'object') return ['profile is not an object'];
|
|
35
|
-
if (profile.custom !== true) errors.push('custom profiles must set custom: true');
|
|
36
|
-
|
|
37
|
-
if (!profile.id || !/^[a-z][a-z0-9-]{1,30}$/.test(profile.id)) {
|
|
38
|
-
errors.push('id must match ^[a-z][a-z0-9-]{1,30}$');
|
|
39
|
-
}
|
|
40
|
-
if (!profile.displayName || typeof profile.displayName !== 'string') {
|
|
41
|
-
errors.push('displayName is required');
|
|
42
|
-
}
|
|
43
|
-
if (!Array.isArray(profile.roles) || profile.roles.length === 0) {
|
|
44
|
-
errors.push('roles must be a non-empty array');
|
|
45
|
-
} else if (profile.roles.length > MAX_ROLES) {
|
|
46
|
-
errors.push(`roles exceeds max of ${MAX_ROLES}`);
|
|
47
|
-
}
|
|
48
|
-
if (!profile.intake || typeof profile.intake !== 'object') {
|
|
49
|
-
errors.push('intake is required');
|
|
50
|
-
} else {
|
|
51
|
-
if (!Array.isArray(profile.intake.types) || profile.intake.types.length === 0) {
|
|
52
|
-
errors.push('intake.types must be a non-empty array');
|
|
53
|
-
} else if (profile.intake.types.length > MAX_INTAKE_TYPES) {
|
|
54
|
-
errors.push(`intake.types exceeds max of ${MAX_INTAKE_TYPES}`);
|
|
55
|
-
}
|
|
56
|
-
if (!Array.isArray(profile.intake.stages) || profile.intake.stages.length === 0) {
|
|
57
|
-
errors.push('intake.stages must be a non-empty array');
|
|
58
|
-
} else if (profile.intake.stages.length > MAX_STAGES) {
|
|
59
|
-
errors.push(`intake.stages exceeds max of ${MAX_STAGES}`);
|
|
60
|
-
}
|
|
61
|
-
if (typeof profile.intake.classificationTable === 'string') {
|
|
62
|
-
const t = profile.intake.classificationTable;
|
|
63
|
-
if (path.isAbsolute(t)) {
|
|
64
|
-
errors.push('intake.classificationTable must be a relative path');
|
|
65
|
-
} else if (cwd && !t.startsWith('.cx/')) {
|
|
66
|
-
errors.push('intake.classificationTable must live under .cx/ for custom profiles');
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
if (profile.departments !== undefined) {
|
|
71
|
-
if (!Array.isArray(profile.departments)) {
|
|
72
|
-
errors.push('departments must be an array');
|
|
73
|
-
} else {
|
|
74
|
-
if (profile.departments.length > MAX_DEPARTMENTS) {
|
|
75
|
-
errors.push(`departments exceeds max of ${MAX_DEPARTMENTS}`);
|
|
76
|
-
}
|
|
77
|
-
for (const [i, dept] of profile.departments.entries()) {
|
|
78
|
-
if (!dept || typeof dept !== 'object') {
|
|
79
|
-
errors.push(`departments[${i}] must be an object`);
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
if (!dept.id || !/^[a-z][a-z0-9-]{1,40}$/.test(dept.id)) {
|
|
83
|
-
errors.push(`departments[${i}].id is missing or malformed`);
|
|
84
|
-
}
|
|
85
|
-
if (!dept.charter || dept.charter.length < 20) {
|
|
86
|
-
errors.push(`departments[${i}].charter must be at least 20 chars (mission statement, not a label)`);
|
|
87
|
-
}
|
|
88
|
-
if (!Array.isArray(dept.roles) || dept.roles.length === 0) {
|
|
89
|
-
errors.push(`departments[${i}].roles must be a non-empty array`);
|
|
90
|
-
} else if (dept.roles.length > MAX_ROLES_PER_DEPARTMENT) {
|
|
91
|
-
errors.push(`departments[${i}].roles exceeds max of ${MAX_ROLES_PER_DEPARTMENT}`);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return errors;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Read and validate `<cwd>/.cx/profile.json`. Returns:
|
|
101
|
-
* { status: 'absent' } if the file does not exist
|
|
102
|
-
* { status: 'invalid', errors: [...] } if validation failed
|
|
103
|
-
* { status: 'ok', profile } if the file is a valid custom profile
|
|
104
|
-
*/
|
|
105
|
-
export function validateCustomProfileFile(cwd) {
|
|
106
|
-
const p = path.join(cwd, '.cx', 'profile.json');
|
|
107
|
-
if (!fs.existsSync(p)) return { status: 'absent' };
|
|
108
|
-
let raw;
|
|
109
|
-
try { raw = JSON.parse(fs.readFileSync(p, 'utf8')); }
|
|
110
|
-
catch (err) { return { status: 'invalid', errors: [`malformed JSON: ${err.message}`] }; }
|
|
111
|
-
const errors = validateCustomProfile(raw, { cwd });
|
|
112
|
-
if (errors.length > 0) return { status: 'invalid', errors };
|
|
113
|
-
return { status: 'ok', profile: raw };
|
|
114
|
-
}
|
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* lib/services/telemetry-backend.mjs — local services stack management.
|
|
3
|
-
*
|
|
4
|
-
* Manages the local Postgres service (services/docker-compose.yml).
|
|
5
|
-
* In solo mode, Postgres runs as a Docker container with pgvector enabled.
|
|
6
|
-
*
|
|
7
|
-
* Remote telemetry backends (for trace ingestion) are configured via
|
|
8
|
-
* CONSTRUCT_TELEMETRY_URL / CONSTRUCT_TELEMETRY_PUBLIC_KEY /
|
|
9
|
-
* CONSTRUCT_TELEMETRY_SECRET_KEY env vars. This module handles local
|
|
10
|
-
* infrastructure only — it does not manage telemetry dashboard services.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { spawnSync } from 'node:child_process';
|
|
14
|
-
import fs from 'node:fs';
|
|
15
|
-
import os from 'node:os';
|
|
16
|
-
import path from 'node:path';
|
|
17
|
-
|
|
18
|
-
import { getUserEnvPath, writeEnvValues } from '../env-config.mjs';
|
|
19
|
-
|
|
20
|
-
/** Local Postgres port (within Construct's 54329-54339 reserved block). */
|
|
21
|
-
export const POSTGRES_LOCAL_PORT = 54329;
|
|
22
|
-
|
|
23
|
-
export function isRemoteTelemetry(url = '') {
|
|
24
|
-
if (!url) return false;
|
|
25
|
-
return !url.includes('localhost') && !url.includes('127.0.0.1');
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function servicesComposePath(rootDir) {
|
|
29
|
-
return path.join(rootDir, 'services', 'docker-compose.yml');
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Keep only the N most recent stash pairs (.dump + .json) in the stash dir.
|
|
33
|
-
export function pruneStashDir(dir, keep = 3) {
|
|
34
|
-
try {
|
|
35
|
-
const dumps = fs.readdirSync(dir)
|
|
36
|
-
.filter((f) => f.endsWith('.dump'))
|
|
37
|
-
.sort()
|
|
38
|
-
.reverse();
|
|
39
|
-
for (const dump of dumps.slice(keep)) {
|
|
40
|
-
fs.rmSync(path.join(dir, dump), { force: true });
|
|
41
|
-
fs.rmSync(path.join(dir, dump.replace('.dump', '.json')), { force: true });
|
|
42
|
-
}
|
|
43
|
-
} catch { /* non-critical */ }
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Verify that the Postgres service is reachable. Returns a status object.
|
|
48
|
-
* Verifies the Postgres service is up and ready.
|
|
49
|
-
*/
|
|
50
|
-
export async function verifyPostgresHealth({
|
|
51
|
-
port = POSTGRES_LOCAL_PORT,
|
|
52
|
-
fetchFn = globalThis.fetch,
|
|
53
|
-
maxRetries = 5,
|
|
54
|
-
intervalMs = 2000,
|
|
55
|
-
} = {}) {
|
|
56
|
-
// Postgres is not HTTP — health is checked via pg_isready in docker instead
|
|
57
|
-
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
58
|
-
try {
|
|
59
|
-
const result = spawnSync('docker', [
|
|
60
|
-
'exec', 'construct-postgres',
|
|
61
|
-
'pg_isready', '-U', 'construct', '-d', 'construct',
|
|
62
|
-
], { stdio: 'pipe', timeout: 3000 });
|
|
63
|
-
if (result.status === 0) return { status: 'verified' };
|
|
64
|
-
} catch { /* not ready yet */ }
|
|
65
|
-
if (attempt < maxRetries - 1) await new Promise((r) => setTimeout(r, intervalMs));
|
|
66
|
-
}
|
|
67
|
-
return { status: 'unreachable' };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Verify telemetry backend keys (remote HTTP).
|
|
72
|
-
*
|
|
73
|
-
*/
|
|
74
|
-
export async function verifyTelemetryKeys({
|
|
75
|
-
baseUrl,
|
|
76
|
-
publicKey,
|
|
77
|
-
secretKey,
|
|
78
|
-
composeRunner,
|
|
79
|
-
composeFile,
|
|
80
|
-
homeDir = os.homedir(),
|
|
81
|
-
maxRetries = 5,
|
|
82
|
-
intervalMs = 2000,
|
|
83
|
-
spawnSyncFn = spawnSync,
|
|
84
|
-
fetchFn = globalThis.fetch,
|
|
85
|
-
overallTimeoutMs = 0,
|
|
86
|
-
} = {}) {
|
|
87
|
-
const resolvedBaseUrl =
|
|
88
|
-
baseUrl ??
|
|
89
|
-
process.env.CONSTRUCT_TELEMETRY_URL ??
|
|
90
|
-
'';
|
|
91
|
-
const resolvedPublicKey =
|
|
92
|
-
publicKey ??
|
|
93
|
-
process.env.CONSTRUCT_TELEMETRY_PUBLIC_KEY ??
|
|
94
|
-
'';
|
|
95
|
-
const resolvedSecretKey =
|
|
96
|
-
secretKey ??
|
|
97
|
-
process.env.CONSTRUCT_TELEMETRY_SECRET_KEY ??
|
|
98
|
-
'';
|
|
99
|
-
|
|
100
|
-
if (!resolvedPublicKey || !resolvedSecretKey || !resolvedBaseUrl) {
|
|
101
|
-
return { status: 'unconfigured' };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
async function doVerify() {
|
|
105
|
-
const auth = `Basic ${Buffer.from(`${resolvedPublicKey}:${resolvedSecretKey}`).toString('base64')}`;
|
|
106
|
-
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
107
|
-
try {
|
|
108
|
-
const controller = new AbortController();
|
|
109
|
-
const timer = setTimeout(() => controller.abort(), 3000);
|
|
110
|
-
const res = await fetchFn(`${resolvedBaseUrl}/api/public/health`, {
|
|
111
|
-
signal: controller.signal,
|
|
112
|
-
}).finally(() => clearTimeout(timer));
|
|
113
|
-
if (res.ok) break;
|
|
114
|
-
} catch { /* not ready yet */ }
|
|
115
|
-
if (attempt < maxRetries - 1) await new Promise((r) => setTimeout(r, intervalMs));
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
try {
|
|
119
|
-
const controller = new AbortController();
|
|
120
|
-
const timer = setTimeout(() => controller.abort(), 5000);
|
|
121
|
-
const res = await fetchFn(`${resolvedBaseUrl}/api/public/traces?limit=1`, {
|
|
122
|
-
headers: { Authorization: auth },
|
|
123
|
-
signal: controller.signal,
|
|
124
|
-
}).finally(() => clearTimeout(timer));
|
|
125
|
-
if (res.ok) return { status: 'verified' };
|
|
126
|
-
} catch {
|
|
127
|
-
return { status: 'unreachable' };
|
|
128
|
-
}
|
|
129
|
-
return { status: 'auth-failed', reseeded: false };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
if (overallTimeoutMs > 0) {
|
|
133
|
-
return Promise.race([
|
|
134
|
-
doVerify(),
|
|
135
|
-
new Promise((_, reject) =>
|
|
136
|
-
setTimeout(() => reject(new Error('verifyTelemetryKeys timeout')), overallTimeoutMs),
|
|
137
|
-
),
|
|
138
|
-
]);
|
|
139
|
-
}
|
|
140
|
-
return doVerify();
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Bring up the local Postgres service via services/docker-compose.yml.
|
|
145
|
-
* Starts the local Postgres service via services/docker-compose.yml.
|
|
146
|
-
*/
|
|
147
|
-
export async function startManagedServices({
|
|
148
|
-
rootDir,
|
|
149
|
-
homeDir = os.homedir(),
|
|
150
|
-
env = process.env,
|
|
151
|
-
composeRunner,
|
|
152
|
-
spawnDetached,
|
|
153
|
-
} = {}) {
|
|
154
|
-
if (!rootDir) throw new Error('startManagedServices: rootDir is required');
|
|
155
|
-
if (!composeRunner) throw new Error('startManagedServices: composeRunner is required');
|
|
156
|
-
if (!spawnDetached) throw new Error('startManagedServices: spawnDetached is required');
|
|
157
|
-
|
|
158
|
-
const composeFile = servicesComposePath(rootDir);
|
|
159
|
-
if (!fs.existsSync(composeFile)) {
|
|
160
|
-
return { status: 'unavailable', note: `services compose file missing at ${composeFile}` };
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const { logPath } = spawnDetached(
|
|
164
|
-
composeRunner.command,
|
|
165
|
-
[...composeRunner.argsPrefix, '-p', 'construct-services', '-f', composeFile, 'up', '-d'],
|
|
166
|
-
homeDir,
|
|
167
|
-
'services.log',
|
|
168
|
-
);
|
|
169
|
-
|
|
170
|
-
return {
|
|
171
|
-
status: 'started',
|
|
172
|
-
note: `Postgres started — connect at localhost:${POSTGRES_LOCAL_PORT}`,
|
|
173
|
-
composeFile,
|
|
174
|
-
logPath,
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
package/lib/storage/fusion.mjs
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* lib/storage/fusion.mjs — score fusion for hybrid retrieval.
|
|
3
|
-
*
|
|
4
|
-
* Exposes two fusion strategies for combining ranked results from
|
|
5
|
-
* multiple retrieval signals (lexical, vector, metadata, recency):
|
|
6
|
-
*
|
|
7
|
-
* fuseScores({ vector, lexical, metadata, recency }, weights)
|
|
8
|
-
* — weighted linear combination, returns the final score and
|
|
9
|
-
* component scores so callers can debug retrieval drift.
|
|
10
|
-
*
|
|
11
|
-
* reciprocalRankFusion(rankedLists, { k })
|
|
12
|
-
* — RRF: finalScore = Σ 1 / (k + rank_i) for each list a doc appears in.
|
|
13
|
-
* Order-insensitive across lists; robust to score-scale mismatch
|
|
14
|
-
* between lexical (BM25) and vector (cosine) backends.
|
|
15
|
-
*
|
|
16
|
-
* Default weights match the source plan: vector 0.45, lexical 0.35,
|
|
17
|
-
* metadata 0.15, recency 0.05. Callers override per-query when needed
|
|
18
|
-
* (e.g. weight metadata higher for filter-driven queries).
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
export const DEFAULT_WEIGHTS = Object.freeze({
|
|
22
|
-
vector: 0.45,
|
|
23
|
-
lexical: 0.35,
|
|
24
|
-
metadata: 0.15,
|
|
25
|
-
recency: 0.05,
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
function clamp01(value) {
|
|
29
|
-
if (!Number.isFinite(value)) return 0;
|
|
30
|
-
if (value < 0) return 0;
|
|
31
|
-
if (value > 1) return 1;
|
|
32
|
-
return value;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Compute a fused score from component signals.
|
|
37
|
-
*
|
|
38
|
-
* @param {object} components
|
|
39
|
-
* @param {number} [components.vector] cosine similarity in [0, 1]
|
|
40
|
-
* @param {number} [components.lexical] normalized lexical score in [0, 1]
|
|
41
|
-
* @param {number} [components.metadata] metadata-match score in [0, 1]
|
|
42
|
-
* @param {number} [components.recency] freshness score in [0, 1]
|
|
43
|
-
* @param {object} [weights]
|
|
44
|
-
* @returns {{ vector, lexical, metadata, recency, finalScore, weights }}
|
|
45
|
-
*/
|
|
46
|
-
export function fuseScores(components = {}, weights = DEFAULT_WEIGHTS) {
|
|
47
|
-
const v = clamp01(components.vector);
|
|
48
|
-
const l = clamp01(components.lexical);
|
|
49
|
-
const m = clamp01(components.metadata);
|
|
50
|
-
const r = clamp01(components.recency);
|
|
51
|
-
const w = { ...DEFAULT_WEIGHTS, ...weights };
|
|
52
|
-
const finalScore = clamp01(v * w.vector + l * w.lexical + m * w.metadata + r * w.recency);
|
|
53
|
-
return { vector: v, lexical: l, metadata: m, recency: r, finalScore, weights: w };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Reciprocal Rank Fusion across N ranked lists. Each list is an array
|
|
58
|
-
* of `{ id, ... }` objects ordered by descending relevance.
|
|
59
|
-
*
|
|
60
|
-
* @param {Array<Array<object>>} rankedLists
|
|
61
|
-
* @param {object} [opts]
|
|
62
|
-
* @param {number} [opts.k=60]
|
|
63
|
-
* @returns {Array<{ id, rrfScore, appearsIn: number }>}
|
|
64
|
-
*/
|
|
65
|
-
export function reciprocalRankFusion(rankedLists, { k = 60 } = {}) {
|
|
66
|
-
if (!Array.isArray(rankedLists) || rankedLists.length === 0) return [];
|
|
67
|
-
const scores = new Map();
|
|
68
|
-
for (const list of rankedLists) {
|
|
69
|
-
if (!Array.isArray(list)) continue;
|
|
70
|
-
list.forEach((item, idx) => {
|
|
71
|
-
if (!item?.id) return;
|
|
72
|
-
const rrf = 1 / (k + idx + 1);
|
|
73
|
-
const prev = scores.get(item.id) || { id: item.id, rrfScore: 0, appearsIn: 0 };
|
|
74
|
-
prev.rrfScore += rrf;
|
|
75
|
-
prev.appearsIn += 1;
|
|
76
|
-
scores.set(item.id, prev);
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
return [...scores.values()].sort((a, b) => b.rrfScore - a.rrfScore);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Compute a normalized recency score in [0, 1] given a timestamp and
|
|
84
|
-
* a half-life in days. A doc updated today scores ~1, a doc older than
|
|
85
|
-
* 4× the half-life scores near 0.
|
|
86
|
-
*/
|
|
87
|
-
export function recencyScore(updatedAt, { halfLifeDays = 30, now = Date.now() } = {}) {
|
|
88
|
-
if (!updatedAt) return 0;
|
|
89
|
-
const ts = updatedAt instanceof Date ? updatedAt.getTime() : Date.parse(updatedAt);
|
|
90
|
-
if (!Number.isFinite(ts)) return 0;
|
|
91
|
-
const ageMs = Math.max(0, now - ts);
|
|
92
|
-
const halfLifeMs = halfLifeDays * 24 * 60 * 60 * 1000;
|
|
93
|
-
if (halfLifeMs <= 0) return 0;
|
|
94
|
-
return clamp01(Math.pow(0.5, ageMs / halfLifeMs));
|
|
95
|
-
}
|