feat(平台): 补齐服务端持久化和AI拆解契约
This commit is contained in:
@@ -2,9 +2,12 @@ import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { ProductModule } from './modules/product/product.module';
|
||||
import { RequirementModule } from './modules/requirement/requirement.module';
|
||||
import { AiModule } from './modules/ai/ai.module';
|
||||
import { ConfigModule } from './modules/config/config.module';
|
||||
import { DataModule } from './modules/data/data.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ProductModule, RequirementModule],
|
||||
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, AiModule],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
})
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
// 加载 .env(Node v20+ 内置 loadEnvFile)
|
||||
// 优先 cwd/.env,其次 dist 上一级(编译运行时 cwd 可能在 dist/)
|
||||
for (const candidate of [resolve(process.cwd(), '.env'), resolve(__dirname, '../../.env'), resolve(__dirname, '../.env')]) {
|
||||
if (existsSync(candidate) && typeof (process as any).loadEnvFile === 'function') {
|
||||
(process as any).loadEnvFile(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
37
apps/server/src/modules/ai/ai-gateway.service.ts
Normal file
37
apps/server/src/modules/ai/ai-gateway.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
14
apps/server/src/modules/ai/ai.controller.ts
Normal file
14
apps/server/src/modules/ai/ai.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
apps/server/src/modules/ai/ai.module.ts
Normal file
13
apps/server/src/modules/ai/ai.module.ts
Normal 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 {}
|
||||
155
apps/server/src/modules/ai/ai.service.spec.ts
Normal file
155
apps/server/src/modules/ai/ai.service.spec.ts
Normal 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_use;content=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');
|
||||
});
|
||||
});
|
||||
223
apps/server/src/modules/ai/ai.service.ts
Normal file
223
apps/server/src/modules/ai/ai.service.ts
Normal 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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/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 返回结果。`;
|
||||
}
|
||||
}
|
||||
46
apps/server/src/modules/ai/dto/decompose.dto.ts
Normal file
46
apps/server/src/modules/ai/dto/decompose.dto.ts
Normal 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[];
|
||||
}
|
||||
186
apps/server/src/modules/ai/prompts/decompose.ts
Normal file
186
apps/server/src/modules/ai/prompts/decompose.ts
Normal 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'],
|
||||
};
|
||||
27
apps/server/src/modules/ai/prototype-context.spec.ts
Normal file
27
apps/server/src/modules/ai/prototype-context.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
72
apps/server/src/modules/ai/prototype-context.ts
Normal file
72
apps/server/src/modules/ai/prototype-context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
106
apps/server/src/modules/ai/providers/anthropic.provider.spec.ts
Normal file
106
apps/server/src/modules/ai/providers/anthropic.provider.spec.ts
Normal 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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
88
apps/server/src/modules/ai/providers/anthropic.provider.ts
Normal file
88
apps/server/src/modules/ai/providers/anthropic.provider.ts
Normal 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(';');
|
||||
}
|
||||
}
|
||||
14
apps/server/src/modules/ai/providers/factory.ts
Normal file
14
apps/server/src/modules/ai/providers/factory.ts
Normal 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}`);
|
||||
}
|
||||
79
apps/server/src/modules/ai/providers/openai.provider.ts
Normal file
79
apps/server/src/modules/ai/providers/openai.provider.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
49
apps/server/src/modules/ai/providers/provider.interface.ts
Normal file
49
apps/server/src/modules/ai/providers/provider.interface.ts
Normal 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;
|
||||
/** 必须强制走 tool(Anthropic: 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>;
|
||||
}
|
||||
76
apps/server/src/modules/config/ai-config.controller.ts
Normal file
76
apps/server/src/modules/config/ai-config.controller.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { AiConfigService } from './ai-config.service';
|
||||
import {
|
||||
UpsertProviderDto,
|
||||
TestProviderDto,
|
||||
ActivateProviderDto,
|
||||
} from './dto/update-ai-config.dto';
|
||||
import { createProvider } from '../ai/providers/factory';
|
||||
|
||||
@Controller('config/ai')
|
||||
export class AiConfigController {
|
||||
constructor(private readonly svc: AiConfigService) {}
|
||||
|
||||
@Get()
|
||||
async get() {
|
||||
return this.svc.getPublic();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或更新一个提供商
|
||||
*/
|
||||
@Patch('providers')
|
||||
async upsert(@Body() dto: UpsertProviderDto) {
|
||||
return this.svc.upsertProvider(dto, dto.operator);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除提供商
|
||||
*/
|
||||
@Delete('providers/:id')
|
||||
async remove(@Param('id') id: string, @Query('operator') operator?: string) {
|
||||
return this.svc.deleteProvider(id, operator);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换激活提供商
|
||||
*/
|
||||
@Post('activate')
|
||||
async activate(@Body() dto: ActivateProviderDto) {
|
||||
return this.svc.activate(dto.id, dto.operator);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试某个提供商连通性
|
||||
*/
|
||||
@Post('test')
|
||||
async test(@Body() dto: TestProviderDto) {
|
||||
if (!dto.id) throw new BadRequestException('id 必填');
|
||||
const fullProvider = await this.svc.getProviderFullById(dto.id);
|
||||
if (!fullProvider) throw new BadRequestException('提供商不存在');
|
||||
|
||||
try {
|
||||
const provider = createProvider(fullProvider);
|
||||
const r = await provider.ping({ model: fullProvider.model });
|
||||
return {
|
||||
ok: true,
|
||||
message: `连接成功(${fullProvider.model} · 输入 ${r.inputTokens}tok 输出 ${r.outputTokens}tok)`,
|
||||
};
|
||||
} catch (e: any) {
|
||||
return {
|
||||
ok: false,
|
||||
error: e?.message || '调用失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
200
apps/server/src/modules/config/ai-config.service.ts
Normal file
200
apps/server/src/modules/config/ai-config.service.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { promises as fs } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type {
|
||||
AiConfigPublic,
|
||||
AiConfigStored,
|
||||
AiProviderConfig,
|
||||
AiProviderPublic,
|
||||
AiProviderUpsertInput,
|
||||
} from '@ftb/shared';
|
||||
|
||||
const CONFIG_FILE = join(process.cwd(), 'data', 'ai-config.json');
|
||||
|
||||
const DEFAULT_MODEL = 'claude-sonnet-4-6';
|
||||
|
||||
@Injectable()
|
||||
export class AiConfigService {
|
||||
private readonly logger = new Logger(AiConfigService.name);
|
||||
private cache: AiConfigStored | null = null;
|
||||
|
||||
/**
|
||||
* 当前激活的提供商配置(含完整 apiKey),用于 AiGateway 实例化客户端
|
||||
*/
|
||||
async getActiveProvider(): Promise<AiProviderConfig | null> {
|
||||
const cfg = await this.read();
|
||||
if (!cfg.activeProviderId) {
|
||||
// 兜底:环境变量
|
||||
const envKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (envKey) {
|
||||
return {
|
||||
id: '__env__',
|
||||
name: '环境变量',
|
||||
format: 'anthropic',
|
||||
baseURL: 'https://api.anthropic.com',
|
||||
apiKey: envKey,
|
||||
model: DEFAULT_MODEL,
|
||||
createdAt: new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return cfg.providers.find((p) => p.id === cfg.activeProviderId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端用的整体视图
|
||||
*/
|
||||
async getPublic(): Promise<AiConfigPublic> {
|
||||
const cfg = await this.read();
|
||||
return {
|
||||
providers: cfg.providers.map((p) => this.toPublic(p, p.id === cfg.activeProviderId)),
|
||||
activeProviderId: cfg.activeProviderId,
|
||||
updatedAt: cfg.updatedAt,
|
||||
updatedBy: cfg.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或更新提供商
|
||||
* 若 input.apiKey 为空且 id 已存在,则保留原 key
|
||||
*/
|
||||
async upsertProvider(input: AiProviderUpsertInput, operator?: string): Promise<AiConfigPublic> {
|
||||
if (!input.id) throw new BadRequestException('id 必填');
|
||||
if (!input.name) throw new BadRequestException('name 必填');
|
||||
if (!input.baseURL) throw new BadRequestException('baseURL 必填');
|
||||
if (!input.model) throw new BadRequestException('model 必填');
|
||||
|
||||
const cfg = await this.read();
|
||||
const existingIdx = cfg.providers.findIndex((p) => p.id === input.id);
|
||||
|
||||
const apiKey = input.apiKey || cfg.providers[existingIdx]?.apiKey;
|
||||
if (!apiKey) throw new BadRequestException('apiKey 必填');
|
||||
|
||||
const provider: AiProviderConfig = {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
format: input.format,
|
||||
baseURL: input.baseURL,
|
||||
apiKey,
|
||||
model: input.model,
|
||||
remark: input.remark,
|
||||
createdAt:
|
||||
existingIdx >= 0 ? cfg.providers[existingIdx].createdAt : new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (existingIdx >= 0) {
|
||||
cfg.providers[existingIdx] = provider;
|
||||
} else {
|
||||
cfg.providers.push(provider);
|
||||
// 第一个新增的自动激活
|
||||
if (!cfg.activeProviderId) cfg.activeProviderId = provider.id;
|
||||
}
|
||||
|
||||
cfg.updatedAt = new Date().toISOString();
|
||||
cfg.updatedBy = operator;
|
||||
await this.write(cfg);
|
||||
return this.getPublic();
|
||||
}
|
||||
|
||||
async deleteProvider(id: string, operator?: string): Promise<AiConfigPublic> {
|
||||
const cfg = await this.read();
|
||||
const idx = cfg.providers.findIndex((p) => p.id === id);
|
||||
if (idx < 0) throw new NotFoundException('提供商不存在');
|
||||
cfg.providers.splice(idx, 1);
|
||||
if (cfg.activeProviderId === id) {
|
||||
cfg.activeProviderId = cfg.providers[0]?.id;
|
||||
}
|
||||
cfg.updatedAt = new Date().toISOString();
|
||||
cfg.updatedBy = operator;
|
||||
await this.write(cfg);
|
||||
return this.getPublic();
|
||||
}
|
||||
|
||||
async activate(id: string, operator?: string): Promise<AiConfigPublic> {
|
||||
const cfg = await this.read();
|
||||
if (!cfg.providers.find((p) => p.id === id)) {
|
||||
throw new NotFoundException('提供商不存在');
|
||||
}
|
||||
cfg.activeProviderId = id;
|
||||
cfg.updatedAt = new Date().toISOString();
|
||||
cfg.updatedBy = operator;
|
||||
await this.write(cfg);
|
||||
return this.getPublic();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某个提供商的完整配置(含 apiKey),仅供同进程内的服务调用(如 ping/decompose)
|
||||
*/
|
||||
async getProviderFullById(id: string): Promise<AiProviderConfig | null> {
|
||||
const cfg = await this.read();
|
||||
return cfg.providers.find((p) => p.id === id) ?? null;
|
||||
}
|
||||
|
||||
private async read(): Promise<AiConfigStored> {
|
||||
if (this.cache) return this.cache;
|
||||
try {
|
||||
const buf = await fs.readFile(CONFIG_FILE, 'utf-8');
|
||||
const parsed = JSON.parse(buf);
|
||||
this.cache = this.migrate(parsed);
|
||||
return this.cache!;
|
||||
} catch (e: any) {
|
||||
if (e.code !== 'ENOENT') this.logger.warn(`读取 ai-config.json 失败: ${e.message}`);
|
||||
this.cache = { providers: [] };
|
||||
return this.cache;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧数据格式(单 apiKey + model)迁移到新格式(providers[])
|
||||
*/
|
||||
private migrate(raw: any): AiConfigStored {
|
||||
if (Array.isArray(raw?.providers)) return raw as AiConfigStored;
|
||||
// 旧格式:{ anthropicApiKey, model, updatedAt, updatedBy }
|
||||
if (raw?.anthropicApiKey) {
|
||||
this.logger.log('检测到旧版 ai-config.json,自动迁移为多 provider 结构');
|
||||
const legacy: AiProviderConfig = {
|
||||
id: 'anthropic-official',
|
||||
name: 'Anthropic 官方',
|
||||
format: 'anthropic',
|
||||
baseURL: 'https://api.anthropic.com',
|
||||
apiKey: raw.anthropicApiKey,
|
||||
model: raw.model || DEFAULT_MODEL,
|
||||
createdAt: raw.updatedAt || new Date().toISOString(),
|
||||
};
|
||||
return {
|
||||
providers: [legacy],
|
||||
activeProviderId: legacy.id,
|
||||
updatedAt: raw.updatedAt,
|
||||
updatedBy: raw.updatedBy,
|
||||
};
|
||||
}
|
||||
return { providers: [] };
|
||||
}
|
||||
|
||||
private async write(data: AiConfigStored): Promise<void> {
|
||||
await fs.mkdir(join(process.cwd(), 'data'), { recursive: true });
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify(data, null, 2), 'utf-8');
|
||||
this.cache = data;
|
||||
}
|
||||
|
||||
private toPublic(p: AiProviderConfig, isActive: boolean): AiProviderPublic {
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
format: p.format,
|
||||
baseURL: p.baseURL,
|
||||
keyMask: this.mask(p.apiKey),
|
||||
model: p.model,
|
||||
remark: p.remark,
|
||||
createdAt: p.createdAt,
|
||||
isActive,
|
||||
};
|
||||
}
|
||||
|
||||
private mask(key: string): string {
|
||||
if (!key) return '';
|
||||
if (key.length <= 10) return '****';
|
||||
return `${key.slice(0, 6)}...${key.slice(-4)}`;
|
||||
}
|
||||
}
|
||||
10
apps/server/src/modules/config/config.module.ts
Normal file
10
apps/server/src/modules/config/config.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AiConfigController } from './ai-config.controller';
|
||||
import { AiConfigService } from './ai-config.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AiConfigController],
|
||||
providers: [AiConfigService],
|
||||
exports: [AiConfigService],
|
||||
})
|
||||
export class ConfigModule {}
|
||||
44
apps/server/src/modules/config/dto/update-ai-config.dto.ts
Normal file
44
apps/server/src/modules/config/dto/update-ai-config.dto.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { IsOptional, IsString, IsIn } from 'class-validator';
|
||||
|
||||
export class UpsertProviderDto {
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsIn(['anthropic', 'openai'])
|
||||
format!: 'anthropic' | 'openai';
|
||||
|
||||
@IsString()
|
||||
baseURL!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
apiKey?: string;
|
||||
|
||||
@IsString()
|
||||
model!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remark?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
operator?: string;
|
||||
}
|
||||
|
||||
export class TestProviderDto {
|
||||
@IsString()
|
||||
id!: string;
|
||||
}
|
||||
|
||||
export class ActivateProviderDto {
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
operator?: string;
|
||||
}
|
||||
18
apps/server/src/modules/data/data-keys.ts
Normal file
18
apps/server/src/modules/data/data-keys.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const APP_DATA_KEYS = [
|
||||
'products-overview',
|
||||
'requirements',
|
||||
'version-plans',
|
||||
'dev-tasks',
|
||||
'test-cases',
|
||||
'bugs',
|
||||
'members',
|
||||
'task-categories',
|
||||
'task-worklogs',
|
||||
'overtime',
|
||||
] as const;
|
||||
|
||||
export type AppDataKey = (typeof APP_DATA_KEYS)[number];
|
||||
|
||||
export function isAppDataKey(key: string): key is AppDataKey {
|
||||
return (APP_DATA_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
17
apps/server/src/modules/data/data.controller.ts
Normal file
17
apps/server/src/modules/data/data.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Body, Controller, Get, Param, Put } from '@nestjs/common';
|
||||
import { DataService } from './data.service';
|
||||
|
||||
@Controller('data')
|
||||
export class DataController {
|
||||
constructor(private readonly dataService: DataService) {}
|
||||
|
||||
@Get(':key')
|
||||
get(@Param('key') key: string) {
|
||||
return this.dataService.get(key);
|
||||
}
|
||||
|
||||
@Put(':key')
|
||||
put(@Param('key') key: string, @Body('value') value: unknown) {
|
||||
return this.dataService.put(key, value);
|
||||
}
|
||||
}
|
||||
9
apps/server/src/modules/data/data.module.ts
Normal file
9
apps/server/src/modules/data/data.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DataController } from './data.controller';
|
||||
import { DataService } from './data.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DataController],
|
||||
providers: [DataService],
|
||||
})
|
||||
export class DataModule {}
|
||||
66
apps/server/src/modules/data/data.service.spec.ts
Normal file
66
apps/server/src/modules/data/data.service.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { DataService } from './data.service';
|
||||
|
||||
describe('DataService', () => {
|
||||
const makeService = () => {
|
||||
const prisma = {
|
||||
appData: {
|
||||
findUnique: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
},
|
||||
};
|
||||
return {
|
||||
prisma,
|
||||
service: new DataService(prisma as any),
|
||||
};
|
||||
};
|
||||
|
||||
it('returns null for an allowed key with no stored value', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.appData.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.get('products-overview')).resolves.toEqual({
|
||||
key: 'products-overview',
|
||||
value: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown keys', async () => {
|
||||
const { service } = makeService();
|
||||
|
||||
await expect(service.get('unknown-key')).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('upserts JSON values for allowed keys', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
const value = [{ id: 'p1', name: 'Product 1' }];
|
||||
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value });
|
||||
|
||||
await expect(service.put('products-overview', value)).resolves.toEqual({
|
||||
key: 'products-overview',
|
||||
value,
|
||||
});
|
||||
expect(prisma.appData.upsert).toHaveBeenCalledWith({
|
||||
where: { key: 'products-overview' },
|
||||
update: { value },
|
||||
create: { key: 'products-overview', value },
|
||||
});
|
||||
});
|
||||
|
||||
it('allows supporting business data keys migrated from browser storage', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
const value: unknown[] = [];
|
||||
prisma.appData.upsert.mockImplementation(({ where }) =>
|
||||
Promise.resolve({ key: where.key, value }),
|
||||
);
|
||||
|
||||
await expect(service.put('task-worklogs', value)).resolves.toEqual({
|
||||
key: 'task-worklogs',
|
||||
value,
|
||||
});
|
||||
await expect(service.put('overtime', { records: [], reasons: [] })).resolves.toEqual({
|
||||
key: 'overtime',
|
||||
value,
|
||||
});
|
||||
});
|
||||
});
|
||||
32
apps/server/src/modules/data/data.service.ts
Normal file
32
apps/server/src/modules/data/data.service.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { isAppDataKey } from './data-keys';
|
||||
|
||||
@Injectable()
|
||||
export class DataService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async get(key: string) {
|
||||
this.ensureAllowedKey(key);
|
||||
const row = await this.prisma.appData.findUnique({ where: { key } });
|
||||
return { key, value: row?.value ?? null };
|
||||
}
|
||||
|
||||
async put(key: string, value: unknown) {
|
||||
this.ensureAllowedKey(key);
|
||||
const jsonValue = value as Prisma.InputJsonValue;
|
||||
const row = await this.prisma.appData.upsert({
|
||||
where: { key },
|
||||
update: { value: jsonValue },
|
||||
create: { key, value: jsonValue },
|
||||
});
|
||||
return { key: row.key, value: row.value };
|
||||
}
|
||||
|
||||
private ensureAllowedKey(key: string) {
|
||||
if (!isAppDataKey(key)) {
|
||||
throw new BadRequestException(`Unsupported data key: ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,26 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
private connected = false;
|
||||
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
try {
|
||||
await this.$connect();
|
||||
this.connected = true;
|
||||
this.logger.log('Prisma connected');
|
||||
} catch (e: any) {
|
||||
this.logger.warn(
|
||||
`数据库连接失败 (${e?.errorCode || e?.message || e}) — 跳过,需要 DB 的接口将不可用,但不影响 AI 配置 / 拆解模块`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
if (this.connected) {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user