diff --git a/apps/bot/src/discord-bot-handlers.ts b/apps/bot/src/discord-bot-handlers.ts index 67f0b21..4310ae6 100644 --- a/apps/bot/src/discord-bot-handlers.ts +++ b/apps/bot/src/discord-bot-handlers.ts @@ -304,6 +304,7 @@ export async function handlePrompt(options: PromptHandlerOptions): Promise details: { command: options.command, summary: response.summary, + outputPreview: truncate(response.outputText ?? response.summary, 500), }, }); diff --git a/packages/providers/src/local-llm-provider.ts b/packages/providers/src/local-llm-provider.ts index 8bf9b20..bc5ae39 100644 --- a/packages/providers/src/local-llm-provider.ts +++ b/packages/providers/src/local-llm-provider.ts @@ -33,6 +33,44 @@ export type { LocalLlmToolbox, } from "./local-llm-shared"; +const FINAL_OUTPUT_REWRITE_LIMIT = 1; +const METADATA_ONLY_KEYS = new Set([ + "timestamp", + "editedAt", + "authorId", + "authorName", + "channelId", + "channelName", + "threadId", + "jumpUrl", + "sourceId", + "repoName", + "path", + "language", + "title", + "score", + "text", + "chunkIndex", + "lineStart", + "lineEnd", + "query", + "found", + "count", + "resultCount", + "messages", + "results", + "repos", + "repoCount", + "chunks", +]); +const STRUCTURED_OUTPUT_REQUEST_PATTERN = + /\b(json|yaml|yml|xml|csv|tsv|object|array|schema|key-value|key value|machine-readable|machine readable)\b/i; +const RAW_TOOL_OUTPUT_REWRITE_PROMPT = [ + "You just returned raw JSON or a tool payload fragment instead of answering the user.", + "Do not paste raw tool output.", + "Answer the user's question directly in concise natural-language Discord prose using the context you already gathered.", +].join(" "); + export class LocalLlmProvider implements ProviderAdapter { public constructor(private readonly options: LocalLlmProviderOptions) {} @@ -97,8 +135,9 @@ export class LocalLlmProvider implements ProviderAdapter { try { let finalPayload: OllamaChatResponse | undefined; let finalOutputText: string | undefined; + let rewriteAttempts = 0; - for (let turn = 0; turn <= MAX_TOOL_TURNS; turn += 1) { + for (let turn = 0; turn <= MAX_TOOL_TURNS; ) { const requestBody = { model: this.options.ollamaModel, messages, @@ -142,6 +181,23 @@ export class LocalLlmProvider implements ProviderAdapter { if (toolCalls.length === 0) { const candidateOutput = normalizeModelText(assistantMessage.content); + if ( + candidateOutput && + rewriteAttempts < FINAL_OUTPUT_REWRITE_LIMIT && + shouldRewriteStructuredOutput( + candidateOutput, + request.contextBundle.prompt, + toolTrace.length, + ) + ) { + rewriteAttempts += 1; + messages.push({ + role: "user", + content: RAW_TOOL_OUTPUT_REWRITE_PROMPT, + }); + continue; + } + finalOutputText = candidateOutput; break; } @@ -171,6 +227,8 @@ export class LocalLlmProvider implements ProviderAdapter { content: JSON.stringify(execution.payload, null, 2), }); } + + turn += 1; } if (!finalOutputText) { @@ -232,3 +290,88 @@ export class LocalLlmProvider implements ProviderAdapter { return []; } } + +function shouldRewriteStructuredOutput( + candidateOutput: string, + userPrompt: string, + toolTraceCount: number, +): boolean { + if (STRUCTURED_OUTPUT_REQUEST_PATTERN.test(userPrompt)) { + return false; + } + + const parsed = parseStructuredOutput(candidateOutput); + + if (parsed === undefined) { + return false; + } + + if (toolTraceCount > 0) { + return true; + } + + return isMetadataOnlyValue(parsed); +} + +function parseStructuredOutput(input: string): unknown { + const normalized = unwrapStructuredFence(input); + + if (!normalized.startsWith("{") && !normalized.startsWith("[")) { + return undefined; + } + + try { + return JSON.parse(normalized); + } catch { + return undefined; + } +} + +function unwrapStructuredFence(input: string): string { + const trimmed = input.trim(); + const match = /^```(?:json|yaml|yml)?\s*([\s\S]*?)\s*```$/i.exec(trimmed); + return (match?.[1] ?? trimmed).trim(); +} + +function isMetadataOnlyValue(value: unknown): boolean { + if (Array.isArray(value)) { + return value.length > 0 && value.every((entry) => isMetadataOnlyValue(entry)); + } + + if (!value || typeof value !== "object") { + return false; + } + + const entries = Object.entries(value); + + if (entries.length === 0) { + return false; + } + + return entries.every(([key, child]) => { + if (!METADATA_ONLY_KEYS.has(key)) { + return false; + } + + if (Array.isArray(child)) { + return child.every((entry) => + entry && typeof entry === "object" ? isMetadataOnlyValue(entry) : isScalarMetadataValue(entry), + ); + } + + if (child && typeof child === "object") { + return isMetadataOnlyValue(child); + } + + return isScalarMetadataValue(child); + }); +} + +function isScalarMetadataValue(value: unknown): boolean { + return ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + value === null + ); +}