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,
|
||
};
|
||
} |