feat(P19AI1-1,P19AI1-2): 实现AI建议引擎与权限门禁后端
这个提交包含在:
@@ -0,0 +1,142 @@
|
||||
const { AiProviderError, buildAiProvider, __testables } = require("../../../src/services/ai/provider");
|
||||
|
||||
describe("AI provider service", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("preserves base path for openai-compatible chat completions endpoint", () => {
|
||||
expect(__testables.resolveChatCompletionsUrl("https://example.com/v1")).toBe(
|
||||
"https://example.com/v1/chat/completions",
|
||||
);
|
||||
expect(__testables.resolveChatCompletionsUrl("https://example.com/v1/")).toBe(
|
||||
"https://example.com/v1/chat/completions",
|
||||
);
|
||||
expect(__testables.resolveChatCompletionsUrl("https://example.com/chat/completions")).toBe(
|
||||
"https://example.com/chat/completions",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns deterministic mock suggestion payload", async () => {
|
||||
const provider = buildAiProvider({ aiProvider: "mock" });
|
||||
const result = await provider.generateSuggestion({
|
||||
targetType: "reimbursement",
|
||||
snapshot: {
|
||||
targetId: "r-1",
|
||||
formNumber: "BX-001",
|
||||
status: "reviewing",
|
||||
totalAmount: "123.00",
|
||||
itemCount: 2,
|
||||
budgetHint: { exceedsBudget: false, exceedsThreshold: true },
|
||||
invoiceSummary: { abnormalCount: 1 },
|
||||
},
|
||||
promptSpec: { systemPrompt: "system", userPrompt: "user" },
|
||||
});
|
||||
|
||||
expect(result.provider).toBe("mock");
|
||||
expect(result.suggestion.summary).toContain("BX-001");
|
||||
expect(result.suggestion.riskHints.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("maps http 4xx failures to upstream_4xx", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: vi.fn().mockResolvedValue({ error: "bad_request" }),
|
||||
});
|
||||
|
||||
const provider = buildAiProvider({
|
||||
aiProvider: "http",
|
||||
aiBaseUrl: "https://example.com/v1",
|
||||
aiModel: "gpt-test",
|
||||
aiTimeoutMs: 500,
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.generateSuggestion({
|
||||
targetType: "reimbursement",
|
||||
snapshot: {},
|
||||
promptSpec: { systemPrompt: "system", userPrompt: "user" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "upstream_4xx" });
|
||||
});
|
||||
|
||||
it("maps http 5xx failures to upstream_5xx", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: vi.fn().mockResolvedValue({ error: "service_unavailable" }),
|
||||
});
|
||||
|
||||
const provider = buildAiProvider({
|
||||
aiProvider: "http",
|
||||
aiBaseUrl: "https://example.com/v1",
|
||||
aiModel: "gpt-test",
|
||||
aiTimeoutMs: 500,
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.generateSuggestion({
|
||||
targetType: "purchase_request",
|
||||
snapshot: {},
|
||||
promptSpec: { systemPrompt: "system", userPrompt: "user" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "upstream_5xx" });
|
||||
});
|
||||
|
||||
it("maps aborted requests to timeout", async () => {
|
||||
global.fetch = vi.fn().mockRejectedValue({ name: "AbortError" });
|
||||
|
||||
const provider = buildAiProvider({
|
||||
aiProvider: "http",
|
||||
aiBaseUrl: "https://example.com/v1",
|
||||
aiModel: "gpt-test",
|
||||
aiTimeoutMs: 500,
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.generateSuggestion({
|
||||
targetType: "asset_card",
|
||||
snapshot: {},
|
||||
promptSpec: { systemPrompt: "system", userPrompt: "user" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "timeout" });
|
||||
});
|
||||
|
||||
it("rejects invalid json payload from upstream", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
choices: [{ message: { content: "```json\nnot-valid-json\n```" } }],
|
||||
usage: {},
|
||||
}),
|
||||
});
|
||||
|
||||
const provider = buildAiProvider({
|
||||
aiProvider: "http",
|
||||
aiBaseUrl: "https://example.com/v1",
|
||||
aiModel: "gpt-test",
|
||||
aiTimeoutMs: 500,
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.generateSuggestion({
|
||||
targetType: "asset_card",
|
||||
snapshot: {},
|
||||
promptSpec: { systemPrompt: "system", userPrompt: "user" },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AiProviderError);
|
||||
|
||||
await expect(
|
||||
provider.generateSuggestion({
|
||||
targetType: "asset_card",
|
||||
snapshot: {},
|
||||
promptSpec: { systemPrompt: "system", userPrompt: "user" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "invalid_response" });
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户