feat(observer): step 3.6 embedding async wiring (phase 4 follow-up)
Mirrors step 3.5 self-assessment pattern (c1ec61fa). When embedding-mode=on and task is non-trivial (per shouldEmbed), computes Xenova 384-dim embedding via Promise.race with 2s timeout. Result -> prompt_embedding_base64 base64 string, or null + environment.embedding_unavailable=true on timeout/failure. Closes Phase 4 follow-up "embedding async wiring" (was deferred from Phase 3 deferred #2 / parser write-block — parser writes the slot, CLI now fills it). Extracted core into exported helper computeEmbeddingForEpisode(ep, ctx, opts) with injectable embedFn / shouldEmbedFn / encodeBase64Fn / timeoutMs, mirroring the pure-API style of callSelfAssessmentApi. CLI binds the real router-embedding.mjs implementations; tests inject fakes. 4 new tests: - embedding-mode off -> field null - taskType=conversation (exempt) -> embedding skipped - embedding success -> base64 string - embedding timeout -> environment.embedding_unavailable=true Regression: 650/650 tests passed (35 test files), 0 failed (excluding 4 pre-existing empty ruflo-*/subagent-prompt-prefix test files).
This commit is contained in:
@@ -20,6 +20,7 @@ import { sanitize, sanitizeWithCount } from './observer-pii-filter.mjs';
|
||||
import { parseTranscript, extractLastUserPromptText } from './observer-transcript-parser.mjs';
|
||||
import { detectMethodDirected, loadKnownNodes } from './observer-routing-detector.mjs';
|
||||
import { callSelfAssessmentApi, readRuntimeFlag } from './observer-self-assessment-api.mjs';
|
||||
import { shouldEmbed as _shouldEmbed, encodeBase64 as _encodeBase64, embed as _embed } from './router-embedding.mjs';
|
||||
|
||||
const REQUIRED_FIELDS = ['task_id', 'timestamps', 'path_type', 'outcome', 'primary_rationale'];
|
||||
const V2_FIELDS = [
|
||||
@@ -242,6 +243,60 @@ export function buildSelfAssessment({ apiResult } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3.6 embedding async wiring (Phase 4 follow-up).
|
||||
*
|
||||
* Mirrors the Step 3.5 self-assessment pattern (commit c1ec61fa). When the
|
||||
* embedding-mode runtime flag is 'on' and the task is non-trivial (per
|
||||
* shouldEmbed), computes a 384-dim sentence embedding via Xenova and stores
|
||||
* it on the episode as `prompt_embedding_base64`. Fail-quiet: on timeout /
|
||||
* model load failure / runtime error → field stays null and
|
||||
* `environment.embedding_unavailable = true` is set.
|
||||
*
|
||||
* Pure-API style: injectable embedFn / shouldEmbedFn / encodeBase64Fn for tests
|
||||
* (the CLI binds them to the real router-embedding.mjs implementations).
|
||||
*
|
||||
* @param {object} ep — episode object to mutate
|
||||
* @param {object} ctx — Stop-hook context (uses ctx.prompt)
|
||||
* @param {object} opts
|
||||
* @param {string} [opts.embedMode] — runtime flag value ('on' to compute)
|
||||
* @param {Function} [opts.shouldEmbedFn] — taskType -> bool
|
||||
* @param {Function} [opts.embedFn] — async(prompt) -> Float32Array | null
|
||||
* @param {Function} [opts.encodeBase64Fn]— Float32Array -> base64 string
|
||||
* @param {number} [opts.timeoutMs] — race timeout (default 2000)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function computeEmbeddingForEpisode(ep, ctx = {}, opts = {}) {
|
||||
const {
|
||||
embedMode = 'off',
|
||||
shouldEmbedFn = _shouldEmbed,
|
||||
embedFn = _embed,
|
||||
encodeBase64Fn = _encodeBase64,
|
||||
timeoutMs = 2000,
|
||||
} = opts;
|
||||
|
||||
if (embedMode !== 'on') return;
|
||||
const taskType = ep?.primary_rationale?.task_classification;
|
||||
if (!shouldEmbedFn(taskType)) return;
|
||||
if (!ctx || !ctx.prompt) return;
|
||||
|
||||
try {
|
||||
const vec = await Promise.race([
|
||||
embedFn(ctx.prompt),
|
||||
new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs)),
|
||||
]);
|
||||
if (vec && vec.length > 0) {
|
||||
ep.prompt_embedding_base64 = encodeBase64Fn(vec);
|
||||
} else {
|
||||
ep.environment ??= {};
|
||||
ep.environment.embedding_unavailable = true;
|
||||
}
|
||||
} catch (_e) {
|
||||
ep.environment ??= {};
|
||||
ep.environment.embedding_unavailable = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal observer_error marker — written instead of a silent skip
|
||||
* when the Stop-hook fails internally (spec §3 / §5.2).
|
||||
@@ -333,6 +388,11 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-s
|
||||
ep.self_assessment = buildSelfAssessment({ apiResult });
|
||||
}
|
||||
|
||||
// Step 3.6: embedding async wiring (fail-quiet, 2s timeout).
|
||||
// Trivial task types skipped via shouldEmbed. Mirrors Step 3.5 pattern.
|
||||
const embMode = readRuntimeFlag('embedding-mode');
|
||||
await computeEmbeddingForEpisode(ep, ctx, { embedMode: embMode });
|
||||
|
||||
// Always write the episode first — exit-0-safe (spec §5.1 step 1).
|
||||
appendEpisode(ep);
|
||||
// Then the routing-gate (spec §5.1 steps 2-4).
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync, mkdirSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { appendEpisode, buildEpisodeFromContext, buildObserverError, routingGateDecision, buildExecutionTrace, buildEpisode, buildSelfAssessment } from './observer-stop-hook.mjs';
|
||||
import { appendEpisode, buildEpisodeFromContext, buildObserverError, routingGateDecision, buildExecutionTrace, buildEpisode, buildSelfAssessment, computeEmbeddingForEpisode } from './observer-stop-hook.mjs';
|
||||
|
||||
let workdir;
|
||||
|
||||
@@ -303,3 +303,66 @@ describe('routingGateDecision', () => {
|
||||
expect(gate.block).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 3.6 embedding async wiring (Phase 4 follow-up)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Step 3.6 embedding async wiring', () => {
|
||||
// Helper to build an episode with a given task_classification.
|
||||
const epWithClass = (cls = 'feature') => v2Episode({
|
||||
primary_rationale: { ...defaultRat(), task_classification: cls },
|
||||
});
|
||||
|
||||
it('embedding-mode off → embedding not computed, field null', async () => {
|
||||
const ep = epWithClass('feature');
|
||||
const embedFn = async () => new Float32Array([0.1, 0.2, 0.3]);
|
||||
await computeEmbeddingForEpisode(ep, { prompt: 'напиши тест' }, {
|
||||
embedMode: 'off',
|
||||
embedFn,
|
||||
});
|
||||
expect(ep.prompt_embedding_base64).toBeUndefined();
|
||||
expect(ep.environment?.embedding_unavailable).toBeUndefined();
|
||||
});
|
||||
|
||||
it('taskType="conversation" (exempt) → embedding skipped, field null', async () => {
|
||||
const ep = epWithClass('conversation');
|
||||
let called = false;
|
||||
const embedFn = async () => { called = true; return new Float32Array([0.1]); };
|
||||
await computeEmbeddingForEpisode(ep, { prompt: 'спасибо' }, {
|
||||
embedMode: 'on',
|
||||
embedFn,
|
||||
});
|
||||
expect(called).toBe(false);
|
||||
expect(ep.prompt_embedding_base64).toBeUndefined();
|
||||
expect(ep.environment?.embedding_unavailable).toBeUndefined();
|
||||
});
|
||||
|
||||
it('embedding success → prompt_embedding_base64 is base64 string, environment.embedding_unavailable not set', async () => {
|
||||
const ep = epWithClass('feature');
|
||||
// Distinctive non-zero vector so encoding produces a stable, non-empty base64.
|
||||
const fakeVec = new Float32Array([0.5, -0.25, 1.0, 0.0]);
|
||||
const embedFn = async () => fakeVec;
|
||||
await computeEmbeddingForEpisode(ep, { prompt: 'напиши тест для биллинга' }, {
|
||||
embedMode: 'on',
|
||||
embedFn,
|
||||
});
|
||||
expect(typeof ep.prompt_embedding_base64).toBe('string');
|
||||
expect(ep.prompt_embedding_base64.length).toBeGreaterThan(0);
|
||||
// Base64-only chars (no whitespace, no null prefix).
|
||||
expect(ep.prompt_embedding_base64).toMatch(/^[A-Za-z0-9+/]+=*$/);
|
||||
expect(ep.environment?.embedding_unavailable).toBeUndefined();
|
||||
});
|
||||
|
||||
it('embedding timeout (2s) → field null, environment.embedding_unavailable=true', async () => {
|
||||
const ep = epWithClass('feature');
|
||||
// embedFn never resolves — timeout (overridden short for test) must win.
|
||||
const embedFn = () => new Promise(() => {});
|
||||
await computeEmbeddingForEpisode(ep, { prompt: 'долгая задача' }, {
|
||||
embedMode: 'on',
|
||||
embedFn,
|
||||
timeoutMs: 30, // short override so the test is fast
|
||||
});
|
||||
expect(ep.prompt_embedding_base64).toBeUndefined();
|
||||
expect(ep.environment.embedding_unavailable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user