Files
AIclinicalresearch/backend/src/modules/ssa/services/TokenTruncationService.ts
HaHafeng 3446909ff7 feat(ssa): Complete Phase I-IV intelligent dialogue and tool system development
Phase I - Session Blackboard + READ Layer:
- SessionBlackboardService with Postgres-Only cache
- DataProfileService for data overview generation
- PicoInferenceService for LLM-driven PICO extraction
- Frontend DataContextCard and VariableDictionaryPanel
- E2E tests: 31/31 passed

Phase II - Conversation Layer LLM + Intent Router:
- ConversationService with SSE streaming
- IntentRouterService (rule-first + LLM fallback, 6 intents)
- SystemPromptService with 6-segment dynamic assembly
- TokenTruncationService for context management
- ChatHandlerService as unified chat entry
- Frontend SSAChatPane and useSSAChat hook
- E2E tests: 38/38 passed

Phase III - Method Consultation + AskUser Standardization:
- ToolRegistryService with Repository Pattern
- MethodConsultService with DecisionTable + LLM enhancement
- AskUserService with global interrupt handling
- Frontend AskUserCard component
- E2E tests: 13/13 passed

Phase IV - Dialogue-Driven Analysis + QPER Integration:
- ToolOrchestratorService (plan/execute/report)
- analysis_plan SSE event for WorkflowPlan transmission
- Dual-channel confirmation (ask_user card + workspace button)
- PICO as optional hint for LLM parsing
- E2E tests: 25/25 passed

R Statistics Service:
- 5 new R tools: anova_one, baseline_table, fisher, linear_reg, wilcoxon
- Enhanced guardrails and block helpers
- Comprehensive test suite (run_all_tools_test.js)

Documentation:
- Updated system status document (v5.9)
- Updated SSA module status and development plan (v1.8)

Total E2E: 107/107 passed (Phase I: 31, Phase II: 38, Phase III: 13, Phase IV: 25)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-22 18:53:39 +08:00

205 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Phase I — Token 截断服务
*
* 在将 SessionBlackboard 数据注入 LLM Prompt 之前,
* 按优先级策略裁剪 payload 以适配模型上下文窗口。
*
* 裁剪策略(按优先级从低到高保留):
* 1. 完整变量字典 → 仅保留非 isIdLike 的变量
* 2. topValues 列表 → 截断到 top 5
* 3. 数值列详细统计 → 保留 mean/std/median + 去掉 skewness/kurtosis
* 4. normalityTests → 仅保留非正态的变量
* 5. picoInference → 始终保留(最高优先级)
* 6. fiveSectionReport.content → 若超限则截断到前 500 字符
*
* 预估 token 使用简易方式: 1 中文字 ≈ 2 tokens, 1 英文词 ≈ 1.3 tokens
* 通过 JSON.stringify 长度 / 2 作为粗略上界。
*/
import { logger } from '../../../common/logging/index.js';
import type {
SessionBlackboard,
DataOverview,
VariableDictEntry,
FiveSectionReport,
} from '../types/session-blackboard.types.js';
export interface TruncationOptions {
maxTokens?: number;
strategy?: 'aggressive' | 'balanced' | 'minimal';
}
interface TruncatedContext {
overview: string;
variables: string;
pico: string;
report: string;
estimatedTokens: number;
}
const DEFAULT_MAX_TOKENS = 3000;
export class TokenTruncationService {
/**
* 将 SessionBlackboard 截断为可注入 Prompt 的紧凑文本。
*/
truncate(
blackboard: SessionBlackboard,
options: TruncationOptions = {},
): TruncatedContext {
const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
const strategy = options.strategy ?? 'balanced';
logger.debug('[SSA:TokenTrunc] Truncating context', {
sessionId: blackboard.sessionId,
maxTokens,
strategy,
});
const pico = this.formatPico(blackboard);
const overview = this.formatOverview(blackboard.dataOverview, strategy);
const variables = this.formatVariables(blackboard.variableDictionary, strategy);
const report = this.formatReport(blackboard, strategy);
let ctx: TruncatedContext = {
pico,
overview,
variables,
report,
estimatedTokens: 0,
};
ctx.estimatedTokens = this.estimateTokens(ctx);
if (ctx.estimatedTokens > maxTokens) {
ctx = this.applyAggressiveTruncation(ctx, blackboard, maxTokens);
}
logger.debug('[SSA:TokenTrunc] Truncation complete', {
estimatedTokens: ctx.estimatedTokens,
maxTokens,
});
return ctx;
}
/**
* 一次性生成可直接拼入 system prompt 的字符串。
*/
toPromptString(ctx: TruncatedContext): string {
const parts: string[] = [];
if (ctx.pico) parts.push(`## PICO 结构\n${ctx.pico}`);
if (ctx.overview) parts.push(`## 数据概览\n${ctx.overview}`);
if (ctx.variables) parts.push(`## 变量列表\n${ctx.variables}`);
if (ctx.report) parts.push(`## 数据诊断摘要\n${ctx.report}`);
return parts.join('\n\n');
}
private formatPico(bb: SessionBlackboard): string {
const p = bb.picoInference;
if (!p) return '';
const lines = [];
if (p.population) lines.push(`P (人群): ${p.population}`);
if (p.intervention) lines.push(`I (干预): ${p.intervention}`);
if (p.comparison) lines.push(`C (对照): ${p.comparison}`);
if (p.outcome) lines.push(`O (结局): ${p.outcome}`);
return lines.join('\n');
}
private formatOverview(ov: DataOverview | null, strategy: string): string {
if (!ov) return '';
const s = ov.profile.summary;
let text = `${s.totalRows}× ${s.totalColumns} 列, 缺失率 ${s.overallMissingRate}%, 完整病例 ${ov.completeCaseCount}`;
if (strategy !== 'aggressive' && ov.normalityTests?.length) {
const nonNormal = ov.normalityTests.filter(t => !t.isNormal).map(t => t.variable);
if (nonNormal.length > 0) {
text += `\n非正态: ${nonNormal.join(', ')}`;
}
}
return text;
}
private formatVariables(dict: VariableDictEntry[], strategy: string): string {
let vars = dict.filter(v => !v.isIdLike);
if (strategy === 'aggressive') {
vars = vars.slice(0, 15);
}
return vars.map(v => {
const type = v.confirmedType ?? v.inferredType;
const label = v.label ? ` "${v.label}"` : '';
const role = v.picoRole ? ` [${v.picoRole}]` : '';
return `- ${v.name}: ${type}${label}${role}`;
}).join('\n');
}
private formatReport(bb: SessionBlackboard, strategy: string): string {
const report = bb.dataOverview
? this.buildReportSummary(bb.dataOverview)
: '';
if (strategy === 'aggressive' && report.length > 500) {
return report.slice(0, 500) + '...';
}
return report;
}
private buildReportSummary(ov: DataOverview): string {
const s = ov.profile.summary;
const lines: string[] = [];
const missingCols = ov.profile.columns.filter(c => c.missingCount > 0);
if (missingCols.length > 0) {
lines.push(`缺失变量(${missingCols.length}): ${missingCols.map(c => c.name).join(', ')}`);
}
const outlierCols = ov.profile.columns.filter(c => (c as any).outlierCount > 0);
if (outlierCols.length > 0) {
lines.push(`异常值变量(${outlierCols.length}): ${outlierCols.map(c => c.name).join(', ')}`);
}
const catCount = s.categoricalColumns;
const numCount = s.numericColumns;
lines.push(`类型: 数值${numCount} + 分类${catCount}`);
return lines.join('\n');
}
private estimateTokens(ctx: TruncatedContext): number {
const total = ctx.pico.length + ctx.overview.length + ctx.variables.length + ctx.report.length;
return Math.ceil(total / 2);
}
private applyAggressiveTruncation(
ctx: TruncatedContext,
bb: SessionBlackboard,
maxTokens: number,
): TruncatedContext {
const result = { ...ctx };
result.report = result.report.length > 300 ? result.report.slice(0, 300) + '...' : result.report;
let vars = bb.variableDictionary.filter(v => !v.isIdLike);
if (vars.length > 10) {
const picoVars = vars.filter(v => v.picoRole);
const others = vars.filter(v => !v.picoRole).slice(0, 10 - picoVars.length);
vars = [...picoVars, ...others];
}
result.variables = vars.map(v => {
const type = v.confirmedType ?? v.inferredType;
return `- ${v.name}: ${type}`;
}).join('\n');
result.estimatedTokens = this.estimateTokens(result);
return result;
}
}
export const tokenTruncationService = new TokenTruncationService();