From de333852e71eb7da5e7af9ffa5fa00465e9b808a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 25 May 2026 14:41:05 +0300 Subject: [PATCH] feat(observer): step 3.6 embedding async wiring (phase 4 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- tools/observer-stop-hook.mjs | 60 ++++++++++++++++++++++++++++ tools/observer-stop-hook.test.mjs | 65 ++++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/tools/observer-stop-hook.mjs b/tools/observer-stop-hook.mjs index d3389e69..0ac9a91a 100644 --- a/tools/observer-stop-hook.mjs +++ b/tools/observer-stop-hook.mjs @@ -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} + */ +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). diff --git a/tools/observer-stop-hook.test.mjs b/tools/observer-stop-hook.test.mjs index fbcc09dd..bbd51772 100644 --- a/tools/observer-stop-hook.test.mjs +++ b/tools/observer-stop-hook.test.mjs @@ -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); + }); +});