diff --git a/.env.example b/.env.example index 9a48585..1c21eb6 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,7 @@ VITE_SUPABASE_PUBLISHABLE_KEY= # Required in hosted mode; ignored in self-hosted mode. # Hosted: the Facet account/billing API base URL (e.g., https://api.facet.app). VITE_FACET_API_BASE_URL= +# Pilot flag (#103): route eligible AI calls through Anthropic native structured +# outputs (output_config.format) instead of the -sentinel JSON extraction +# path. Default off; set to "true" to enable. Currently gates bulletReframing only. +VITE_FACET_STRUCTURED_OUTPUTS=false diff --git a/src/test/bulletReframing.test.ts b/src/test/bulletReframing.test.ts index 8702d09..e85963f 100644 --- a/src/test/bulletReframing.test.ts +++ b/src/test/bulletReframing.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { reframeBulletForVector } from '../utils/bulletReframing' +import { REFRAME_OUTPUT_SCHEMA, reframeBulletForVector } from '../utils/bulletReframing' describe('bulletReframing', () => { const mockEndpoint = 'https://api.example.com/ai' @@ -242,4 +242,65 @@ describe('bulletReframing', () => { expect(body.messages[0]?.content).toContain('Vector 1') expect(body.messages[0]?.content).not.toContain('') }) + + it('sends output_config.format and parses structured JSON directly when enabled', async () => { + // Anthropic-style envelope with a pure-JSON text block (no sentinel, + // no fence) — what the structured-outputs path returns. The direct-parse path + // must consume it without the extraction heuristics. + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + content: [ + { + type: 'text', + text: JSON.stringify({ reframed: 'Structured rewrite', reasoning: 'Schema-bound' }), + }, + ], + }), + } as Response) + + const result = await reframeBulletForVector('Original text', 'Vector 1', mockEndpoint, { + structuredOutput: true, + }) + + expect(result).toEqual({ + original: 'Original text', + reframed: 'Structured rewrite', + reasoning: 'Schema-bound', + }) + + const [, init] = vi.mocked(fetch).mock.calls[0] ?? [] + const body = JSON.parse((init as RequestInit).body as string) as { + output_config?: unknown + } + expect(body.output_config).toEqual({ + format: { type: 'json_schema', schema: REFRAME_OUTPUT_SCHEMA }, + }) + }) + + it('omits output_config on the default extraction path', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [ + { + message: { + content: JSON.stringify({ + reframed: 'Rewritten text', + reasoning: 'Strategic reason', + }), + }, + }, + ], + }), + } as Response) + + await reframeBulletForVector('Original text', 'Vector 1', mockEndpoint, { + structuredOutput: false, + }) + + const [, init] = vi.mocked(fetch).mock.calls[0] ?? [] + const body = JSON.parse((init as RequestInit).body as string) as Record + expect(body.output_config).toBeUndefined() + }) }) diff --git a/src/utils/bulletReframing.ts b/src/utils/bulletReframing.ts index 428f404..65f9340 100644 --- a/src/utils/bulletReframing.ts +++ b/src/utils/bulletReframing.ts @@ -1,10 +1,34 @@ +import { facetClientEnv } from './facetEnv' import { callLlmProxy, extractJsonBlock, JsonExtractionError } from './llmProxy' const BULLET_REFRAMING_MODEL = 'sonnet' +/** + * Output contract for the structured-outputs path (#103 pilot). Hand-written + * rather than Zod-derived so the pilot stays dependency-free and provably within + * the constrained-decoding subset: every object sets `additionalProperties: false` + * and lists `required`. The broader rollout is expected to adopt Zod + * (`z.toJSONSchema()`) for ergonomics + a runtime backstop. + */ +export const REFRAME_OUTPUT_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['reframed', 'reasoning'], + properties: { + reframed: { type: 'string' }, + reasoning: { type: 'string' }, + }, +} as const + export interface BulletReframingOptions { apiKey?: string strategy?: string + /** + * Force the structured-outputs path on/off for this call. Defaults to the + * `VITE_FACET_STRUCTURED_OUTPUTS` flag (`facetClientEnv.structuredOutputs`). + * Primarily a test/escape hatch; production callers inherit the env flag. + */ + structuredOutput?: boolean } export interface ReframedBulletResult { @@ -41,17 +65,24 @@ ${options.strategy ? `${escapePromptXml(options.strategy)} Respond in JSON only.` + const useStructuredOutput = options.structuredOutput ?? facetClientEnv.structuredOutputs + const rawResponse = await callLlmProxy(endpoint, systemPrompt, userPrompt, { feature: 'build.bullet-reframe', model: BULLET_REFRAMING_MODEL, temperature: 0, apiKey: options.apiKey, + ...(useStructuredOutput + ? { outputConfig: { format: { type: 'json_schema', schema: REFRAME_OUTPUT_SCHEMA } } } + : {}), }) let parsed: Record try { - const extracted = extractJsonBlock(rawResponse) - parsed = JSON.parse(extracted) as Record + // Structured outputs guarantee the text block is valid JSON matching the + // schema, so parse it directly and skip the sentinel/fence/brace heuristics. + const jsonText = useStructuredOutput ? rawResponse : extractJsonBlock(rawResponse) + parsed = JSON.parse(jsonText) as Record } catch (error) { if (error instanceof JsonExtractionError) throw error const detail = error instanceof Error ? error.message : 'Unknown error' diff --git a/src/utils/facetEnv.ts b/src/utils/facetEnv.ts index 78c7052..2eba85f 100644 --- a/src/utils/facetEnv.ts +++ b/src/utils/facetEnv.ts @@ -9,6 +9,13 @@ export interface FacetClientEnv { anthropicProxyApiKey: string supabaseUrl: string supabasePublishableKey: string + /** + * Pilot flag (#103): route eligible AI calls through Anthropic native + * structured outputs instead of the ``-sentinel extraction path. + * Env-only (no hosted build-time define yet) while the migration is scoped + * to the `bulletReframing` pilot. + */ + structuredOutputs: boolean } export function resolveClientEnvValue(buildValue: BuildEnvValue, viteValue?: string): string { @@ -19,6 +26,10 @@ export function resolveClientEnvValue(buildValue: BuildEnvValue, viteValue?: str return viteValue?.trim() ?? '' } +export function resolveBooleanEnvValue(viteValue?: string): boolean { + return viteValue?.trim().toLowerCase() === 'true' +} + export function resolveFacetDeploymentModeValue( buildValue: BuildEnvValue, viteValue?: FacetDeploymentMode, @@ -79,6 +90,7 @@ export function getFacetClientEnv(): FacetClientEnv { buildSupabasePublishableKey, import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY, ), + structuredOutputs: resolveBooleanEnvValue(import.meta.env.VITE_FACET_STRUCTURED_OUTPUTS), } }