feat(平台): 补齐服务端持久化和AI拆解契约

This commit is contained in:
Script Generator
2026-06-25 15:21:32 +08:00
parent 5723356d08
commit 5adc7759ad
73 changed files with 5599 additions and 368 deletions

View File

@@ -0,0 +1,37 @@
import { Injectable, Logger } from '@nestjs/common';
import type { AiProvider } from './providers/provider.interface';
import { createProvider } from './providers/factory';
import { AiConfigService } from '../config/ai-config.service';
/**
* AI Gateway — 屏蔽底层 Provider对上层 AiService 提供统一 callTool 接口
* 激活的 Provider 切换 / Key 改变时自动重建客户端
*/
@Injectable()
export class AiGatewayService {
private readonly logger = new Logger(AiGatewayService.name);
private cachedProvider: AiProvider | null = null;
private cachedKey: string = '';
constructor(private readonly config: AiConfigService) {}
async getActiveProvider(): Promise<AiProvider> {
const cfg = await this.config.getActiveProvider();
if (!cfg) {
throw new Error('未配置任何 AI 提供商请在「AI 配置」页面新增并激活一个');
}
const sig = `${cfg.format}:${cfg.baseURL}:${cfg.apiKey}`;
if (!this.cachedProvider || this.cachedKey !== sig) {
this.cachedProvider = createProvider(cfg);
this.cachedKey = sig;
this.logger.log(`Provider 切换到 ${cfg.name} (${cfg.format})`);
}
return this.cachedProvider;
}
async getActiveModel(): Promise<string> {
const cfg = await this.config.getActiveProvider();
if (!cfg) throw new Error('未配置任何 AI 提供商');
return cfg.model;
}
}

View File

@@ -0,0 +1,14 @@
import { Body, Controller, Post } from '@nestjs/common';
import { AiService } from './ai.service';
import { DecomposeDto } from './dto/decompose.dto';
import type { AgentDecomposeResponse, AgentDecomposeError } from '@ftb/shared';
@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
@Post('decompose')
async decompose(@Body() dto: DecomposeDto): Promise<AgentDecomposeResponse | AgentDecomposeError> {
return this.aiService.decompose(dto);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
import { AiGatewayService } from './ai-gateway.service';
import { ConfigModule } from '../config/config.module';
@Module({
imports: [ConfigModule],
controllers: [AiController],
providers: [AiService, AiGatewayService],
exports: [AiService],
})
export class AiModule {}

View File

@@ -0,0 +1,155 @@
import { AiService } from './ai.service';
import type { AiGatewayService } from './ai-gateway.service';
import { DECOMPOSE_TOOL_INPUT_SCHEMA } from './prompts/decompose';
const missingToolUseMessage =
'Anthropic 未通过 tool_use 返回结果stop_reason=tool_usecontent=text("⚠️ 上游模型未返回任何内容。可能原因:触发了安全策略、上游限流、或模型对当前输入直接结束。")';
describe('AiService', () => {
it('retries once with shorter prototype context when Anthropic returns no tool_use', async () => {
const callTool = jest
.fn()
.mockRejectedValueOnce(new Error(missingToolUseMessage))
.mockResolvedValueOnce({
toolName: 'submit_decompose',
toolInput: {
report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] },
devTaskDrafts: [],
testCaseDrafts: [],
},
inputTokens: 100,
outputTokens: 20,
rawModel: 'claude-test',
});
const gateway = {
getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
getActiveModel: jest.fn().mockResolvedValue('claude-test'),
} as unknown as AiGatewayService;
const service = new AiService(gateway);
(service as any).fetchPrototype = jest.fn().mockResolvedValue(
`${'无关内容'.repeat(5000)} QY0001手机号登录。${'噪声内容'.repeat(5000)}`,
);
const result = await service.decompose({
prototypeUrl: 'https://example.com/prototype',
requirements: [{ id: 'req-1', code: 'REQ001', title: '手机号登录' }],
members: [{ name: '张三', role: 'frontend' }],
versionId: 'version-1',
planId: 'plan-1',
});
expect(result.ok).toBe(true);
expect(callTool).toHaveBeenCalledTimes(2);
const firstPrompt = callTool.mock.calls[0][0].userPrompt;
const retryPrompt = callTool.mock.calls[1][0].userPrompt;
expect(retryPrompt.length).toBeLessThan(firstPrompt.length);
expect(retryPrompt).toContain('重试');
});
it('keeps shrinking prototype context when the first retry still returns no tool_use', async () => {
const callTool = jest
.fn()
.mockRejectedValueOnce(new Error(missingToolUseMessage))
.mockRejectedValueOnce(new Error(missingToolUseMessage))
.mockResolvedValueOnce({
toolName: 'submit_decompose',
toolInput: {
report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] },
devTaskDrafts: [],
testCaseDrafts: [],
},
inputTokens: 100,
outputTokens: 20,
rawModel: 'claude-test',
});
const gateway = {
getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
getActiveModel: jest.fn().mockResolvedValue('claude-test'),
} as unknown as AiGatewayService;
const service = new AiService(gateway);
(service as any).fetchPrototype = jest.fn().mockResolvedValue(
Array.from({ length: 30 }, (_, i) => `QY${String(i + 1).padStart(4, '0')}${'内容'.repeat(300)}`).join('\n'),
);
const result = await service.decompose({
prototypeUrl: 'https://example.com/prototype',
requirements: [{ id: 'req-1', code: 'REQ001', title: '手机号登录' }],
members: [{ name: '张三', role: 'frontend' }],
versionId: 'version-1',
planId: 'plan-1',
});
expect(result.ok).toBe(true);
expect(callTool).toHaveBeenCalledTimes(3);
const firstPrompt = callTool.mock.calls[0][0].userPrompt;
const secondPrompt = callTool.mock.calls[1][0].userPrompt;
const thirdPrompt = callTool.mock.calls[2][0].userPrompt;
expect(secondPrompt.length).toBeLessThan(firstPrompt.length);
expect(thirdPrompt.length).toBeLessThan(secondPrompt.length);
});
it('retries prototype fetch before returning fetch failure', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = jest
.fn()
.mockRejectedValueOnce(new Error('fetch failed'))
.mockResolvedValueOnce({
ok: true,
text: async () => '<html><body>QY0001手机号登录</body></html>',
});
(globalThis as any).fetch = fetchMock;
const callTool = jest.fn().mockResolvedValue({
toolName: 'submit_decompose',
toolInput: {
report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] },
devTaskDrafts: [],
testCaseDrafts: [],
},
inputTokens: 100,
outputTokens: 20,
rawModel: 'claude-test',
});
const gateway = {
getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
getActiveModel: jest.fn().mockResolvedValue('claude-test'),
} as unknown as AiGatewayService;
const service = new AiService(gateway);
try {
const result = await service.decompose({
prototypeUrl: 'https://example.com/prototype',
requirements: [{ id: 'req-1', code: 'REQ001', title: '手机登录' }],
members: [{ name: '张三', role: 'frontend' }],
versionId: 'version-1',
planId: 'plan-1',
});
expect(result.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(callTool).toHaveBeenCalledTimes(1);
} finally {
(globalThis as any).fetch = originalFetch;
}
});
it('requires categoryCode for dev task and test case drafts', () => {
const devRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items.required;
const testCaseRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.required;
expect(devRequired).toContain('categoryCode');
expect(testCaseRequired).toContain('categoryCode');
});
it('requires estimateHours for test case drafts', () => {
const testCaseRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.required;
expect(testCaseRequired).toContain('estimateHours');
});
});

View File

@@ -0,0 +1,223 @@
import { Injectable, Logger } from '@nestjs/common';
import { AiGatewayService } from './ai-gateway.service';
import {
DECOMPOSE_SYSTEM_PROMPT,
DECOMPOSE_TOOL_NAME,
DECOMPOSE_TOOL_DESCRIPTION,
DECOMPOSE_TOOL_INPUT_SCHEMA,
} from './prompts/decompose';
import { buildPrototypeContext } from './prototype-context';
import type {
AgentDecomposeRequest,
AgentDecomposeResponse,
AgentDecomposeError,
AgentDecomposeResult,
} from '@ftb/shared';
const DECOMPOSE_CONTEXT_CHAR_STEPS = [1800, 1200, 800] as const;
const PROTOTYPE_FETCH_TIMEOUT_MS = 15000;
const PROTOTYPE_FETCH_ATTEMPTS = 3;
const PROTOTYPE_FETCH_RETRY_DELAY_MS = 500;
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
constructor(private readonly gateway: AiGatewayService) {}
async decompose(req: AgentDecomposeRequest): Promise<AgentDecomposeResponse | AgentDecomposeError> {
const startedAt = Date.now();
if (!req.requirements || req.requirements.length === 0) {
return {
ok: false,
error: '本版本未关联任何需求,无法进行原型拆解',
code: 'EMPTY_REQUIREMENTS',
};
}
let prototypeContent: string;
try {
prototypeContent = await this.fetchPrototype(req.prototypeUrl);
} catch (e: any) {
this.logger.error(`抓取原型失败: ${e.message}`);
return {
ok: false,
error: `无法访问原型链接:${e.message}`,
code: 'PROTOTYPE_FETCH_FAILED',
};
}
let provider;
let model: string;
try {
provider = await this.gateway.getActiveProvider();
model = await this.gateway.getActiveModel();
} catch (e: any) {
return {
ok: false,
error: e?.message || '未配置 AI 提供商',
code: 'NO_PROVIDER',
};
}
let toolResp;
for (let attemptIndex = 0; attemptIndex < DECOMPOSE_CONTEXT_CHAR_STEPS.length; attemptIndex++) {
const contextChars = DECOMPOSE_CONTEXT_CHAR_STEPS[attemptIndex];
const prototypeContext = buildPrototypeContext(prototypeContent, contextChars);
const userPrompt = this.buildUserPrompt(req, prototypeContext.text, prototypeContext, attemptIndex > 0);
try {
toolResp = await provider.callTool({
systemPrompt: DECOMPOSE_SYSTEM_PROMPT,
userPrompt,
tool: {
name: DECOMPOSE_TOOL_NAME,
description: DECOMPOSE_TOOL_DESCRIPTION,
inputSchema: DECOMPOSE_TOOL_INPUT_SCHEMA,
},
forceTool: true,
maxTokens: 16000,
model,
});
break;
} catch (e: any) {
const canRetry = this.shouldRetryWithShortContext(e) && attemptIndex < DECOMPOSE_CONTEXT_CHAR_STEPS.length - 1;
if (canRetry) {
this.logger.warn(
`AI 第 ${attemptIndex + 1} 次调用未返回 tool_use使用 ${DECOMPOSE_CONTEXT_CHAR_STEPS[attemptIndex + 1]} 字符原型上下文重试: ${e.message}`,
);
continue;
}
this.logger.error(`AI 调用失败: ${e.message}`);
return {
ok: false,
error: `AI 服务调用失败:${e.message}`,
code: 'API_ERROR',
};
}
}
if (!toolResp) {
return {
ok: false,
error: 'AI 服务调用失败:未获得工具结果',
code: 'UNKNOWN',
};
}
const result = toolResp.toolInput as AgentDecomposeResult;
if (!result.report || !Array.isArray(result.devTaskDrafts) || !Array.isArray(result.testCaseDrafts)) {
return {
ok: false,
error: 'AI 返回结构不符合预期',
code: 'PARSE_ERROR',
};
}
return {
ok: true,
result,
meta: {
model: toolResp.rawModel || model,
inputTokens: toolResp.inputTokens,
outputTokens: toolResp.outputTokens,
durationMs: Date.now() - startedAt,
},
};
}
private async fetchPrototype(url: string): Promise<string> {
if (!url || !url.startsWith('http')) {
throw new Error('原型链接无效');
}
let lastError: unknown;
for (let attempt = 1; attempt <= PROTOTYPE_FETCH_ATTEMPTS; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), PROTOTYPE_FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const html = await res.text();
return this.htmlToText(html);
} catch (e) {
lastError = e;
if (attempt < PROTOTYPE_FETCH_ATTEMPTS) {
this.logger.warn(`抓取原型失败,准备重试(${attempt}/${PROTOTYPE_FETCH_ATTEMPTS}): ${(e as Error).message}`);
await this.sleep(PROTOTYPE_FETCH_RETRY_DELAY_MS * attempt);
}
} finally {
clearTimeout(timeoutId);
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError));
}
private htmlToText(html: string): string {
return html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/\s+/g, ' ')
.trim();
}
private shouldRetryWithShortContext(error: any): boolean {
const msg = error?.message || '';
return (
msg.includes('Anthropic 未通过 tool_use 返回结果') &&
(msg.includes('上游模型未返回任何内容') || msg.includes('stop_reason=tool_use'))
);
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private buildUserPrompt(
req: AgentDecomposeRequest,
prototypeContent: string,
context?: { noteCount: number; truncated: boolean },
isRetry = false,
): string {
const reqList = req.requirements
.map(
(r) =>
`- id: ${r.id}, code: ${r.code}, title: ${r.title}${r.description ? `, desc: ${r.description}` : ''}`,
)
.join('\n');
const memberList = req.members.map((m) => `- ${m.name} (${m.role})`).join('\n');
return `${isRetry ? '## 重试说明\n上一次 AI 服务未返回工具结果,本次已缩短原型上下文。仍然必须只通过 submit_decompose 工具返回结果。\n\n' : ''}## 产品方案原型
URL: ${req.prototypeUrl}
### 原型内容(已提取 QY 批注相关片段)
- 识别到 QY 批注数量:${context?.noteCount ?? 0}
- 内容已截断:${context?.truncated ? '是' : '否'}
${prototypeContent}
## 本版本关联需求
${reqList || '(无)'}
## 版本成员
${memberList || '(无)'}
## 你的任务
按系统提示词的规则,拆解出开发任务草案、测试用例草案、对账报告。
通过 tool 调用 submit_decompose 返回结果。`;
}
}

View File

@@ -0,0 +1,46 @@
import { IsString, IsArray, ValidateNested, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
export class DecomposeReqMemberDto {
@IsString()
name!: string;
@IsString()
role!: string;
}
export class DecomposeReqRequirementDto {
@IsString()
id!: string;
@IsString()
code!: string;
@IsString()
title!: string;
@IsString()
@IsOptional()
description?: string;
}
export class DecomposeDto {
@IsString()
prototypeUrl!: string;
@IsString()
versionId!: string;
@IsString()
planId!: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => DecomposeReqRequirementDto)
requirements!: DecomposeReqRequirementDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => DecomposeReqMemberDto)
members!: DecomposeReqMemberDto[];
}

View File

@@ -0,0 +1,186 @@
/**
* Prototype Decompose Agent — System Prompt + Tool Schema
* 详细规范见 docs/agent-spec.md
*
* 注意:这里的 Tool Schema 是格式无关的 JSON Schema
* 由各 Provider 自己包成 Anthropic 的 input_schema 或 OpenAI 的 parameters
*/
export const DECOMPOSE_SYSTEM_PROMPT = `你是 FTB 项目管理系统的产品方案拆解助手。
【职责】
输入:原型 HTML/文档内容 + 关联需求列表 + 版本成员清单
输出:开发任务草案 + 测试用例草案 + 对账报告
【硬规则】
1. 引用必须真实
- 引用 requirement.id 必须在输入需求清单中
- 引用 prototype_note 必须是输入原型里真实存在的 QY 编号
- 不得编造
2. 任务来源限定
- 只为以下情况拆任务:
a) 同时被需求和原型 QY 命中
b) 仅需求命中(原型未涉及,按需求文字拆,但工时设小,标记需要后期补充)
- 仅 QY 命中、需求未提的批注,不拆任务,只在 noteOnly 报告里列出
3. 颗粒度(细颗粒)
- 一条 QY 涉及前后端时,前端任务和后端任务必须分开
- 接口、数据库改动、前端 UI、前端交互、表单校验视为独立任务
- 一条 QY 可能产出 3-6 个 DevTask
- 测试用例:每条 QY 至少 1 条功能用例 + 1 条边界用例
4. 任务类型
- 每条开发任务和测试用例都必须输出 categoryCode
- 开发任务优先使用 frontend_development / frontend_interaction / backend_development / backend_api / database_schema / api_integration
- 测试用例优先使用 test_functional / test_api / test_exception / test_compatibility
- 不输出数据库 categoryId
- 不输出推荐负责人(用户后续手填)
5. 工时估算(小时,按团队使用 AI 辅助研发/测试估算,必须偏严格)
- 简单前端字段、文案、展示调整: 0.25-0.5h
- 简单前端交互,如拖拽排序 UI、开关、筛选项: 0.5-1h
- 拖拽排序并需要持久化接口: 1-1.5h
- 简单 CRUD 接口: 0.75-1.5h
- 数据库字段/索引调整: 0.5h
- 中等业务规则变更: 1.5-3h
- 简单功能测试用例执行: 0.25-0.5h
- API/异常/兼容性测试用例执行: 0.5-1h
- 只有跨端同步、复杂权限、历史数据迁移、强一致性、复杂兼容性时,才允许超过上述区间
6. 标题:中文动词开头,简洁
✓ "在主题列表实现拖拽排序"
✗ "关于 QY0010 主题拖拽排序的优化方案研究与实现"
7. 不凭空补
- 不要因为"通常应该有"就加"权限校验"任务
- 只拆需求和原型上明确存在的内容
【对账报告要求】
- matched: 完美对应(哪条需求 ↔ 哪些 QY ↔ 拆出多少任务)
- reqOnly: 需求里有但原型未见 → 列出需求 ID
- noteOnly: 原型里有但需求未提 → 列出 QY 编号
- ambiguous: QY 描述含糊无法转化 → 列出 QY 编号 + 含糊原因
【特殊情况】
- 若原型内容里看不到任何 QY 编号或类似的批注编号 → ambiguous 列表里标注"原型内容无可识别的批注,可能不是 PRD/原型文档"devTaskDrafts/testCaseDrafts 返回空数组
- 若原型完全无法解析 → 同上处理
【输出通道强制要求】
- 第一块响应内容必须是 submit_decompose 的 tool_use
- 不要输出任何自然语言说明、Markdown、分析过程或前置文本
- 即使无法解析原型,也必须通过 submit_decompose 返回空数组和 ambiguous 报告
通过 tool 调用 submit_decompose 工具返回结果。`;
export const DECOMPOSE_TOOL_NAME = 'submit_decompose';
export const DECOMPOSE_TOOL_DESCRIPTION = '提交原型拆解结果(开发任务草案 + 测试用例草案 + 对账报告)';
export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
type: 'object',
properties: {
report: {
type: 'object',
properties: {
matched: {
type: 'array',
items: {
type: 'object',
properties: {
reqId: { type: 'string' },
noteIds: { type: 'array', items: { type: 'string' } },
taskCount: { type: 'integer' },
},
required: ['reqId', 'noteIds', 'taskCount'],
},
},
reqOnly: { type: 'array', items: { type: 'string' } },
noteOnly: { type: 'array', items: { type: 'string' } },
ambiguous: {
type: 'array',
items: {
type: 'object',
properties: {
noteId: { type: 'string' },
reason: { type: 'string' },
},
required: ['noteId', 'reason'],
},
},
},
required: ['matched', 'reqOnly', 'noteOnly', 'ambiguous'],
},
devTaskDrafts: {
type: 'array',
items: {
type: 'object',
properties: {
title: { type: 'string' },
description: { type: 'string' },
categoryCode: {
type: 'string',
enum: [
'frontend_development',
'frontend_interaction',
'backend_development',
'backend_api',
'database_schema',
'api_integration',
'data_processing',
'implementation_support',
'documentation',
],
},
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
estimateHours: { type: 'number' },
references: {
type: 'array',
items: {
type: 'object',
properties: {
type: { type: 'string', enum: ['requirement', 'prototype_note'] },
id: { type: 'string' },
label: { type: 'string' },
},
required: ['type', 'id', 'label'],
},
minItems: 1,
},
},
required: ['title', 'categoryCode', 'priority', 'estimateHours', 'references'],
},
},
testCaseDrafts: {
type: 'array',
items: {
type: 'object',
properties: {
title: { type: 'string' },
description: { type: 'string' },
categoryCode: {
type: 'string',
enum: ['test_functional', 'test_api', 'test_exception', 'test_compatibility'],
},
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
estimateHours: { type: 'number' },
references: {
type: 'array',
items: {
type: 'object',
properties: {
type: { type: 'string', enum: ['requirement', 'prototype_note'] },
id: { type: 'string' },
label: { type: 'string' },
},
required: ['type', 'id', 'label'],
},
minItems: 1,
},
},
required: ['title', 'description', 'categoryCode', 'priority', 'estimateHours', 'references'],
},
},
},
required: ['report', 'devTaskDrafts', 'testCaseDrafts'],
};

View File

@@ -0,0 +1,27 @@
import { buildPrototypeContext } from './prototype-context';
describe('buildPrototypeContext', () => {
it('keeps QY note snippets and drops unrelated long text', () => {
const longPrefix = '无关说明'.repeat(2000);
const longSuffix = '页面噪声'.repeat(2000);
const content = `${longPrefix} QY0007支持手机号登录需要验证码输入和倒计时。${longSuffix}`;
const context = buildPrototypeContext(content, 800);
expect(context.text).toContain('QY0007');
expect(context.text).toContain('手机号登录');
expect(context.text).not.toContain(longPrefix.slice(0, 200));
expect(context.text.length).toBeLessThanOrEqual(900);
expect(context.noteCount).toBe(1);
});
it('merges overlapping QY snippets instead of duplicating the same content', () => {
const content = `${'P'.repeat(160)} QY0001 这里是第一个批注 ${'A'.repeat(180)} QY0002 这里是第二个批注 ${'B'.repeat(300)}`;
const context = buildPrototypeContext(content, 1000);
expect(context.text).toContain('QY0001');
expect(context.text).toContain('QY0002');
expect(context.text.length).toBeLessThan(800);
});
});

View File

@@ -0,0 +1,72 @@
export interface PrototypeContext {
text: string;
noteCount: number;
truncated: boolean;
}
const QY_NOTE_PATTERN = /QY\d{3,}/gi;
const MAX_NOTE_SNIPPETS = 40;
export function buildPrototypeContext(content: string, maxChars = 12000): PrototypeContext {
const normalized = content.replace(/\s+/g, ' ').trim();
const matches = Array.from(normalized.matchAll(QY_NOTE_PATTERN));
if (matches.length === 0) {
return {
text: normalized.slice(0, maxChars),
noteCount: 0,
truncated: normalized.length > maxChars,
};
}
const windowSize = Math.max(280, Math.floor(maxChars / Math.min(matches.length, 12)));
const ranges = matches.slice(0, MAX_NOTE_SNIPPETS)
.map((match) => {
const index = match.index ?? 0;
return {
start: Math.max(0, index - 120),
end: Math.min(normalized.length, index + windowSize),
};
})
.sort((a, b) => a.start - b.start);
const mergedRanges: Array<{ start: number; end: number }> = [];
for (const range of ranges) {
const last = mergedRanges[mergedRanges.length - 1];
if (!last || range.start > last.end) {
mergedRanges.push({ ...range });
} else {
last.end = Math.max(last.end, range.end);
}
}
const snippets: string[] = [];
let usedChars = 0;
let truncated = matches.length > MAX_NOTE_SNIPPETS;
for (const range of mergedRanges) {
const separatorLength = snippets.length > 0 ? '\n\n---\n\n'.length : 0;
const remaining = maxChars - usedChars - separatorLength;
if (remaining <= 0) {
truncated = true;
break;
}
const snippet = normalized.slice(range.start, range.end).trim();
if (snippet.length > remaining) {
snippets.push(snippet.slice(0, remaining).trim());
usedChars = maxChars;
truncated = true;
break;
}
snippets.push(snippet);
usedChars += separatorLength + snippet.length;
}
const text = snippets.join('\n\n---\n\n');
return {
text,
noteCount: matches.length,
truncated,
};
}

View File

@@ -0,0 +1,106 @@
import { AnthropicProvider } from './anthropic.provider';
const mockCreate = jest.fn();
jest.mock('@anthropic-ai/sdk', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => ({
messages: {
create: mockCreate,
},
})),
}));
describe('AnthropicProvider', () => {
beforeEach(() => {
mockCreate.mockReset();
});
it('reports stop reason and text preview when no tool_use block is returned', async () => {
mockCreate.mockResolvedValue({
stop_reason: 'end_turn',
model: 'claude-test',
content: [
{
type: 'text',
text: '我理解你的意思,但这里没有可识别的 QY 编号,所以我先用自然语言解释。',
},
],
usage: {
input_tokens: 12,
output_tokens: 34,
},
});
const provider = new AnthropicProvider('test-key', 'https://example.test');
await expect(
provider.callTool({
systemPrompt: '只通过工具返回',
userPrompt: '拆解这个原型',
tool: {
name: 'submit_decompose',
description: '提交拆解结果',
inputSchema: {
type: 'object',
properties: { ok: { type: 'boolean' } },
required: ['ok'],
},
},
forceTool: true,
maxTokens: 1000,
model: 'claude-test',
}),
).rejects.toThrow(
/Anthropic 未通过 tool_use 返回结果.*stop_reason=end_turn.*content=text\("我理解你的意思/,
);
});
it('disables parallel tool use when a specific tool is forced', async () => {
mockCreate.mockResolvedValue({
stop_reason: 'tool_use',
model: 'claude-test',
content: [
{
type: 'tool_use',
id: 'toolu_test',
name: 'submit_decompose',
input: { ok: true },
},
],
usage: {
input_tokens: 12,
output_tokens: 34,
},
});
const provider = new AnthropicProvider('test-key', 'https://example.test');
await provider.callTool({
systemPrompt: '只通过工具返回',
userPrompt: '拆解这个原型',
tool: {
name: 'submit_decompose',
description: '提交拆解结果',
inputSchema: {
type: 'object',
properties: { ok: { type: 'boolean' } },
required: ['ok'],
},
},
forceTool: true,
maxTokens: 1000,
model: 'claude-test',
});
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
tool_choice: {
type: 'tool',
name: 'submit_decompose',
disable_parallel_tool_use: true,
},
}),
);
});
});

View File

@@ -0,0 +1,88 @@
import Anthropic from '@anthropic-ai/sdk';
import type { AiProvider, PingRequest, PingResponse, ToolCallRequest, ToolCallResponse } from './provider.interface';
/**
* Anthropic 格式提供商(官方 API + 兼容 Anthropic 格式的中转站)
*/
export class AnthropicProvider implements AiProvider {
readonly format = 'anthropic' as const;
private client: Anthropic;
constructor(apiKey: string, baseURL?: string) {
this.client = new Anthropic({
apiKey,
...(baseURL && baseURL !== 'https://api.anthropic.com' ? { baseURL } : {}),
});
}
async callTool(req: ToolCallRequest): Promise<ToolCallResponse> {
const toolChoice = req.forceTool
? ({
type: 'tool',
name: req.tool.name,
disable_parallel_tool_use: true,
} as any)
: { type: 'auto' as const };
const response = await this.client.messages.create({
model: req.model,
max_tokens: req.maxTokens,
system: req.systemPrompt,
tools: [
{
name: req.tool.name,
description: req.tool.description,
input_schema: req.tool.inputSchema,
},
],
tool_choice: toolChoice,
messages: [{ role: 'user', content: req.userPrompt }],
});
const toolUse = response.content.find(
(c): c is Anthropic.ToolUseBlock => c.type === 'tool_use',
);
if (!toolUse) {
throw new Error(this.describeMissingToolUse(response));
}
return {
toolName: toolUse.name,
toolInput: toolUse.input as Record<string, any>,
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
rawModel: response.model,
};
}
async ping(req: PingRequest): Promise<PingResponse> {
const resp = await this.client.messages.create({
model: req.model,
max_tokens: 16,
messages: [{ role: 'user', content: 'ping' }],
});
return {
ok: true,
inputTokens: resp.usage.input_tokens,
outputTokens: resp.usage.output_tokens,
};
}
private describeMissingToolUse(response: Anthropic.Message): string {
const contentSummary = response.content
.map((block) => {
if (block.type === 'text') {
const text = block.text.replace(/\s+/g, ' ').slice(0, 160);
return `text("${text}")`;
}
return block.type;
})
.join(', ');
return [
'Anthropic 未通过 tool_use 返回结果',
`stop_reason=${response.stop_reason || 'unknown'}`,
`content=${contentSummary || 'empty'}`,
].join('');
}
}

View File

@@ -0,0 +1,14 @@
import type { AiProvider } from './provider.interface';
import { AnthropicProvider } from './anthropic.provider';
import { OpenAIProvider } from './openai.provider';
import type { AiProviderConfig } from '@ftb/shared';
export function createProvider(config: AiProviderConfig): AiProvider {
if (config.format === 'anthropic') {
return new AnthropicProvider(config.apiKey, config.baseURL);
}
if (config.format === 'openai') {
return new OpenAIProvider(config.apiKey, config.baseURL);
}
throw new Error(`未支持的 provider format: ${(config as any).format}`);
}

View File

@@ -0,0 +1,79 @@
import OpenAI from 'openai';
import type { AiProvider, PingRequest, PingResponse, ToolCallRequest, ToolCallResponse } from './provider.interface';
/**
* OpenAI 格式提供商(兼容 OpenAI Chat Completions 风格的中转站)
*/
export class OpenAIProvider implements AiProvider {
readonly format = 'openai' as const;
private client: OpenAI;
constructor(apiKey: string, baseURL: string) {
this.client = new OpenAI({
apiKey,
baseURL,
});
}
async callTool(req: ToolCallRequest): Promise<ToolCallResponse> {
const response = await this.client.chat.completions.create({
model: req.model,
max_tokens: req.maxTokens,
messages: [
{ role: 'system', content: req.systemPrompt },
{ role: 'user', content: req.userPrompt },
],
tools: [
{
type: 'function',
function: {
name: req.tool.name,
description: req.tool.description,
parameters: req.tool.inputSchema,
},
},
],
tool_choice: req.forceTool
? { type: 'function', function: { name: req.tool.name } }
: 'auto',
});
const choice = response.choices[0];
if (!choice?.message?.tool_calls?.length) {
throw new Error('OpenAI 未通过 tool_calls 返回结果');
}
const tc = choice.message.tool_calls[0];
if (tc.type !== 'function') {
throw new Error('OpenAI 返回的 tool_call 类型非 function');
}
let parsed: Record<string, any>;
try {
parsed = JSON.parse(tc.function.arguments || '{}');
} catch (e) {
throw new Error(`OpenAI tool_call arguments 不是合法 JSON: ${(e as Error).message}`);
}
return {
toolName: tc.function.name,
toolInput: parsed,
inputTokens: response.usage?.prompt_tokens ?? 0,
outputTokens: response.usage?.completion_tokens ?? 0,
rawModel: response.model,
};
}
async ping(req: PingRequest): Promise<PingResponse> {
const resp = await this.client.chat.completions.create({
model: req.model,
max_tokens: 16,
messages: [{ role: 'user', content: 'ping' }],
});
return {
ok: true,
inputTokens: resp.usage?.prompt_tokens ?? 0,
outputTokens: resp.usage?.completion_tokens ?? 0,
};
}
}

View File

@@ -0,0 +1,49 @@
/**
* AI Provider 抽象接口
* 屏蔽 Anthropic vs OpenAI 格式差异,对上层提供统一调用
*/
export interface ToolDefinition {
name: string;
description: string;
inputSchema: any; // JSON Schema for tool input
}
export interface ToolCallRequest {
systemPrompt: string;
userPrompt: string;
tool: ToolDefinition;
/** 必须强制走 toolAnthropic: tool_choice / OpenAI: tool_choice */
forceTool: boolean;
maxTokens: number;
model: string;
}
export interface ToolCallResponse {
toolName: string;
toolInput: Record<string, any>;
inputTokens: number;
outputTokens: number;
rawModel: string; // 实际服务返回的 model 字段
}
export interface PingRequest {
model: string;
}
export interface PingResponse {
ok: true;
inputTokens: number;
outputTokens: number;
}
/**
* 所有 Provider 实现都满足此接口
*/
export interface AiProvider {
format: 'anthropic' | 'openai';
/** 调一次 tool 工具,返回归一化结果 */
callTool(req: ToolCallRequest): Promise<ToolCallResponse>;
/** 极简调用,验证 Key + 端点可用 */
ping(req: PingRequest): Promise<PingResponse>;
}