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:
Дмитрий
2026-05-25 14:41:05 +03:00
parent 2b116250a6
commit de333852e7
2 changed files with 124 additions and 1 deletions
+60
View File
@@ -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).
+64 -1
View File
@@ -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);
});
});