Skip to content
Merged
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
18 changes: 15 additions & 3 deletions src/lib/document-enrichment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
compactPromptChunk,
selectCoverageAwarePromptChunks,
} from "@/lib/indexing-coverage";
import { generateStructuredTextResponse } from "@/lib/openai";
import { generateStructuredTextResult } from "@/lib/openai";
import { ragDeepMemoryVersion } from "@/lib/deep-memory";
import { normalizeDocumentLabelForStorage } from "@/lib/document-tags";
import { cleanClinicalSummaryText, fenceSourceEvidence, sourceTextForModelEvidence } from "@/lib/source-text-sanitizer";
Expand Down Expand Up @@ -555,11 +555,13 @@ export async function generateDocumentEnrichment(args: {
chunks: EnrichmentChunk[];
images?: EnrichmentImage[];
}) {
const raw = await generateStructuredTextResponse(
const result = await generateStructuredTextResult(
buildEnrichmentPrompt({ ...args, images: args.images ?? [] }),
summarySchema,
{
model: env.OPENAI_STRONG_ANSWER_MODEL || env.OPENAI_FAST_ANSWER_MODEL,
// Answer-size budget; responseBody() floors the effective cap by reasoning effort so
// medium-effort reasoning cannot starve the enrichment JSON (reasoningHeadroomFloor).
maxOutputTokens: 2400,
operation: "summary",
schemaName: "clinical_document_enrichment",
Expand All @@ -568,7 +570,17 @@ export async function generateDocumentEnrichment(args: {
textVerbosity: "medium",
},
);
const parsed = parseGeneratedSummary(raw, args.document, args.chunks, args.images ?? []);
// Ingestion runs unattended over the whole corpus, so a truncated enrichment must be loud,
// not silent — a silently cut-off summary degrades retrieval for that document and would be
// re-wasted on every re-index. Warn with the document identity so it is greppable and
// re-processable; parsing still proceeds on the partial text.
if (result.truncated) {
console.warn("document enrichment truncated", {
document: args.document.file_name ?? args.document.title,
reason: result.incompleteReason ?? result.status ?? "unknown",
});
}
const parsed = parseGeneratedSummary(result.text, args.document, args.chunks, args.images ?? []);
const inferred = inferLabels(args.document);
const organization = classifyDocumentOrganization({
...args.document,
Expand Down
28 changes: 22 additions & 6 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,19 @@ const envSchema = z.object({
// Strong tier intentionally stays on the standard (non-"pro") model. Fast vs strong
// is differentiated by reasoning effort (OPENAI_*_REASONING_EFFORT), not model tier.
OPENAI_STRONG_ANSWER_MODEL: z.string().default("gpt-5.5"),
// Reasoning models (gpt-5*) draw reasoning tokens from this same budget, so a
// low cap can starve the JSON answer payload and silently truncate clinical
// content (doses/thresholds cut mid-sentence). Raised default to 4000 for headroom;
// if output is still cut off, createTextResult now flags it as truncated (GEN-C1).
OPENAI_MAX_OUTPUT_TOKENS: z.coerce.number().int().positive().default(4000),
// Reasoning models (gpt-5*) draw reasoning tokens from this SAME budget as the
// visible answer, so a low cap makes medium/high-effort reasoning consume the whole
// budget *thinking* and return `incomplete: max_output_tokens` BEFORE it writes the
// JSON answer — the answer is then discarded and the request degrades to
// "unsupported" after 60-90s of wasted generation (GEN-C1). The old 4000 default did
// exactly this on the strong route in production (confirmed via rag_queries telemetry).
// 16000 gives ample headroom for reasoning + a full clinical answer; it is a CEILING
// (billed per token actually used), not a spend commitment, so raising it costs
// nothing when answers finish early. responseBody() additionally floors the effective
// budget by reasoning effort so no call site can under-provision (reasoningHeadroomFloor
// in openai.ts), and the answer path self-heals a truncation by retrying with a larger
// cap before falling back.
OPENAI_MAX_OUTPUT_TOKENS: z.coerce.number().int().positive().default(16000),
OPENAI_QUERY_CACHE_SIZE: z.coerce.number().int().nonnegative().default(200),
// Max inputs per embeddings request. The OpenAI embeddings endpoint caps a single
// request at 2048 inputs / ~300k tokens; a full-corpus re-embed of ~400k texts in one
Expand Down Expand Up @@ -65,7 +73,15 @@ const envSchema = z.object({
.default("false")
.transform((value) => value === "true"),
OPENAI_FAST_REASONING_EFFORT: z.enum(["none", "low", "medium", "high", "xhigh"]).default("low"),
OPENAI_STRONG_REASONING_EFFORT: z.enum(["none", "low", "medium", "high", "xhigh"]).default("high"),
// "high" reasoning on gpt-5.5 is both slow (overruns OPENAI_ANSWER_TIMEOUT_MS ->
// provider_timeout) and token-hungry (exhausts OPENAI_MAX_OUTPUT_TOKENS -> truncation) —
// the two dominant answer-generation failure modes seen in production. "medium" is ample
// for answers grounded in retrieved sources and roughly halves generation time. Note
// strongReasoningEffortForQueryClass() previously kept the FULL configured effort for the
// safety-critical medication_dose_risk/table_threshold classes, so with the old "high"
// default those exact classes ran high-effort and starved first; medium fixes them too.
// That function never raises effort above this configured value.
OPENAI_STRONG_REASONING_EFFORT: z.enum(["none", "low", "medium", "high", "xhigh"]).default("medium"),
OPENAI_SUMMARY_REASONING_EFFORT: z.enum(["none", "low", "medium", "high", "xhigh"]).default("medium"),
OPENAI_VISION_REASONING_EFFORT: z.enum(["none", "low", "medium", "high", "xhigh"]).default("low"),
OPENAI_TEXT_VERBOSITY: z.enum(["low", "medium", "high"]).default("low"),
Expand Down
17 changes: 14 additions & 3 deletions src/lib/model-index-extraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
buildIndexingCoverageProfile,
selectCoverageAwarePromptChunks,
} from "@/lib/indexing-coverage";
import { generateStructuredTextResponse } from "@/lib/openai";
import { generateStructuredTextResult } from "@/lib/openai";
import { cleanClinicalSummaryText, fenceSourceEvidence, sourceTextForModelEvidence } from "@/lib/source-text-sanitizer";

export const modelIndexExtractionVersion = "model-heavy-index-v1" as const;
Expand Down Expand Up @@ -368,16 +368,27 @@ export async function generateModelIndexProfile(args: {
}) {
if (args.chunks.length === 0) return emptyProfile();
const model = env.OPENAI_STRONG_ANSWER_MODEL || env.OPENAI_ANSWER_MODEL;
const raw = await generateStructuredTextResponse(buildPrompt({ ...args, images: args.images ?? [] }), schema, {
const result = await generateStructuredTextResult(buildPrompt({ ...args, images: args.images ?? [] }), schema, {
model,
// Answer-size budget; responseBody() floors the effective cap by reasoning effort so
// medium-effort reasoning cannot starve the model-index JSON (reasoningHeadroomFloor).
maxOutputTokens: 3200,
operation: "summary",
schemaName: "clinical_model_index_profile",
promptCacheKey: "clinical-model-index-profile-v1",
reasoningEffort: "medium",
textVerbosity: "medium",
});
return parseProfile(raw, args.chunks, args.images ?? [], model);
// Ingestion runs unattended over the whole corpus; a truncated extraction silently drops
// model-index coverage for the document. Warn loudly (greppable, with document identity)
// instead of failing silent; parsing still proceeds on the partial text.
if (result.truncated) {
console.warn("model-index extraction truncated", {
document: args.document.file_name ?? args.document.title,
reason: result.incompleteReason ?? result.status ?? "unknown",
});
}
return parseProfile(result.text, args.chunks, args.images ?? [], model);
}

export function fallbackModelIndexProfile() {
Expand Down
24 changes: 22 additions & 2 deletions src/lib/observability/answer-slo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,18 @@ export type AnswerSloSnapshot = {
totalQueries: number;
hybridRpcErrorQueries: number;
degradedQueries: number;
// Subsets of degradedQueries broken out by the two dominant answer-generation waste
// classes: reasoning/answer token starvation (fallback_reason contains
// "max_output_tokens") and generation timeouts (contains "timeout"). These are exactly
// the failure modes the OPENAI_MAX_OUTPUT_TOKENS raise + reasoning-effort drop target,
// surfaced as scrapeable counters so a regression is visible without hand-running SQL.
truncationFallbackQueries: number;
timeoutFallbackQueries: number;
// 0..1; 0 when there were no queries in the window (avoid divide-by-zero noise).
hybridRpcErrorRate: number;
degradedRate: number;
truncationFallbackRate: number;
timeoutFallbackRate: number;
};

type CountResult = { count: number | null; error: unknown };
Expand All @@ -30,6 +39,7 @@ type SloCountBuilder = PromiseLike<CountResult> & {
gt(column: string, value: string): SloCountBuilder;
is(column: string, value: null): SloCountBuilder;
not(column: string, operator: string, value: null): SloCountBuilder;
ilike(column: string, pattern: string): SloCountBuilder;
};

export type SloProbeClient = {
Expand Down Expand Up @@ -60,13 +70,17 @@ export async function answerSloSnapshot(client: SloProbeClient, windowMinutes =
// discriminator without hiding normal production answers from the SLO.
.is("metadata->>event_type", null);

const [total, hybrid, degraded] = await Promise.all([
const [total, hybrid, degraded, truncation, timeout] = await Promise.all([
base(),
base().not("metadata->hybrid_rpc_errors", "is", null),
base().not("metadata->>fallback_reason", "is", null),
// fallback_reason values look like "...generation_fallback:provider_incomplete_max_output_tokens"
// and "...generation_fallback:provider_timeout" (confirmed in live rag_queries).
base().ilike("metadata->>fallback_reason", "%max_output_tokens%"),
base().ilike("metadata->>fallback_reason", "%timeout%"),
]);

for (const result of [total, hybrid, degraded]) {
for (const result of [total, hybrid, degraded, truncation, timeout]) {
if (result.error) {
if (result.error instanceof Error) throw result.error;
// Supabase surfaces a plain PostgrestError object ({ message, code, ... }),
Expand All @@ -82,13 +96,19 @@ export async function answerSloSnapshot(client: SloProbeClient, windowMinutes =
const totalQueries = total.count ?? 0;
const hybridRpcErrorQueries = hybrid.count ?? 0;
const degradedQueries = degraded.count ?? 0;
const truncationFallbackQueries = truncation.count ?? 0;
const timeoutFallbackQueries = timeout.count ?? 0;

return {
windowMinutes,
totalQueries,
hybridRpcErrorQueries,
degradedQueries,
truncationFallbackQueries,
timeoutFallbackQueries,
hybridRpcErrorRate: rate(hybridRpcErrorQueries, totalQueries),
degradedRate: rate(degradedQueries, totalQueries),
truncationFallbackRate: rate(truncationFallbackQueries, totalQueries),
timeoutFallbackRate: rate(timeoutFallbackQueries, totalQueries),
};
}
26 changes: 25 additions & 1 deletion src/lib/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,30 @@ function resolveReasoningEffort(model: string, effort: OpenAIReasoningEffort) {
return capabilities.allowedReasoningEfforts.has(effort) ? effort : "low";
}

// Reasoning models spend reasoning tokens from the SAME budget as the visible answer
// (max_output_tokens). A budget sized only for the answer lets reasoning starve it ->
// `incomplete: max_output_tokens` (the answer is discarded and the request degrades).
// This floor guarantees a minimum TOTAL budget per effort level so no call site can
// under-provision reasoning headroom — a site that declares its answer size gets that
// answer size plus guaranteed reasoning room, and a site asking for MORE than the floor
// keeps its own value (Math.max). The floor only ever RAISES a budget, and the budget is
// a ceiling on token spend (billed per token actually used), so lifting small budgets to
// it is free when the response finishes early.
function reasoningHeadroomFloor(effort: OpenAIReasoningEffort): number {
switch (effort) {
case "xhigh":
return 16000;
case "high":
return 12000;
case "medium":
return 8000;
case "low":
return 2000;
default:
return 0;
}
}

function responseBody(
input: OpenAIResponseInput,
resolved: ResolvedTextGenerationOptions,
Expand All @@ -222,7 +246,7 @@ function responseBody(
model: resolved.model,
input,
...(resolved.instructions ? { instructions: resolved.instructions } : {}),
max_output_tokens: resolved.maxOutputTokens,
max_output_tokens: Math.max(resolved.maxOutputTokens, reasoningHeadroomFloor(resolvedReasoningEffort)),
store: env.OPENAI_STORE_RESPONSES,
prompt_cache_key: resolved.promptCacheKey ?? promptCacheKeyFor(operation),
...(promptCacheRetention ? { prompt_cache_retention: promptCacheRetention } : {}),
Expand Down
43 changes: 37 additions & 6 deletions src/lib/rag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4598,7 +4598,7 @@ ${buildRagSourceBlock(contextResults, { query: answerFocusQuery, queryClass })}`
async function generateWithModel(
model: string,
contextResults: SearchResult[],
options?: { strong?: boolean; qualityRetryInstruction?: string },
options?: { strong?: boolean; qualityRetryInstruction?: string; maxOutputTokensOverride?: number },
): Promise<OpenAITextResult> {
const qualityRetryInstruction = options?.qualityRetryInstruction;
// Fast vs strong is differentiated by reasoning effort, not model identity, so the
Expand All @@ -4619,7 +4619,7 @@ ${qualityRetryInstruction}`
try {
const result = await generateStructuredTextResult(input, answerJsonOutputSchemaForResults(contextResults), {
model,
maxOutputTokens: env.OPENAI_MAX_OUTPUT_TOKENS,
maxOutputTokens: options?.maxOutputTokensOverride ?? env.OPENAI_MAX_OUTPUT_TOKENS,
operation: "answer",
schemaName: "clinical_rag_answer",
instructions: answerInstructions,
Expand Down Expand Up @@ -4647,6 +4647,18 @@ ${qualityRetryInstruction}`
}
}

// Truncation self-heal budget: a max_output_tokens truncation means reasoning+answer
// exhausted the cap, not that the model failed. The strong retries below spend MORE
// reasoning than the first attempt, so they get a boosted cap — escalating to strong on
// the SAME budget is what previously burned a second full generation and still fell
// through to "unsupported". Billed per token actually used, so this is free unless hit.
const strongRetryMaxOutputTokens = Math.max(env.OPENAI_MAX_OUTPUT_TOKENS * 2, 24000);
// Cap cumulative generation wall-clock so a fast -> strong -> quality-repair chain can't
// stack three ~timeout-length calls into a ~90s tail. The quality-repair is a polish pass
// over an already-valid, cited strong answer, so once this budget is spent we keep the
// strong answer rather than risk a third generation (and a truncation -> unsupported tail).
const generationTotalBudgetMs = env.OPENAI_ANSWER_TIMEOUT_MS * 2;

/** Generation incomplete reason. */
function generationIncompleteReason(result: OpenAITextResult) {
return result.incompleteReason ?? (result.status === "incomplete" ? "incomplete" : "unknown");
Expand Down Expand Up @@ -4808,7 +4820,10 @@ ${qualityRetryInstruction}`
retriedWithStrong = true;
await args.onProgress?.({
stage: "retrying",
message: "Fast answer hit the output limit, retrying with the strong model.",
message:
route.mode === "fast"
? "Fast answer hit the output limit, retrying with the strong model and a larger output budget."
: "Answer hit the output limit, retrying with a larger output budget.",
mode: "strong",
model: env.OPENAI_STRONG_ANSWER_MODEL,
reason: routingReason,
Expand All @@ -4819,7 +4834,12 @@ ${qualityRetryInstruction}`
// Widen the retry context from the trimmed fast set to the full result set, but keep the P9
// per-document crowding cap — the strong-initial route is capped, so the retry must be too.
packedContextResults = await packContextForGeneration(capPerDocumentCrowding(answerInputResults));
generated = await generateWithModel(env.OPENAI_STRONG_ANSWER_MODEL, packedContextResults, { strong: true });
// Boost the cap: a max_output_tokens truncation retried on the SAME budget with MORE
// reasoning (strong) just re-truncates. This is the truncation self-heal.
generated = await generateWithModel(env.OPENAI_STRONG_ANSWER_MODEL, packedContextResults, {
strong: true,
maxOutputTokensOverride: strongRetryMaxOutputTokens,
});
retrievalDiagnostics.routeMode = "strong";
}
if (generated.truncated) {
Expand Down Expand Up @@ -4896,7 +4916,12 @@ ${qualityRetryInstruction}`
args.onRevising?.(retryReason);
// Same as the truncation retry above: widen but keep the P9 per-document crowding cap.
packedContextResults = await packContextForGeneration(capPerDocumentCrowding(answerInputResults));
generated = await generateWithModel(env.OPENAI_STRONG_ANSWER_MODEL, packedContextResults, { strong: true });
// Strong spends more reasoning tokens than the fast attempt it is replacing, so it needs
// the boosted cap to avoid truncating (and degrading to unsupported) on the escalation.
generated = await generateWithModel(env.OPENAI_STRONG_ANSWER_MODEL, packedContextResults, {
strong: true,
maxOutputTokensOverride: strongRetryMaxOutputTokens,
});
retrievalDiagnostics.routeMode = "strong";
if (generated.truncated) {
const truncatedReason = generationRetryReason("strong", generated);
Expand All @@ -4917,7 +4942,12 @@ ${qualityRetryInstruction}`
? generatedAnswerQualityFailureReason(answer, args.query, queryClass)
: null;
const answerNeedsStrongQualityRepair = usedStrongModel && Boolean(strongQualityFailureReason);
if (answerNeedsStrongQualityRepair) {
if (answerNeedsStrongQualityRepair && generationLatencyMs >= generationTotalBudgetMs) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve time before starting the quality retry

When the fast/strong path has already spent just under 2 * OPENAI_ANSWER_TIMEOUT_MS, this >= check still allows the strong_quality_retry below to start with a full per-call timeout, so two 29s generations under the default 30s timeout can still kick off a third 30s call and recreate the ~90s tail this guard is meant to prevent. Reserve the next call's timeout in this guard, or pass only the remaining budget into the retry, so the new wall-clock budget actually bounds the chain.

Useful? React with 👍 / 👎.

// A4 tail-latency guard: out of the cumulative generation time budget, so keep the
// valid (if imperfect) cited strong answer instead of spending a third generation
// and risking a truncation -> unsupported tail. Recorded for observability.
answerRetryReasons.push(`strong_quality_repair_skipped_time_budget:${strongQualityFailureReason}`);
} else if (answerNeedsStrongQualityRepair) {
routingReason = `${routingReason}; strong_quality_retry`;
answerRetryCount += 1;
answerRetryReasons.push("strong_quality_retry");
Expand All @@ -4931,6 +4961,7 @@ ${qualityRetryInstruction}`
args.onRevising?.("strong_quality_retry");
generated = await generateWithModel(env.OPENAI_STRONG_ANSWER_MODEL, packedContextResults, {
strong: true,
maxOutputTokensOverride: strongRetryMaxOutputTokens,
qualityRetryInstruction: `The previous answer failed deterministic validation (${strongQualityFailureReason}). Return schema-valid output only, with a complete natural clinical synthesis in the answer field. The first sentence must directly answer the question as a full sentence. Every clinical claim must be supported by valid retrieved citation_chunk_id values; do not invent citation IDs. Avoid template/source-inventory wording and do not include JSON fragments inside text fields. If the evidence cannot support the requested clinical answer, return a concise source-gap answer instead. If the question is a simple definition or direct fact question, answer only that question and return answerSections as an empty array unless a source-gap or safety caveat is essential.`,
});
retrievalDiagnostics.routeMode = "strong";
Expand Down
Loading