Harden local LLM direct reply output
This commit is contained in:
parent
2c32b8df1f
commit
80b5ec337c
2 changed files with 145 additions and 1 deletions
|
|
@ -304,6 +304,7 @@ export async function handlePrompt(options: PromptHandlerOptions): Promise<void>
|
|||
details: {
|
||||
command: options.command,
|
||||
summary: response.summary,
|
||||
outputPreview: truncate(response.outputText ?? response.summary, 500),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue