mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
feat: Implement localSearch CiC prompting flow in CopilotPlusChainRunner (#1856)
This commit is contained in:
parent
a1efad1c64
commit
56cac6eff9
4 changed files with 157 additions and 43 deletions
|
|
@ -71,6 +71,14 @@ The system uses a three-layer approach for providing tool instructions to LLMs:
|
|||
- Only for persistent model-specific failures
|
||||
- Keep minimal and targeted
|
||||
|
||||
### localSearch CiC Prompting Flow
|
||||
|
||||
- CiC: Corpus in Context https://arxiv.org/pdf/2406.13121
|
||||
- **Instruction First**: `CopilotPlusChainRunner` now assembles the localSearch payload via `buildLocalSearchInnerContent`, ensuring citation guidance (e.g., `<guidance>` rules) tops the XML block before any documents.
|
||||
- **Documents Next**: Search hits are serialized once through `formatSearchResultsForLLM`; the helper simply appends them after guidance, keeping the documents section untouched but clearly separated.
|
||||
- **Question Last**: `renderCiCMessage` formats the final prompt so any context precedes the user's original query, optionally labeling it (`Question:`) when only vault search was used; this matches the CiC recommendation for instruction → context → query ordering.
|
||||
- **Reusable Wrapping**: `wrapLocalSearchPayload` centralizes the `<localSearch>` tag creation (including optional `timeRange`), making the layout reusable for future chains without copying string glue.
|
||||
|
||||
## Current Implementation
|
||||
|
||||
### Core Files
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { getStandaloneQuestion } from "@/chainUtils";
|
||||
import { AVAILABLE_TOOLS } from "@/components/chat-components/constants/tools";
|
||||
import {
|
||||
ABORT_REASON,
|
||||
COMPOSER_OUTPUT_INSTRUCTIONS,
|
||||
|
|
@ -23,28 +24,32 @@ import { getApiErrorMessage, getMessageRole, withSuppressedTokenWarnings } from
|
|||
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
|
||||
import { IntentAnalyzer } from "../intentAnalyzer";
|
||||
import { BaseChainRunner } from "./BaseChainRunner";
|
||||
import {
|
||||
formatSourceCatalog,
|
||||
getCitationInstructions,
|
||||
sanitizeContentForCitations,
|
||||
addFallbackSources,
|
||||
type SourceCatalogEntry,
|
||||
} from "./utils/citationUtils";
|
||||
import { ActionBlockStreamer } from "./utils/ActionBlockStreamer";
|
||||
import {
|
||||
addChatHistoryToMessages,
|
||||
processedMessagesToTextOnly,
|
||||
processRawChatHistory,
|
||||
} from "./utils/chatHistoryUtils";
|
||||
import {
|
||||
addFallbackSources,
|
||||
formatSourceCatalog,
|
||||
getCitationInstructions,
|
||||
sanitizeContentForCitations,
|
||||
type SourceCatalogEntry,
|
||||
} from "./utils/citationUtils";
|
||||
import {
|
||||
extractSourcesFromSearchResults,
|
||||
formatSearchResultsForLLM,
|
||||
formatSearchResultStringForLLM,
|
||||
logSearchResultsDebugTable,
|
||||
} from "./utils/searchResultUtils";
|
||||
import {
|
||||
buildLocalSearchInnerContent,
|
||||
renderCiCMessage,
|
||||
wrapLocalSearchPayload,
|
||||
} from "./utils/cicPromptUtils";
|
||||
import { ThinkBlockStreamer } from "./utils/ThinkBlockStreamer";
|
||||
import { deduplicateSources } from "./utils/toolExecution";
|
||||
import { AVAILABLE_TOOLS } from "@/components/chat-components/constants/tools";
|
||||
|
||||
export class CopilotPlusChainRunner extends BaseChainRunner {
|
||||
private async processImageUrls(urls: string[]): Promise<ImageProcessingResult> {
|
||||
|
|
@ -616,46 +621,47 @@ export class CopilotPlusChainRunner extends BaseChainRunner {
|
|||
}
|
||||
}
|
||||
|
||||
if (toolOutputs.length > 0) {
|
||||
const validOutputs = toolOutputs.filter((output) => output.output != null);
|
||||
if (validOutputs.length > 0) {
|
||||
// Don't add "Additional context" header if only localSearch with results to maintain QA format
|
||||
const contextHeader =
|
||||
hasLocalSearchWithResults && validOutputs.length === 1
|
||||
? ""
|
||||
: "\n\n# Additional context:\n\n";
|
||||
context =
|
||||
contextHeader +
|
||||
validOutputs
|
||||
.map((output) => {
|
||||
let content = output.output;
|
||||
const validOutputs = toolOutputs.filter((output) => output.output != null);
|
||||
|
||||
// localSearch results are already formatted by executeToolCalls
|
||||
// No need for special processing here
|
||||
if (validOutputs.length > 0) {
|
||||
// Don't add "Additional context" header if only localSearch with results to maintain QA format
|
||||
const contextHeader =
|
||||
hasLocalSearchWithResults && validOutputs.length === 1
|
||||
? ""
|
||||
: "\n\n# Additional context:\n\n";
|
||||
const rawContext =
|
||||
contextHeader +
|
||||
validOutputs
|
||||
.map((output) => {
|
||||
let content = output.output;
|
||||
|
||||
// Ensure content is string
|
||||
if (typeof content !== "string") {
|
||||
content = JSON.stringify(content);
|
||||
}
|
||||
// localSearch results are already formatted by executeToolCalls
|
||||
// No need for special processing here
|
||||
|
||||
// localSearch is already wrapped in XML tags, don't double-wrap
|
||||
if (output.tool === "localSearch") {
|
||||
return content;
|
||||
}
|
||||
// Ensure content is string
|
||||
if (typeof content !== "string") {
|
||||
content = JSON.stringify(content);
|
||||
}
|
||||
|
||||
// All other tools get wrapped consistently
|
||||
return `<${output.tool}>\n${content}\n</${output.tool}>`;
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
// localSearch is already wrapped in XML tags, don't double-wrap
|
||||
if (output.tool === "localSearch") {
|
||||
return content;
|
||||
}
|
||||
|
||||
// All other tools get wrapped consistently
|
||||
return `<${output.tool}>\n${content}\n</${output.tool}>`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
context = rawContext.trim();
|
||||
}
|
||||
|
||||
// For QA format when only localSearch with results is present
|
||||
if (hasLocalSearchWithResults && toolOutputs.filter((o) => o.output != null).length === 1) {
|
||||
return `${context}\n\nQuestion: ${userMessage}`;
|
||||
if (!context) {
|
||||
return userMessage;
|
||||
}
|
||||
|
||||
return `${userMessage}${context}`;
|
||||
const shouldLabelQuestion = hasLocalSearchWithResults && validOutputs.length === 1;
|
||||
return renderCiCMessage(context, userMessage, shouldLabelQuestion);
|
||||
}
|
||||
|
||||
protected getTimeExpression(toolCalls: any[]): string {
|
||||
|
|
@ -724,10 +730,10 @@ export class CopilotPlusChainRunner extends BaseChainRunner {
|
|||
const settings = getSettings();
|
||||
const guidance = getCitationInstructions(settings.enableInlineCitations, catalogLines);
|
||||
|
||||
const innerContent = buildLocalSearchInnerContent(guidance, formattedContent);
|
||||
|
||||
// Wrap in XML-like tags for better LLM understanding
|
||||
return timeExpression
|
||||
? `<localSearch timeRange="${timeExpression}">\n${formattedContent}${guidance}\n</localSearch>`
|
||||
: `<localSearch>\n${formattedContent}${guidance}\n</localSearch>`;
|
||||
return wrapLocalSearchPayload(innerContent, timeExpression);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
50
src/LLMProviders/chainRunner/utils/cicPromptUtils.test.ts
Normal file
50
src/LLMProviders/chainRunner/utils/cicPromptUtils.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import {
|
||||
buildLocalSearchInnerContent,
|
||||
renderCiCMessage,
|
||||
wrapLocalSearchPayload,
|
||||
} from "./cicPromptUtils";
|
||||
|
||||
describe("cicPromptUtils", () => {
|
||||
describe("buildLocalSearchInnerContent", () => {
|
||||
it("orders guidance before documents and trims whitespace", () => {
|
||||
const guidance = "\n<guidance>Rules</guidance>\n";
|
||||
const documents = "\n<document>Doc</document>\n";
|
||||
|
||||
const inner = buildLocalSearchInnerContent(guidance, documents);
|
||||
|
||||
expect(inner).toBe("<guidance>Rules</guidance>\n\n<document>Doc</document>");
|
||||
});
|
||||
|
||||
it("returns empty string when both inputs are blank", () => {
|
||||
expect(buildLocalSearchInnerContent("", "")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapLocalSearchPayload", () => {
|
||||
it("wraps content with localSearch tag and preserves time range", () => {
|
||||
const content = "<guidance>Rules</guidance>\n\n<document>Doc</document>";
|
||||
|
||||
const wrapped = wrapLocalSearchPayload(content, "last week");
|
||||
|
||||
expect(wrapped).toBe(
|
||||
'<localSearch timeRange="last week">\n<guidance>Rules</guidance>\n\n<document>Doc</document>\n</localSearch>'
|
||||
);
|
||||
});
|
||||
|
||||
it("omits payload whitespace when content is empty", () => {
|
||||
expect(wrapLocalSearchPayload("", "")).toBe("<localSearch></localSearch>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderCiCMessage", () => {
|
||||
it("places context before question and adds label when requested", () => {
|
||||
const combined = renderCiCMessage("context block", "What?", true);
|
||||
|
||||
expect(combined).toBe("context block\n\nQuestion: What?");
|
||||
});
|
||||
|
||||
it("returns the original question when context is blank", () => {
|
||||
expect(renderCiCMessage(" \n ", "What?", false)).toBe("What?");
|
||||
});
|
||||
});
|
||||
});
|
||||
50
src/LLMProviders/chainRunner/utils/cicPromptUtils.ts
Normal file
50
src/LLMProviders/chainRunner/utils/cicPromptUtils.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Helper utilities for assembling Corpus-in-Context (CiC) ordered tool payloads.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Builds the ordered inner payload for a localSearch result, placing guidance before documents.
|
||||
* @param guidance Citation guidance or instructions associated with the search results.
|
||||
* @param formattedContent Serialized documents selected for inclusion.
|
||||
* @returns Combined payload string with minimal whitespace.
|
||||
*/
|
||||
export function buildLocalSearchInnerContent(guidance: string, formattedContent: string): string {
|
||||
const sections = [guidance, formattedContent]
|
||||
.map((section) => section?.trim())
|
||||
.filter((section): section is string => Boolean(section));
|
||||
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps localSearch payload content in XML, preserving optional time range metadata.
|
||||
* @param innerContent Ordered combination of guidance and documents.
|
||||
* @param timeExpression Optional natural-language time range expression.
|
||||
* @returns XML-wrapped localSearch payload ready for LLM consumption.
|
||||
*/
|
||||
export function wrapLocalSearchPayload(innerContent: string, timeExpression: string): string {
|
||||
const payload = innerContent ? `\n${innerContent}\n` : "";
|
||||
const timeAttribute = timeExpression ? ` timeRange="${timeExpression}"` : "";
|
||||
return `<localSearch${timeAttribute}>${payload}</localSearch>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a CiC-aligned prompt by placing context first and the user question last.
|
||||
* @param contextSection Prepared instruction/context block.
|
||||
* @param userQuestion Original user message.
|
||||
* @param labelQuestion Whether to prefix the question with a clarifying label.
|
||||
* @returns String formatted according to CiC ordering.
|
||||
*/
|
||||
export function renderCiCMessage(
|
||||
contextSection: string,
|
||||
userQuestion: string,
|
||||
labelQuestion: boolean
|
||||
): string {
|
||||
const contextBlock = contextSection.trim();
|
||||
if (!contextBlock) {
|
||||
return userQuestion;
|
||||
}
|
||||
|
||||
const questionBlock = labelQuestion ? `Question: ${userQuestion}` : userQuestion;
|
||||
return `${contextBlock}\n\n${questionBlock}`;
|
||||
}
|
||||
Loading…
Reference in a new issue