Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <result>-sentinel JSON extraction
# path. Default off; set to "true" to enable. Currently gates bulletReframing only.
VITE_FACET_STRUCTURED_OUTPUTS=false
63 changes: 62 additions & 1 deletion src/test/bulletReframing.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -242,4 +242,65 @@ describe('bulletReframing', () => {
expect(body.messages[0]?.content).toContain('<target_vector>Vector 1</target_vector>')
expect(body.messages[0]?.content).not.toContain('<positioning_strategy>')
})

it('sends output_config.format and parses structured JSON directly when enabled', async () => {
// Anthropic-style envelope with a pure-JSON text block (no <result> 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<string, unknown>
expect(body.output_config).toBeUndefined()
})
})
35 changes: 33 additions & 2 deletions src/utils/bulletReframing.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -41,17 +65,24 @@ ${options.strategy ? `<positioning_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<string, unknown>
try {
const extracted = extractJsonBlock(rawResponse)
parsed = JSON.parse(extracted) as Record<string, unknown>
// 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<string, unknown>
} catch (error) {
if (error instanceof JsonExtractionError) throw error
const detail = error instanceof Error ? error.message : 'Unknown error'
Expand Down
12 changes: 12 additions & 0 deletions src/utils/facetEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<result>`-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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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),
}
}

Expand Down
Loading