136 lines
5.2 KiB
TypeScript
136 lines
5.2 KiB
TypeScript
|
|
/**
|
|||
|
|
* 上下文构建(任务 A3.8,实施设计 1.3 / 1.5)。
|
|||
|
|
*
|
|||
|
|
* 目标:不把整段会话塞给模型。按预算取「最近若干轮 + 滚动摘要 + 钉住的引用」,
|
|||
|
|
* 工具执行只以引用形式进摘要(能力、状态、参数摘要、executionId),需要细节时再调能力取回。
|
|||
|
|
*
|
|||
|
|
* 三条约束:
|
|||
|
|
* 1. 摘要与原始消息都留在平台:摘要条目带来源消息 id,任何结论都能追回原文
|
|||
|
|
* 2. 构建是确定性的、可测的:这里不调模型(真正的模型摘要等 A4 之后按需替换)
|
|||
|
|
* 3. 引擎只拿到「上下文包」,不拿所有权(架构设计 5.1 固定点:状态归平台)
|
|||
|
|
*/
|
|||
|
|
import type { ChatMessage } from '@aps/contracts';
|
|||
|
|
|
|||
|
|
export interface ContextBudget {
|
|||
|
|
/** 上下文包总字符预算(粗略按字符算,够用且不引入 tokenizer 依赖) */
|
|||
|
|
maxChars: number;
|
|||
|
|
/** 至少保留的最近消息条数 */
|
|||
|
|
recentMessages: number;
|
|||
|
|
/** 摘要部分最多占多少字符 */
|
|||
|
|
summaryMaxChars: number;
|
|||
|
|
/** 摘要里最多引用多少条执行记录 */
|
|||
|
|
recentExecutions: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const DEFAULT_CONTEXT_BUDGET: ContextBudget = {
|
|||
|
|
maxChars: 6000,
|
|||
|
|
recentMessages: 6,
|
|||
|
|
summaryMaxChars: 1800,
|
|||
|
|
recentExecutions: 6,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/** 执行记录的引用(来自 Core,不进历史原文) */
|
|||
|
|
export interface ExecutionReference {
|
|||
|
|
capabilityId: string;
|
|||
|
|
status: string;
|
|||
|
|
executionId: string;
|
|||
|
|
paramsDigest: string;
|
|||
|
|
createdAt: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface ContextBuildInput {
|
|||
|
|
messages: ChatMessage[];
|
|||
|
|
executions?: ExecutionReference[];
|
|||
|
|
/** 用户钉住的消息:无论多早都保留(界面在 A10.4 提供入口) */
|
|||
|
|
pinnedMessageIds?: string[];
|
|||
|
|
budget?: Partial<ContextBudget>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface ContextBuildResult {
|
|||
|
|
/** 注入给引擎的上下文包;空串表示无需注入(例如会话刚开始) */
|
|||
|
|
prefix: string;
|
|||
|
|
/** 存进检查点的摘要(含来源消息 id,便于追溯) */
|
|||
|
|
summary: string;
|
|||
|
|
/** 摘要引用的消息 id */
|
|||
|
|
summarySourceIds: string[];
|
|||
|
|
/** 原样保留的最近消息 id */
|
|||
|
|
includedMessageIds: string[];
|
|||
|
|
/** 因预算被折叠进摘要之前的消息(原文仍在平台,未丢失) */
|
|||
|
|
droppedMessageIds: string[];
|
|||
|
|
estimatedChars: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const clip = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, max)}…(已截断)`);
|
|||
|
|
const roleLabel = (role: ChatMessage['role']): string => (role === 'user' ? '用户' : role === 'assistant' ? '助手' : '系统');
|
|||
|
|
|
|||
|
|
function renderExecution(reference: ExecutionReference): string {
|
|||
|
|
return `- 调用 ${reference.capabilityId} → ${reference.status}(${reference.executionId},参数摘要 ${reference.paramsDigest})`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function buildContext(input: ContextBuildInput): ContextBuildResult {
|
|||
|
|
const budget: ContextBudget = { ...DEFAULT_CONTEXT_BUDGET, ...(input.budget ?? {}) };
|
|||
|
|
const messages = input.messages;
|
|||
|
|
const pinned = new Set(input.pinnedMessageIds ?? []);
|
|||
|
|
|
|||
|
|
const kept: ChatMessage[] = [];
|
|||
|
|
const dropped: ChatMessage[] = [];
|
|||
|
|
let usedChars = 0;
|
|||
|
|
|
|||
|
|
// 从最近往前收:先保证「最近若干轮」,再按预算继续吃历史
|
|||
|
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|||
|
|
const message = messages[index];
|
|||
|
|
if (!message) continue;
|
|||
|
|
const cost = message.text.length + 12;
|
|||
|
|
const isRecent = messages.length - index <= budget.recentMessages;
|
|||
|
|
const mustKeep = isRecent || pinned.has(message.messageId);
|
|||
|
|
if (mustKeep || usedChars + cost <= budget.maxChars) {
|
|||
|
|
kept.unshift(message);
|
|||
|
|
usedChars += cost;
|
|||
|
|
} else {
|
|||
|
|
dropped.unshift(message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 摘要:折叠掉的消息取要点(保留前 80 字),附来源 id
|
|||
|
|
const summaryLines: string[] = [];
|
|||
|
|
let summaryChars = 0;
|
|||
|
|
for (const message of dropped) {
|
|||
|
|
const line = `- ${roleLabel(message.role)}(${message.messageId}):${clip(message.text.replace(/\s+/g, ' '), 80)}`;
|
|||
|
|
if (summaryChars + line.length > budget.summaryMaxChars) {
|
|||
|
|
summaryLines.push(`- ……另有 ${dropped.length - summaryLines.length} 条更早的消息,原始内容保留在平台会话记录里`);
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
summaryLines.push(line);
|
|||
|
|
summaryChars += line.length;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const executionLines = (input.executions ?? [])
|
|||
|
|
.slice(0, budget.recentExecutions)
|
|||
|
|
.map(renderExecution);
|
|||
|
|
|
|||
|
|
const blocks: string[] = [];
|
|||
|
|
if (summaryLines.length > 0 || executionLines.length > 0) {
|
|||
|
|
blocks.push('【此前对话的摘要】');
|
|||
|
|
blocks.push(...summaryLines);
|
|||
|
|
if (executionLines.length > 0) {
|
|||
|
|
blocks.push('【相关工具执行(只给引用,需要细节请调对应能力)】');
|
|||
|
|
blocks.push(...executionLines);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (kept.length > 0) {
|
|||
|
|
blocks.push('【最近的对话】');
|
|||
|
|
for (const message of kept) blocks.push(`${roleLabel(message.role)}:${message.text}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const prefix = blocks.length === 0 ? '' : `${blocks.join('\n')}\n【以上是背景,以下是用户这次的问题】\n`;
|
|||
|
|
const summary = summaryLines.join('\n');
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
prefix,
|
|||
|
|
summary,
|
|||
|
|
summarySourceIds: dropped.map((message) => message.messageId),
|
|||
|
|
includedMessageIds: kept.map((message) => message.messageId),
|
|||
|
|
droppedMessageIds: dropped.map((message) => message.messageId),
|
|||
|
|
estimatedChars: prefix.length,
|
|||
|
|
};
|
|||
|
|
}
|