spec-agent 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +256 -0
- package/bin/spec-agent.js +14 -0
- package/dist/commands/analyze.d.ts +16 -0
- package/dist/commands/analyze.d.ts.map +1 -0
- package/dist/commands/analyze.js +283 -0
- package/dist/commands/analyze.js.map +1 -0
- package/dist/commands/clean.d.ts +9 -0
- package/dist/commands/clean.d.ts.map +1 -0
- package/dist/commands/clean.js +109 -0
- package/dist/commands/clean.js.map +1 -0
- package/dist/commands/dispatch.d.ts +12 -0
- package/dist/commands/dispatch.d.ts.map +1 -0
- package/dist/commands/dispatch.js +232 -0
- package/dist/commands/dispatch.js.map +1 -0
- package/dist/commands/doctor.d.ts +9 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +153 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/learn.d.ts +13 -0
- package/dist/commands/learn.d.ts.map +1 -0
- package/dist/commands/learn.js +234 -0
- package/dist/commands/learn.js.map +1 -0
- package/dist/commands/merge.d.ts +11 -0
- package/dist/commands/merge.d.ts.map +1 -0
- package/dist/commands/merge.js +335 -0
- package/dist/commands/merge.js.map +1 -0
- package/dist/commands/pipeline.d.ts +19 -0
- package/dist/commands/pipeline.d.ts.map +1 -0
- package/dist/commands/pipeline.js +266 -0
- package/dist/commands/pipeline.js.map +1 -0
- package/dist/commands/plan.d.ts +13 -0
- package/dist/commands/plan.d.ts.map +1 -0
- package/dist/commands/plan.js +314 -0
- package/dist/commands/plan.js.map +1 -0
- package/dist/commands/scan.d.ts +28 -0
- package/dist/commands/scan.d.ts.map +1 -0
- package/dist/commands/scan.js +488 -0
- package/dist/commands/scan.js.map +1 -0
- package/dist/commands/status.d.ts +8 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +146 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +126 -0
- package/dist/index.js.map +1 -0
- package/dist/services/document-parser.d.ts +49 -0
- package/dist/services/document-parser.d.ts.map +1 -0
- package/dist/services/document-parser.js +499 -0
- package/dist/services/document-parser.js.map +1 -0
- package/dist/services/llm.d.ts +61 -0
- package/dist/services/llm.d.ts.map +1 -0
- package/dist/services/llm.js +716 -0
- package/dist/services/llm.js.map +1 -0
- package/dist/types.d.ts +159 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/file.d.ts +10 -0
- package/dist/utils/file.d.ts.map +1 -0
- package/dist/utils/file.js +96 -0
- package/dist/utils/file.js.map +1 -0
- package/dist/utils/logger.d.ts +13 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +55 -0
- package/dist/utils/logger.js.map +1 -0
- package/package.json +48 -0
- package/scripts/publish-npm.js +174 -0
- package/spec-agent-implementation.md +750 -0
- package/src/commands/analyze.ts +322 -0
- package/src/commands/clean.ts +88 -0
- package/src/commands/dispatch.ts +250 -0
- package/src/commands/doctor.ts +136 -0
- package/src/commands/learn.ts +261 -0
- package/src/commands/merge.ts +377 -0
- package/src/commands/pipeline.ts +306 -0
- package/src/commands/plan.ts +331 -0
- package/src/commands/scan.ts +568 -0
- package/src/commands/status.ts +129 -0
- package/src/index.ts +137 -0
- package/src/services/document-parser.ts +548 -0
- package/src/services/llm.ts +857 -0
- package/src/types.ts +161 -0
- package/src/utils/file.ts +60 -0
- package/src/utils/logger.ts +58 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
import { Logger } from '../utils/logger';
|
|
2
|
+
import { ChunkSummary, Feature, DataModel, Page, Api } from '../types';
|
|
3
|
+
|
|
4
|
+
export interface LLMConfig {
|
|
5
|
+
apiKey: string;
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
model: string;
|
|
8
|
+
maxTokens?: number;
|
|
9
|
+
temperature?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface LLMResponse {
|
|
13
|
+
content: string;
|
|
14
|
+
usage?: {
|
|
15
|
+
promptTokens: number;
|
|
16
|
+
completionTokens: number;
|
|
17
|
+
totalTokens: number;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ImageUnderstandingInput {
|
|
22
|
+
id: string;
|
|
23
|
+
alt?: string;
|
|
24
|
+
mimeType?: string;
|
|
25
|
+
estimatedSize?: number;
|
|
26
|
+
dataUri: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getLLMConfig(): LLMConfig {
|
|
30
|
+
return {
|
|
31
|
+
apiKey: process.env.OPENAI_API_KEY || process.env.LLM_API_KEY || '',
|
|
32
|
+
baseUrl: process.env.OPENAI_BASE_URL || process.env.LLM_BASE_URL || 'https://api.openai.com/v1',
|
|
33
|
+
model: process.env.LLM_MODEL || 'gpt-4o-mini',
|
|
34
|
+
maxTokens: parseInt(process.env.LLM_MAX_TOKENS || '4000', 10),
|
|
35
|
+
temperature: parseFloat(process.env.LLM_TEMPERATURE || '0.3'),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getLLMConfigForPurpose(purpose: 'scan' | 'analyze' | 'vision' | 'default'): LLMConfig {
|
|
40
|
+
const base = getLLMConfig();
|
|
41
|
+
const purposeModelMap: Record<string, string | undefined> = {
|
|
42
|
+
scan: process.env.LLM_MODEL_SCAN,
|
|
43
|
+
analyze: process.env.LLM_MODEL_ANALYZE,
|
|
44
|
+
vision: process.env.LLM_MODEL_VISION,
|
|
45
|
+
default: process.env.LLM_MODEL,
|
|
46
|
+
};
|
|
47
|
+
const selectedModel = purposeModelMap[purpose] || base.model;
|
|
48
|
+
return {
|
|
49
|
+
...base,
|
|
50
|
+
model: selectedModel,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function validateLLMConfig(config: LLMConfig): void {
|
|
55
|
+
if (!config.apiKey) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
'LLM API key not found. Please set one of:\n - OPENAI_API_KEY\n - LLM_API_KEY'
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sleep(ms: number): Promise<void> {
|
|
63
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseEnvInt(name: string, fallback: number): number {
|
|
67
|
+
const raw = process.env[name];
|
|
68
|
+
if (!raw) {
|
|
69
|
+
return fallback;
|
|
70
|
+
}
|
|
71
|
+
const parsed = parseInt(raw, 10);
|
|
72
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isRetriableStatus(status: number): boolean {
|
|
76
|
+
return [408, 409, 425, 429, 500, 502, 503, 504].includes(status);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function extractStatusFromErrorMessage(message: string): number | null {
|
|
80
|
+
const match = message.match(/LLM API error \((\d+)\):/);
|
|
81
|
+
if (!match) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const status = parseInt(match[1], 10);
|
|
85
|
+
return Number.isFinite(status) ? status : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function shouldRetryError(error: unknown): boolean {
|
|
89
|
+
if (error instanceof Error) {
|
|
90
|
+
if (error.name === 'AbortError') {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
const status = extractStatusFromErrorMessage(error.message);
|
|
94
|
+
if (status !== null) {
|
|
95
|
+
return isRetriableStatus(status);
|
|
96
|
+
}
|
|
97
|
+
const retryableText = [
|
|
98
|
+
'fetch failed',
|
|
99
|
+
'network',
|
|
100
|
+
'timeout',
|
|
101
|
+
'timed out',
|
|
102
|
+
'ECONNRESET',
|
|
103
|
+
'ETIMEDOUT',
|
|
104
|
+
'ENOTFOUND',
|
|
105
|
+
'EAI_AGAIN',
|
|
106
|
+
];
|
|
107
|
+
return retryableText.some(text => error.message.includes(text));
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function callLLM(
|
|
113
|
+
prompt: string,
|
|
114
|
+
config: LLMConfig,
|
|
115
|
+
logger?: Logger
|
|
116
|
+
): Promise<LLMResponse> {
|
|
117
|
+
const url = `${config.baseUrl.replace(/\/$/, '')}/chat/completions`;
|
|
118
|
+
const maxRetries = Math.max(0, parseEnvInt('LLM_RETRIES', 2));
|
|
119
|
+
const timeoutMs = Math.max(5000, parseEnvInt('LLM_TIMEOUT_MS', 60000));
|
|
120
|
+
const retryDelayMs = Math.max(200, parseEnvInt('LLM_RETRY_DELAY_MS', 1200));
|
|
121
|
+
|
|
122
|
+
if (logger) {
|
|
123
|
+
logger.debug(`Calling LLM: ${config.model} at ${config.baseUrl}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
127
|
+
const controller = new AbortController();
|
|
128
|
+
const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const response = await fetch(url, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: {
|
|
134
|
+
'Content-Type': 'application/json',
|
|
135
|
+
'Authorization': `Bearer ${config.apiKey}`,
|
|
136
|
+
},
|
|
137
|
+
body: JSON.stringify({
|
|
138
|
+
model: config.model,
|
|
139
|
+
messages: [
|
|
140
|
+
{
|
|
141
|
+
role: 'system',
|
|
142
|
+
content: 'You are a technical analyst specializing in extracting structured information from software requirement documents. Always respond with valid JSON.',
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
role: 'user',
|
|
146
|
+
content: prompt,
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
max_tokens: config.maxTokens,
|
|
150
|
+
temperature: config.temperature,
|
|
151
|
+
response_format: { type: 'json_object' },
|
|
152
|
+
}),
|
|
153
|
+
signal: controller.signal,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
clearTimeout(timeoutHandle);
|
|
157
|
+
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
const errorText = await response.text();
|
|
160
|
+
throw new Error(`LLM API error (${response.status}): ${errorText}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const data = await response.json() as {
|
|
164
|
+
choices?: Array<{ message?: { content?: string } }>;
|
|
165
|
+
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
if (!data.choices?.[0]?.message?.content) {
|
|
169
|
+
throw new Error('Invalid LLM response format');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
content: data.choices[0].message.content,
|
|
174
|
+
usage: data.usage ? {
|
|
175
|
+
promptTokens: data.usage.prompt_tokens,
|
|
176
|
+
completionTokens: data.usage.completion_tokens,
|
|
177
|
+
totalTokens: data.usage.total_tokens,
|
|
178
|
+
} : undefined,
|
|
179
|
+
};
|
|
180
|
+
} catch (error) {
|
|
181
|
+
clearTimeout(timeoutHandle);
|
|
182
|
+
const canRetry = attempt < maxRetries && shouldRetryError(error);
|
|
183
|
+
|
|
184
|
+
if (canRetry) {
|
|
185
|
+
const delay = retryDelayMs * Math.pow(2, attempt);
|
|
186
|
+
if (logger) {
|
|
187
|
+
logger.warn(`LLM 请求失败,${delay}ms 后重试 (${attempt + 1}/${maxRetries})`);
|
|
188
|
+
}
|
|
189
|
+
await sleep(delay);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
throw new Error('LLM request failed after retries');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function callVisionLLM(
|
|
201
|
+
prompt: string,
|
|
202
|
+
imageDataUri: string,
|
|
203
|
+
config: LLMConfig,
|
|
204
|
+
logger?: Logger
|
|
205
|
+
): Promise<string> {
|
|
206
|
+
const url = `${config.baseUrl.replace(/\/$/, '')}/chat/completions`;
|
|
207
|
+
const timeoutMs = Math.max(5000, parseEnvInt('LLM_TIMEOUT_MS', 60000));
|
|
208
|
+
const controller = new AbortController();
|
|
209
|
+
const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const response = await fetch(url, {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: {
|
|
215
|
+
'Content-Type': 'application/json',
|
|
216
|
+
'Authorization': `Bearer ${config.apiKey}`,
|
|
217
|
+
},
|
|
218
|
+
body: JSON.stringify({
|
|
219
|
+
model: config.model,
|
|
220
|
+
messages: [
|
|
221
|
+
{
|
|
222
|
+
role: 'system',
|
|
223
|
+
content: 'You are a multimodal analyst. Describe image content for requirement understanding. Keep it concise and factual.',
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
role: 'user',
|
|
227
|
+
content: [
|
|
228
|
+
{ type: 'text', text: prompt },
|
|
229
|
+
{ type: 'image_url', image_url: { url: imageDataUri } },
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
max_tokens: Math.min(config.maxTokens || 600, 600),
|
|
234
|
+
temperature: 0,
|
|
235
|
+
}),
|
|
236
|
+
signal: controller.signal,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (!response.ok) {
|
|
240
|
+
const errorText = await response.text();
|
|
241
|
+
throw new Error(`Vision LLM API error (${response.status}): ${errorText}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const data = await response.json() as {
|
|
245
|
+
choices?: Array<{ message?: { content?: string } }>;
|
|
246
|
+
};
|
|
247
|
+
const text = data.choices?.[0]?.message?.content;
|
|
248
|
+
if (!text) {
|
|
249
|
+
throw new Error('Invalid vision LLM response format');
|
|
250
|
+
}
|
|
251
|
+
return text.trim();
|
|
252
|
+
} finally {
|
|
253
|
+
clearTimeout(timeoutHandle);
|
|
254
|
+
if (logger) {
|
|
255
|
+
logger.debug('Vision LLM request finished');
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function parseImageDataUri(dataUri: string): { mimeType: string; base64: string } | null {
|
|
261
|
+
const match = dataUri.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=]+)$/);
|
|
262
|
+
if (!match) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
mimeType: match[1],
|
|
267
|
+
base64: match[2],
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function compressImageDataUri(
|
|
272
|
+
dataUri: string,
|
|
273
|
+
maxImageBytes: number,
|
|
274
|
+
logger?: Logger
|
|
275
|
+
): Promise<string | null> {
|
|
276
|
+
const parsed = parseImageDataUri(dataUri);
|
|
277
|
+
if (!parsed) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const originalBuffer = Buffer.from(parsed.base64, 'base64');
|
|
282
|
+
if (originalBuffer.length <= maxImageBytes) {
|
|
283
|
+
return dataUri;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const sharpImport = await import('sharp');
|
|
288
|
+
const sharpLib = (sharpImport as { default?: typeof import('sharp') }).default || (sharpImport as unknown as typeof import('sharp'));
|
|
289
|
+
const meta = await sharpLib(originalBuffer).metadata();
|
|
290
|
+
const baseWidth = meta.width || 1800;
|
|
291
|
+
|
|
292
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
293
|
+
const scale = Math.max(0.25, 1 - attempt * 0.15);
|
|
294
|
+
const targetWidth = Math.max(480, Math.floor(baseWidth * scale));
|
|
295
|
+
const quality = Math.max(35, 80 - attempt * 10);
|
|
296
|
+
|
|
297
|
+
const compressed = await sharpLib(originalBuffer)
|
|
298
|
+
.resize({ width: targetWidth, withoutEnlargement: true })
|
|
299
|
+
.jpeg({ quality, mozjpeg: true })
|
|
300
|
+
.toBuffer();
|
|
301
|
+
|
|
302
|
+
if (compressed.length <= maxImageBytes) {
|
|
303
|
+
if (logger) {
|
|
304
|
+
logger.info(`图片已压缩: ${originalBuffer.length} -> ${compressed.length} bytes`);
|
|
305
|
+
}
|
|
306
|
+
return `data:image/jpeg;base64,${compressed.toString('base64')}`;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (logger) {
|
|
311
|
+
logger.warn(`图片压缩失败,使用原图: ${error instanceof Error ? error.message : String(error)}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function prepareImageForVision(
|
|
319
|
+
dataUri: string,
|
|
320
|
+
maxImageBytes: number,
|
|
321
|
+
logger?: Logger
|
|
322
|
+
): Promise<{ dataUri: string; bytes: number } | null> {
|
|
323
|
+
const currentBytes = Buffer.byteLength(dataUri, 'utf-8');
|
|
324
|
+
if (currentBytes <= maxImageBytes) {
|
|
325
|
+
return { dataUri, bytes: currentBytes };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const compressed = await compressImageDataUri(dataUri, maxImageBytes, logger);
|
|
329
|
+
if (!compressed) {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
const compressedBytes = Buffer.byteLength(compressed, 'utf-8');
|
|
333
|
+
if (compressedBytes > maxImageBytes) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return { dataUri: compressed, bytes: compressedBytes };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export async function describeEmbeddedImages(
|
|
341
|
+
images: ImageUnderstandingInput[],
|
|
342
|
+
config: LLMConfig,
|
|
343
|
+
logger?: Logger
|
|
344
|
+
): Promise<Record<string, string>> {
|
|
345
|
+
if (images.length === 0) {
|
|
346
|
+
return {};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const visionConfig = {
|
|
350
|
+
...config,
|
|
351
|
+
model: getLLMConfigForPurpose('vision').model || config.model,
|
|
352
|
+
};
|
|
353
|
+
validateLLMConfig(visionConfig);
|
|
354
|
+
const maxImages = Math.max(0, parseEnvInt('LLM_IMAGE_MAX_PER_DOC', 12));
|
|
355
|
+
const maxImageBytes = Math.max(64 * 1024, parseEnvInt('LLM_IMAGE_MAX_BYTES', 2 * 1024 * 1024));
|
|
356
|
+
const selected = images.slice(0, maxImages);
|
|
357
|
+
const summaries: Record<string, string> = {};
|
|
358
|
+
|
|
359
|
+
for (const image of selected) {
|
|
360
|
+
const prepared = await prepareImageForVision(image.dataUri, maxImageBytes, logger);
|
|
361
|
+
if (!prepared) {
|
|
362
|
+
const imageBytes = Buffer.byteLength(image.dataUri, 'utf-8');
|
|
363
|
+
summaries[image.id] = `图片过大且压缩失败,跳过语义识别(${imageBytes} bytes > ${maxImageBytes} bytes)`;
|
|
364
|
+
if (logger) {
|
|
365
|
+
logger.warn(`图片 ${image.id} 超出大小限制,压缩后仍不可用`);
|
|
366
|
+
}
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const prompt = [
|
|
371
|
+
'请阅读这张需求文档中的图片,并完成 OCR+语义理解。',
|
|
372
|
+
'输出要求:',
|
|
373
|
+
'1) 识别可见文字(如标题、字段、按钮、错误信息)',
|
|
374
|
+
'2) 识别界面/流程/图表要点',
|
|
375
|
+
'3) 推测它对应的业务功能',
|
|
376
|
+
'限制:最多 160 字。',
|
|
377
|
+
`上下文 alt: ${image.alt || '无'}`,
|
|
378
|
+
`图片ID: ${image.id}`,
|
|
379
|
+
].join('\n');
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
const rawSummary = await callVisionLLM(prompt, prepared.dataUri, visionConfig, logger);
|
|
383
|
+
summaries[image.id] = sanitizeImageSummary(rawSummary);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
try {
|
|
386
|
+
const ocrFallback = await callVisionLLM(
|
|
387
|
+
`请只做 OCR,提取图片中的关键文字(最多120字)。图片ID: ${image.id}`,
|
|
388
|
+
prepared.dataUri,
|
|
389
|
+
visionConfig,
|
|
390
|
+
logger
|
|
391
|
+
);
|
|
392
|
+
summaries[image.id] = sanitizeImageSummary(`OCR兜底: ${ocrFallback}`);
|
|
393
|
+
if (logger) {
|
|
394
|
+
logger.warn(`图片 ${image.id} 语义理解失败,已使用 OCR 兜底`);
|
|
395
|
+
}
|
|
396
|
+
} catch (fallbackError) {
|
|
397
|
+
summaries[image.id] = `图片理解失败: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`;
|
|
398
|
+
if (logger) {
|
|
399
|
+
logger.warn(`图片 ${image.id} 理解失败,保留占位信息`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return summaries;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function sanitizeImageSummary(raw: string): string {
|
|
409
|
+
if (!raw) return '';
|
|
410
|
+
const cleaned = raw
|
|
411
|
+
.replace(/#{1,6}\s*/g, '')
|
|
412
|
+
.replace(/\*\*/g, '')
|
|
413
|
+
.replace(/`/g, '')
|
|
414
|
+
.replace(/\r?\n+/g, ' ')
|
|
415
|
+
.replace(/\s+/g, ' ')
|
|
416
|
+
.trim();
|
|
417
|
+
|
|
418
|
+
const noLongNoise = cleaned
|
|
419
|
+
.split(' ')
|
|
420
|
+
.filter(token => token.length <= 40)
|
|
421
|
+
.join(' ')
|
|
422
|
+
.trim();
|
|
423
|
+
|
|
424
|
+
if (noLongNoise.length <= 220) {
|
|
425
|
+
return noLongNoise;
|
|
426
|
+
}
|
|
427
|
+
return `${noLongNoise.slice(0, 220)}...`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const ANALYSIS_PROMPT_TEMPLATE = `Analyze the following requirement document chunk and extract normalized, implementation-ready structured information.
|
|
431
|
+
|
|
432
|
+
Focus Area: {focus}
|
|
433
|
+
|
|
434
|
+
Document Chunk:
|
|
435
|
+
---
|
|
436
|
+
{content}
|
|
437
|
+
---
|
|
438
|
+
|
|
439
|
+
Extract the following information and return as JSON:
|
|
440
|
+
|
|
441
|
+
{
|
|
442
|
+
"features": [
|
|
443
|
+
{
|
|
444
|
+
"id": "F001",
|
|
445
|
+
"name": "Feature name",
|
|
446
|
+
"description": "Detailed description",
|
|
447
|
+
"priority": "P0|P1|P2",
|
|
448
|
+
"dependencies": ["F002"]
|
|
449
|
+
}
|
|
450
|
+
],
|
|
451
|
+
"dataModels": [
|
|
452
|
+
{
|
|
453
|
+
"name": "ModelName",
|
|
454
|
+
"fields": ["field1", "field2"],
|
|
455
|
+
"relationships": ["OtherModel"]
|
|
456
|
+
}
|
|
457
|
+
],
|
|
458
|
+
"pages": [
|
|
459
|
+
{
|
|
460
|
+
"name": "PageName",
|
|
461
|
+
"route": "/path",
|
|
462
|
+
"components": ["Component1"]
|
|
463
|
+
}
|
|
464
|
+
],
|
|
465
|
+
"apis": [
|
|
466
|
+
{
|
|
467
|
+
"method": "GET|POST|PUT|DELETE",
|
|
468
|
+
"path": "/api/endpoint",
|
|
469
|
+
"description": "What this API does"
|
|
470
|
+
}
|
|
471
|
+
],
|
|
472
|
+
"businessRules": [
|
|
473
|
+
"Description of business rule 1",
|
|
474
|
+
"Description of business rule 2"
|
|
475
|
+
]
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
Guidelines:
|
|
479
|
+
- Only include information explicitly present in the document.
|
|
480
|
+
- Prefer capability-level features (e.g. "客户开户"), not tiny UI actions.
|
|
481
|
+
- Merge synonymous items in the same chunk. Do not output near-duplicates.
|
|
482
|
+
- Feature names must be short canonical names, avoid prefixes/suffixes like "流程", "功能模块", "支持XX".
|
|
483
|
+
- Dependencies should reference other feature IDs in the same output when explicit.
|
|
484
|
+
- Mark priority based on explicit mentions (P0=critical, P1=important, P2=nice-to-have).
|
|
485
|
+
- If a category has no items, return empty array.
|
|
486
|
+
- Return pure JSON only.`;
|
|
487
|
+
|
|
488
|
+
export async function analyzeChunkWithLLM(
|
|
489
|
+
chunkContent: string,
|
|
490
|
+
chunkId: number,
|
|
491
|
+
focus: string,
|
|
492
|
+
config: LLMConfig,
|
|
493
|
+
logger?: Logger
|
|
494
|
+
): Promise<ChunkSummary> {
|
|
495
|
+
validateLLMConfig(config);
|
|
496
|
+
|
|
497
|
+
const prompt = ANALYSIS_PROMPT_TEMPLATE
|
|
498
|
+
.replace('{focus}', focus)
|
|
499
|
+
.replace('{content}', chunkContent);
|
|
500
|
+
|
|
501
|
+
const response = await callLLM(prompt, config, logger);
|
|
502
|
+
|
|
503
|
+
let parsed: Partial<ChunkSummary>;
|
|
504
|
+
try {
|
|
505
|
+
parsed = JSON.parse(response.content);
|
|
506
|
+
} catch (e) {
|
|
507
|
+
throw new Error(`Failed to parse LLM response as JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const normalizeFeatureName = (name: string): string => {
|
|
511
|
+
return (name || '')
|
|
512
|
+
.replace(/[\r\n]+/g, ' ')
|
|
513
|
+
.replace(/\s+/g, ' ')
|
|
514
|
+
.replace(/(功能模块|功能点|流程管理|流程|功能|模块)$/g, '')
|
|
515
|
+
.trim();
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
const dedupeByName = <T extends { name: string }>(items: T[]): T[] => {
|
|
519
|
+
const seen = new Set<string>();
|
|
520
|
+
const result: T[] = [];
|
|
521
|
+
for (const item of items) {
|
|
522
|
+
const key = normalizeFeatureName(item.name).toLowerCase();
|
|
523
|
+
if (!key || seen.has(key)) continue;
|
|
524
|
+
seen.add(key);
|
|
525
|
+
result.push({ ...item, name: normalizeFeatureName(item.name) });
|
|
526
|
+
}
|
|
527
|
+
return result;
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
// Validate and normalize the response
|
|
531
|
+
const summary: ChunkSummary = {
|
|
532
|
+
chunkId,
|
|
533
|
+
sourceFiles: [], // Will be filled by caller
|
|
534
|
+
size: '0B', // Will be filled by caller
|
|
535
|
+
features: dedupeByName((parsed.features || []).map((f: Partial<Feature>, idx: number) => ({
|
|
536
|
+
id: f.id || `F${String(chunkId * 100 + idx + 1).padStart(3, '0')}`,
|
|
537
|
+
name: f.name || 'Unnamed Feature',
|
|
538
|
+
description: f.description || '',
|
|
539
|
+
priority: (f.priority as Feature['priority']) || 'P2',
|
|
540
|
+
dependencies: f.dependencies || [],
|
|
541
|
+
sourceChunk: chunkId,
|
|
542
|
+
}))),
|
|
543
|
+
dataModels: (parsed.dataModels || []).map((m: Partial<DataModel>) => ({
|
|
544
|
+
name: m.name || 'UnnamedModel',
|
|
545
|
+
fields: m.fields || [],
|
|
546
|
+
relationships: m.relationships || [],
|
|
547
|
+
sourceChunk: chunkId,
|
|
548
|
+
})),
|
|
549
|
+
pages: (parsed.pages || []).map((p: Partial<Page>) => ({
|
|
550
|
+
name: p.name || 'UnnamedPage',
|
|
551
|
+
route: p.route || '/',
|
|
552
|
+
components: p.components || [],
|
|
553
|
+
sourceChunk: chunkId,
|
|
554
|
+
})),
|
|
555
|
+
apis: (parsed.apis || []).map((a: Partial<Api>) => ({
|
|
556
|
+
method: (a.method as Api['method']) || 'GET',
|
|
557
|
+
path: a.path || '/api/unknown',
|
|
558
|
+
description: a.description || '',
|
|
559
|
+
sourceChunk: chunkId,
|
|
560
|
+
})),
|
|
561
|
+
businessRules: parsed.businessRules || [],
|
|
562
|
+
extractedAt: new Date().toISOString(),
|
|
563
|
+
llmUsage: response.usage,
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
return summary;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ============================================================================
|
|
570
|
+
// LLM-Driven Document Structure Analysis
|
|
571
|
+
// ============================================================================
|
|
572
|
+
|
|
573
|
+
export interface DocumentSection {
|
|
574
|
+
id: string;
|
|
575
|
+
title: string;
|
|
576
|
+
level: number;
|
|
577
|
+
startLine: number;
|
|
578
|
+
endLine: number;
|
|
579
|
+
content: string;
|
|
580
|
+
suggestedGroup?: string;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export interface DocumentStructure {
|
|
584
|
+
title: string;
|
|
585
|
+
sections: DocumentSection[];
|
|
586
|
+
suggestedGroups: {
|
|
587
|
+
name: string;
|
|
588
|
+
sections: string[];
|
|
589
|
+
reason: string;
|
|
590
|
+
}[];
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const STRUCTURE_ANALYSIS_PROMPT = `你是需求文档结构分析专家。请基于“带行号的文本”做结构切分。
|
|
594
|
+
|
|
595
|
+
文档内容(每行格式为 "N|文本",N 为原始行号):
|
|
596
|
+
---
|
|
597
|
+
{content}
|
|
598
|
+
---
|
|
599
|
+
|
|
600
|
+
请返回 JSON:
|
|
601
|
+
{
|
|
602
|
+
"title": "文档标题",
|
|
603
|
+
"sections": [
|
|
604
|
+
{
|
|
605
|
+
"id": "S001",
|
|
606
|
+
"title": "章节标题(无标题时可用语义命名)",
|
|
607
|
+
"level": 1,
|
|
608
|
+
"startLine": 1,
|
|
609
|
+
"endLine": 50,
|
|
610
|
+
"content": "章节摘要(<=200字)",
|
|
611
|
+
"suggestedGroup": "分组名称"
|
|
612
|
+
}
|
|
613
|
+
],
|
|
614
|
+
"suggestedGroups": [
|
|
615
|
+
{
|
|
616
|
+
"name": "分组名称",
|
|
617
|
+
"sections": ["S001", "S002"],
|
|
618
|
+
"reason": "该分组的业务理由"
|
|
619
|
+
}
|
|
620
|
+
]
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
要求:
|
|
624
|
+
1. startLine/endLine 必须引用输入中的真实行号,且 startLine <= endLine。
|
|
625
|
+
2. 即使没有标准 Markdown 标题,也要按语义主题划分章节。
|
|
626
|
+
3. 章节尽量完整,不要把同一个业务点打散到多个章节。
|
|
627
|
+
4. 若文本中存在目录、版本记录、附录,可作为单独分组。
|
|
628
|
+
5. 返回严格 JSON,不要附加解释文字。`;
|
|
629
|
+
|
|
630
|
+
const WINDOW_LINE_COUNT = 800;
|
|
631
|
+
const WINDOW_OVERLAP = 40;
|
|
632
|
+
|
|
633
|
+
function numberLines(lines: string[], startLine: number): string {
|
|
634
|
+
return lines
|
|
635
|
+
.map((line, idx) => `${startLine + idx}|${line}`)
|
|
636
|
+
.join('\n');
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function normalizeSection(
|
|
640
|
+
section: Partial<DocumentSection>,
|
|
641
|
+
fallbackId: string,
|
|
642
|
+
minLine: number,
|
|
643
|
+
maxLine: number
|
|
644
|
+
): DocumentSection {
|
|
645
|
+
const rawStart = typeof section.startLine === 'number' ? section.startLine : minLine;
|
|
646
|
+
const rawEnd = typeof section.endLine === 'number' ? section.endLine : rawStart;
|
|
647
|
+
const startLine = Math.max(minLine, Math.min(rawStart, maxLine));
|
|
648
|
+
const endLine = Math.max(startLine, Math.min(rawEnd, maxLine));
|
|
649
|
+
|
|
650
|
+
return {
|
|
651
|
+
id: section.id || fallbackId,
|
|
652
|
+
title: section.title || 'Untitled Section',
|
|
653
|
+
level: section.level || 1,
|
|
654
|
+
startLine,
|
|
655
|
+
endLine,
|
|
656
|
+
content: section.content || '',
|
|
657
|
+
suggestedGroup: section.suggestedGroup || '未分组',
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function dedupeAndSortSections(sections: DocumentSection[]): DocumentSection[] {
|
|
662
|
+
const sorted = sections
|
|
663
|
+
.slice()
|
|
664
|
+
.sort((a, b) => a.startLine - b.startLine || a.endLine - b.endLine);
|
|
665
|
+
|
|
666
|
+
const result: DocumentSection[] = [];
|
|
667
|
+
for (const section of sorted) {
|
|
668
|
+
const last = result[result.length - 1];
|
|
669
|
+
if (!last) {
|
|
670
|
+
result.push(section);
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Skip near-duplicate segments from overlapping windows.
|
|
675
|
+
const sameRange = Math.abs(section.startLine - last.startLine) <= 2
|
|
676
|
+
&& Math.abs(section.endLine - last.endLine) <= 2;
|
|
677
|
+
if (sameRange) {
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
result.push(section);
|
|
682
|
+
}
|
|
683
|
+
return result;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Use LLM to analyze document structure and suggest optimal chunking strategy
|
|
688
|
+
*/
|
|
689
|
+
export async function analyzeDocumentStructure(
|
|
690
|
+
content: string,
|
|
691
|
+
config: LLMConfig,
|
|
692
|
+
logger?: Logger
|
|
693
|
+
): Promise<DocumentStructure> {
|
|
694
|
+
validateLLMConfig(config);
|
|
695
|
+
const lines = content.split('\n');
|
|
696
|
+
if (lines.length === 0) {
|
|
697
|
+
throw new Error('Document content is empty');
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (logger) {
|
|
701
|
+
logger.info(`使用 LLM 分析文档结构(总行数: ${lines.length})...`);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const windowStarts: number[] = [];
|
|
705
|
+
if (lines.length <= WINDOW_LINE_COUNT) {
|
|
706
|
+
windowStarts.push(0);
|
|
707
|
+
} else {
|
|
708
|
+
const stride = WINDOW_LINE_COUNT - WINDOW_OVERLAP;
|
|
709
|
+
for (let start = 0; start < lines.length; start += stride) {
|
|
710
|
+
windowStarts.push(start);
|
|
711
|
+
if (start + WINDOW_LINE_COUNT >= lines.length) {
|
|
712
|
+
break;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const collectedSections: DocumentSection[] = [];
|
|
718
|
+
const collectedGroupReasons = new Map<string, string>();
|
|
719
|
+
let mergedTitle = 'Untitled Document';
|
|
720
|
+
|
|
721
|
+
for (let windowIdx = 0; windowIdx < windowStarts.length; windowIdx++) {
|
|
722
|
+
const start = windowStarts[windowIdx];
|
|
723
|
+
const end = Math.min(start + WINDOW_LINE_COUNT, lines.length);
|
|
724
|
+
const slice = lines.slice(start, end);
|
|
725
|
+
const numbered = numberLines(slice, start + 1);
|
|
726
|
+
const prompt = STRUCTURE_ANALYSIS_PROMPT.replace('{content}', numbered);
|
|
727
|
+
|
|
728
|
+
if (logger && windowStarts.length > 1) {
|
|
729
|
+
logger.info(` 分析窗口 ${windowIdx + 1}/${windowStarts.length}(行 ${start + 1}-${end})`);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const response = await callLLM(prompt, config, logger);
|
|
733
|
+
let parsed: Partial<DocumentStructure>;
|
|
734
|
+
try {
|
|
735
|
+
parsed = JSON.parse(response.content);
|
|
736
|
+
} catch (e) {
|
|
737
|
+
throw new Error(`Failed to parse document structure: ${e instanceof Error ? e.message : String(e)}`);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (parsed.title && mergedTitle === 'Untitled Document') {
|
|
741
|
+
mergedTitle = parsed.title;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const normalizedSections = (parsed.sections || []).map((s: Partial<DocumentSection>, idx: number) =>
|
|
745
|
+
normalizeSection(
|
|
746
|
+
s,
|
|
747
|
+
`S${String(collectedSections.length + idx + 1).padStart(3, '0')}`,
|
|
748
|
+
start + 1,
|
|
749
|
+
end
|
|
750
|
+
)
|
|
751
|
+
);
|
|
752
|
+
collectedSections.push(...normalizedSections);
|
|
753
|
+
|
|
754
|
+
for (const group of parsed.suggestedGroups || []) {
|
|
755
|
+
if (group.name && group.reason && !collectedGroupReasons.has(group.name)) {
|
|
756
|
+
collectedGroupReasons.set(group.name, group.reason);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const sections = dedupeAndSortSections(collectedSections).map((section, idx) => ({
|
|
762
|
+
...section,
|
|
763
|
+
id: `S${String(idx + 1).padStart(3, '0')}`,
|
|
764
|
+
}));
|
|
765
|
+
|
|
766
|
+
if (sections.length === 0) {
|
|
767
|
+
throw new Error('LLM returned no usable sections');
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const groups = new Map<string, string[]>();
|
|
771
|
+
for (const section of sections) {
|
|
772
|
+
const groupName = section.suggestedGroup || '未分组';
|
|
773
|
+
if (!groups.has(groupName)) {
|
|
774
|
+
groups.set(groupName, []);
|
|
775
|
+
}
|
|
776
|
+
groups.get(groupName)!.push(section.id);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const suggestedGroups = Array.from(groups.entries()).map(([name, sectionIds]) => ({
|
|
780
|
+
name,
|
|
781
|
+
sections: sectionIds,
|
|
782
|
+
reason: collectedGroupReasons.get(name) || '语义相近章节分组',
|
|
783
|
+
}));
|
|
784
|
+
|
|
785
|
+
return {
|
|
786
|
+
title: mergedTitle,
|
|
787
|
+
sections,
|
|
788
|
+
suggestedGroups,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Split content based on LLM-analyzed structure
|
|
794
|
+
*/
|
|
795
|
+
export function splitByLLMStructure(
|
|
796
|
+
content: string,
|
|
797
|
+
structure: DocumentStructure,
|
|
798
|
+
maxChunkSize: number
|
|
799
|
+
): Array<{ title: string; content: string; sections: string[] }> {
|
|
800
|
+
const lines = content.split('\n');
|
|
801
|
+
const chunks: Array<{ title: string; content: string; sections: string[] }> = [];
|
|
802
|
+
|
|
803
|
+
// Group sections by suggestedGroup
|
|
804
|
+
const groupMap = new Map<string, DocumentSection[]>();
|
|
805
|
+
|
|
806
|
+
for (const section of structure.sections) {
|
|
807
|
+
const groupName = section.suggestedGroup || '未分组';
|
|
808
|
+
if (!groupMap.has(groupName)) {
|
|
809
|
+
groupMap.set(groupName, []);
|
|
810
|
+
}
|
|
811
|
+
groupMap.get(groupName)!.push(section);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Create chunks from groups
|
|
815
|
+
for (const [groupName, sections] of groupMap) {
|
|
816
|
+
// Sort sections by startLine
|
|
817
|
+
sections.sort((a, b) => a.startLine - b.startLine);
|
|
818
|
+
|
|
819
|
+
let currentChunk: string[] = [];
|
|
820
|
+
let currentSections: string[] = [];
|
|
821
|
+
let currentSize = 0;
|
|
822
|
+
|
|
823
|
+
for (const section of sections) {
|
|
824
|
+
const sectionContent = lines
|
|
825
|
+
.slice(Math.max(0, section.startLine - 1), section.endLine)
|
|
826
|
+
.join('\n');
|
|
827
|
+
const sectionSize = Buffer.byteLength(sectionContent, 'utf-8');
|
|
828
|
+
|
|
829
|
+
// If adding this section would exceed max size, start new chunk
|
|
830
|
+
if (currentSize + sectionSize > maxChunkSize && currentChunk.length > 0) {
|
|
831
|
+
chunks.push({
|
|
832
|
+
title: groupName,
|
|
833
|
+
content: currentChunk.join('\n\n---\n\n'),
|
|
834
|
+
sections: [...currentSections],
|
|
835
|
+
});
|
|
836
|
+
currentChunk = [sectionContent];
|
|
837
|
+
currentSections = [section.id];
|
|
838
|
+
currentSize = sectionSize;
|
|
839
|
+
} else {
|
|
840
|
+
currentChunk.push(sectionContent);
|
|
841
|
+
currentSections.push(section.id);
|
|
842
|
+
currentSize += sectionSize;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Don't forget the last chunk
|
|
847
|
+
if (currentChunk.length > 0) {
|
|
848
|
+
chunks.push({
|
|
849
|
+
title: groupName,
|
|
850
|
+
content: currentChunk.join('\n\n---\n\n'),
|
|
851
|
+
sections: [...currentSections],
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
return chunks;
|
|
857
|
+
}
|