+ ),
+ };
+});
+
+function template(language = "chinese") {
+ const entries = [
+ ["retrieve_subgraph_hop", "2", "^[1-3]$"],
+ ["no_think_mode", "是", "^(是|否)$"],
+ [
+ "prompt_language",
+ language === "english" ? "英文" : "中文",
+ "^(中文|英文)$",
+ ],
+ ["domain", "常规", "^(医疗|金融|常规|法律法规)$"],
+ ["prompt_text", `${language} general`, "^[\\s\\S]{1,4096}$"],
+ ["synonym_merge_enable", "否", "^(是|否)$"],
+ ["disambiguation_enable", "否", "^(是|否)$"],
+ ];
+ return parseAidpGraphTemplate({
+ value: entries.map(([param_key, param_value, regexp]) => ({
+ param_key,
+ param_value,
+ regexp,
+ param_name: param_key,
+ param_desc: "",
+ is_modifiable: true,
+ ...(param_key === "prompt_text"
+ ? {
+ template: {
+ 常规: `${language} general`,
+ 金融: `${language} finance`,
+ },
+ }
+ : {}),
+ })),
+ });
+}
+
+function mount() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ api.listModels.mockResolvedValue({
+ models: [
+ {
+ model_name: "/models/embed",
+ display_name: "bge-m3",
+ model_type: "embedding",
+ },
+ { model_name: "graph-llm", display_name: "Graph LLM", model_type: "llm" },
+ {
+ model_name: "caption-vlm",
+ display_name: "Caption VLM",
+ model_type: "vlm",
+ },
+ ],
+ });
+ api.graphTemplate.mockImplementation(async (language) => template(language));
+ api.createKb.mockResolvedValue({ kds_id: "new-kb" });
+});
+
+describe("AKPR-D1-011 unified model query and graph templates", () => {
+ it("loads all three categories once, displays labels and submits model identifiers", async () => {
+ const user = userEvent.setup();
+ mount();
+ await screen.findByRole("option", { name: "bge-m3" });
+ await waitFor(() =>
+ expect(screen.getByLabelText("embedding")).toHaveValue("/models/embed"),
+ );
+ expect(api.listModels).toHaveBeenCalledExactlyOnceWith("", "KnowledgeBase");
+ expect(api.graphTemplate).not.toHaveBeenCalled();
+ await user.type(screen.getByLabelText("name"), "资料库");
+ await user.click(screen.getByLabelText("graph"));
+ await waitFor(() =>
+ expect(screen.getByLabelText("prompt")).toHaveValue("chinese general"),
+ );
+ await user.click(screen.getByText("aidpKnowledge.createSubmit"));
+ await waitFor(() => expect(api.createKb).toHaveBeenCalledOnce());
+ const body = api.createKb.mock.calls[0][0];
+ expect(body.embedding_model).toBe("/models/embed");
+ expect(body.graph_config).toEqual({
+ domain: "常规",
+ retrieve_subgraph_hop: "2",
+ no_think_mode: "是",
+ prompt_language: "中文",
+ prompt_text: "chinese general",
+ synonym_merge_enable: "否",
+ disambiguation_enable: "否",
+ llm_model_name: "graph-llm",
+ });
+ expect(body.topk).toBe(10);
+ expect(body.similarity).toBe(0.6);
+ expect(body.vlm_model).toBe("");
+ expect(body).not.toHaveProperty("smartsplit");
+ });
+
+ it("switches server templates by domain and language, preserves manual text and restores on request", async () => {
+ const user = userEvent.setup();
+ mount();
+ await screen.findByRole("option", { name: "bge-m3" });
+ await user.click(screen.getByLabelText("graph"));
+ await waitFor(() =>
+ expect(screen.getByLabelText("prompt")).toHaveValue("chinese general"),
+ );
+ await user.selectOptions(screen.getByLabelText("domain"), "金融");
+ await waitFor(() =>
+ expect(screen.getByLabelText("prompt")).toHaveValue("chinese finance"),
+ );
+ await user.clear(screen.getByLabelText("prompt"));
+ await user.type(screen.getByLabelText("prompt"), "手工提示词");
+ await user.selectOptions(screen.getByLabelText("language"), "英文");
+ await waitFor(() =>
+ expect(api.graphTemplate).toHaveBeenCalledWith("english"),
+ );
+ expect(screen.getByLabelText("prompt")).toHaveValue("手工提示词");
+ await user.click(screen.getByText("restore"));
+ expect(screen.getByLabelText("prompt")).toHaveValue("english finance");
+ });
+
+ it("omits hidden graph and VLM selections after disabling both capabilities", async () => {
+ const user = userEvent.setup();
+ mount();
+ await screen.findByRole("option", { name: "bge-m3" });
+ await user.type(screen.getByLabelText("name"), "资料库");
+ await user.click(screen.getByLabelText("graph"));
+ await waitFor(() =>
+ expect(screen.getByLabelText("prompt")).toHaveValue("chinese general"),
+ );
+ await user.click(screen.getByLabelText("graph"));
+ await user.click(screen.getByLabelText("caption"));
+ await user.click(screen.getByLabelText("caption"));
+ await user.click(screen.getByText("aidpKnowledge.createSubmit"));
+ await waitFor(() => expect(api.createKb).toHaveBeenCalledOnce());
+ expect(api.createKb.mock.calls[0][0].graph_config).toBeUndefined();
+ expect(api.createKb.mock.calls[0][0].vlm_model).toBe("");
+ });
+
+ it("blocks submission if the graph template fails to load", async () => {
+ api.graphTemplate.mockRejectedValue(new Error("unavailable"));
+ const user = userEvent.setup();
+ mount();
+ await screen.findByRole("option", { name: "bge-m3" });
+ await user.click(screen.getByLabelText("graph"));
+ await screen.findByText("aidpKnowledge.createConfigurationLoadFailed");
+ expect(
+ screen.getByText("aidpKnowledge.createSubmit").closest("button"),
+ ).toBeDisabled();
+ expect(api.createKb).not.toHaveBeenCalled();
+ });
+
+ it("validates complete templates and reads Chinese choices and booleans", () => {
+ expect(graphChoices(template(), "domain")).toEqual([
+ "医疗",
+ "金融",
+ "常规",
+ "法律法规",
+ ]);
+ expect(() => parseAidpGraphTemplate({ value: [] })).toThrow();
+ expect(graphBoolean("否")).toBe(false);
+ expect(graphBoolean("是")).toBe(true);
+ expect(graphBoolean(undefined)).toBeUndefined();
+ });
+});
diff --git a/test/automation/d1/AidpCreateKbSections.test.tsx b/test/automation/d1/AidpCreateKbSections.test.tsx
new file mode 100644
index 0000000000..e2644b96c5
--- /dev/null
+++ b/test/automation/d1/AidpCreateKbSections.test.tsx
@@ -0,0 +1,119 @@
+import React from "react";
+import { Form } from "antd";
+import type { FormInstance } from "antd";
+import type { TFunction } from "i18next";
+import { act, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import AidpCreateKbSections from "@/ext_components/aidp/components/AidpCreateKbSections";
+import zh from "@/public/locales/zh/common.json";
+import { parseAidpGraphTemplate } from "@/lib/aidpGraphConfig";
+
+const hints = [
+ "createChunkSectionHint",
+ "createChunkTokenHint",
+ "createOverlapPercentHint",
+ "createVectorSectionHint",
+ "createEmbeddingHint",
+ "createCaptionEnableHint",
+ "createRetrievalSectionHint",
+ "createSimilarityHint",
+ "createTopkHint",
+ "createGraphSectionHint",
+ "createGraphModelHint",
+ "createGraphHopHint",
+ "createGraphThinkingHint",
+ "createGraphLanguageHint",
+ "createGraphDomainHint",
+ "createGraphPromptHint",
+ "createGraphSynonymHint",
+ "createGraphDisambiguationHint",
+];
+const t = ((key: string) =>
+ (zh as Record
)[key] || key) as TFunction;
+const entries = [
+ ["retrieve_subgraph_hop", "2", "^[1-3]$"],
+ ["no_think_mode", "是", "^(是|否)$"],
+ ["prompt_language", "中文", "^(中文|英文)$"],
+ ["domain", "常规", "^(医疗|金融|常规|法律法规)$"],
+ ["prompt_text", "提取实体", "^[\\s\\S]{1,4096}$"],
+ ["synonym_merge_enable", "否", "^(是|否)$"],
+ ["disambiguation_enable", "否", "^(是|否)$"],
+];
+const template = parseAidpGraphTemplate({
+ value: entries.map(([param_key, param_value, regexp]) => ({
+ param_key,
+ param_value,
+ regexp,
+ param_name: param_key,
+ param_desc: "",
+ is_modifiable: true,
+ })),
+});
+
+describe("AKPR-D1-011 real creation controls and tooltip copy", () => {
+ it("renders all 18 localized hints and enforces the server character constraint", async () => {
+ let form: FormInstance;
+ function Wrapper() {
+ const [instance] = Form.useForm();
+ form = instance;
+ return (
+ {}}
+ />
+ );
+ }
+ render();
+ await act(async () => {
+ form.setFieldsValue({
+ graph_prompt_text: "提取实体",
+ graph_domain: "常规",
+ graph_prompt_language: "中文",
+ graph_thinking: false,
+ });
+ });
+ await userEvent
+ .setup()
+ .click(
+ screen.getByRole("switch", {
+ name: t("aidpKnowledge.createGraphEnable"),
+ }),
+ );
+ for (const hint of hints)
+ expect(
+ screen.getByLabelText(t(`aidpKnowledge.${hint}`)),
+ ).toBeInTheDocument();
+ expect(
+ document.getElementById("aidp-create-graph-panel"),
+ ).not.toHaveTextContent(t("aidpKnowledge.createGraphTopk"));
+ await act(async () => {
+ form.setFieldValue("graph_prompt_text", "字😀".repeat(2048));
+ });
+ await expect(
+ form!.validateFields(["graph_prompt_text"]),
+ ).resolves.toMatchObject({ graph_prompt_text: "字😀".repeat(2048) });
+ await act(async () => {
+ form.setFieldValue("graph_prompt_text", "字".repeat(4097));
+ });
+ await expect(
+ form!.validateFields(["graph_prompt_text"]),
+ ).rejects.toHaveProperty("errorFields");
+ await act(async () => {
+ form.setFieldValue("graph_prompt_text", "");
+ });
+ await expect(
+ form!.validateFields(["graph_prompt_text"]),
+ ).rejects.toHaveProperty("errorFields");
+ });
+});
diff --git a/test/automation/d1/AidpGroupNamesDisplay.test.tsx b/test/automation/d1/AidpGroupNamesDisplay.test.tsx
new file mode 100644
index 0000000000..b772186d4e
--- /dev/null
+++ b/test/automation/d1/AidpGroupNamesDisplay.test.tsx
@@ -0,0 +1,35 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+import AidpGroupNamesDisplay from "@/ext_components/aidp/components/AidpGroupNamesDisplay";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string, options?: Record) =>
+ options ? `${key} ${Object.values(options).join(" ")}` : key,
+ }),
+}));
+
+describe("AIDP accessible user group display", () => {
+ it("keeps a single row and opens the complete scrollable group list from +N", async () => {
+ const groups = ["Default Group", "Operations", "Product", "Support"];
+ const user = userEvent.setup();
+
+ render();
+
+ const summary = screen.getByTestId("aidp-group-names");
+ expect(summary).toHaveClass("flex-nowrap");
+ expect(screen.getByText("Default Group")).toBeInTheDocument();
+ expect(screen.getByText("Operations")).toBeInTheDocument();
+ expect(screen.queryByText("Product")).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: /\+2/ }));
+
+ for (const group of groups) {
+ expect(await screen.findAllByText(group)).not.toHaveLength(0);
+ }
+ expect(document.querySelector(".max-h-60.overflow-y-auto")).toBeTruthy();
+ });
+});
diff --git a/test/automation/d1/AidpImportDrawer.test.tsx b/test/automation/d1/AidpImportDrawer.test.tsx
new file mode 100644
index 0000000000..ecf090ccd4
--- /dev/null
+++ b/test/automation/d1/AidpImportDrawer.test.tsx
@@ -0,0 +1,265 @@
+import React from "react";
+import { App } from "antd";
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import AidpImportDrawer from "@/ext_components/aidp/components/AidpImportDrawer";
+import aidpKnowledgeService from "@/ext_components/aidp/services/aidpKnowledgeService";
+import { validateAidpFiles } from "@/services/uploadService";
+import type { AidpKnowledgeBaseItem } from "@/types/agentConfig";
+import type { AidpUploadResponse } from "@/ext_components/aidp/services/aidpKnowledgeService";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string, options?: Record) =>
+ options ? `${key} ${Object.values(options).join(" ")}` : key,
+ i18n: { language: "zh" },
+ }),
+}));
+vi.mock("@/ext_components/aidp/services/aidpKnowledgeService", () => ({
+ default: { uploadDocsWithProgress: vi.fn() },
+}));
+vi.mock("@/services/uploadService", () => ({ validateAidpFiles: vi.fn() }));
+const upload = vi.mocked(aidpKnowledgeService.uploadDocsWithProgress);
+const kb = { kds_id: "kb-1", kds_name: "Test KB" } as AidpKnowledgeBaseItem;
+const file = (name: string, size = 4) => new File([new Uint8Array(size)], name);
+const accepted = (files: File[]): AidpUploadResponse => ({
+ summary: { total: files.length, success: files.length, failed: 0 },
+ success_list: files.map((f) => ({
+ file_uuid: `uuid-${f.name}`,
+ file_ino_no: `ino-${f.name}`,
+ file_name: f.name,
+ file_type: "txt",
+ file_size: f.size,
+ first_upload_time: 1,
+ })),
+ failed_list: [],
+});
+function renderDrawer() {
+ const onClose = vi.fn(),
+ onDocsUploaded = vi.fn(),
+ onRefresh = vi.fn();
+ const view = render(
+
+
+ ,
+ );
+ return { ...view, onClose, onDocsUploaded, onRefresh };
+}
+const choose = (files: File[]) =>
+ fireEvent.change(document.body.querySelector('input[type="file"]')!, {
+ target: { files },
+ });
+const confirm = () => screen.getByRole("button", { name: "common.confirm" });
+const rows = () => document.body.querySelectorAll('[class*="fileRow_"]');
+
+describe("AKPR-D1-015 AIDP batch import drawer", () => {
+ beforeEach(() => {
+ upload.mockReset().mockImplementation(() => new Promise(() => {}));
+ vi.mocked(validateAidpFiles).mockReset().mockReturnValue({
+ valid: [],
+ invalidType: [],
+ oversized: [],
+ exceededCount: [],
+ });
+ });
+ it("uploads a selection together, shares progress and waits for acceptance before Confirm", async () => {
+ let progress!: (loaded: number, total: number) => void;
+ let resolve!: (result: AidpUploadResponse) => void;
+ upload.mockImplementation((_id, _files, onProgress) => {
+ progress = onProgress;
+ return new Promise((done) => {
+ resolve = done;
+ });
+ });
+ const { onClose, onDocsUploaded, onRefresh } = renderDrawer();
+ const files = [file("guide.txt", 2560), file("manual.txt", 5120)];
+ choose(files);
+ await waitFor(() => expect(upload).toHaveBeenCalledOnce());
+ expect(upload.mock.calls[0][1]).toEqual(files);
+ expect(confirm()).toBeDisabled();
+ expect(
+ screen.getByRole("button", { name: "common.cancel" }),
+ ).toBeDisabled();
+ act(() => progress(350, 1000));
+ expect(
+ document.body.querySelectorAll('svg[aria-label="35%"]'),
+ ).toHaveLength(2);
+ expect(screen.getByText("3K")).toBeTruthy();
+ expect(screen.getByText("5K")).toBeTruthy();
+ expect(
+ screen.queryByRole("button", { name: /aidpKnowledge.delete/ }),
+ ).toBeNull();
+ fireEvent.keyDown(document, { key: "Escape", code: "Escape" });
+ expect(onClose).not.toHaveBeenCalled();
+ act(() => progress(1000, 1000));
+ expect(
+ document.body.querySelectorAll('[class*="waitingSpinner_"]'),
+ ).toHaveLength(2);
+ expect(
+ document.body.querySelectorAll('[class*="dashedRow_"]'),
+ ).toHaveLength(2);
+ expect(confirm()).toBeDisabled();
+ await act(async () => resolve(accepted([...files].reverse())));
+ await waitFor(() => expect(confirm()).toBeEnabled());
+ expect(
+ document.body.querySelectorAll('[class*="successRow_"]'),
+ ).toHaveLength(2);
+ expect(onDocsUploaded).toHaveBeenCalledWith([
+ "ino-guide.txt",
+ "ino-manual.txt",
+ ]);
+ expect(onRefresh).toHaveBeenCalledOnce();
+ expect(onClose).not.toHaveBeenCalled();
+ expect(
+ screen.queryByRole("button", { name: /aidpKnowledge.delete/ }),
+ ).toBeNull();
+ await userEvent.setup().click(confirm());
+ expect(onClose).toHaveBeenCalledOnce();
+ expect(upload).toHaveBeenCalledOnce();
+ });
+ it("caps the list at 50 and sends all accepted files together", async () => {
+ renderDrawer();
+ choose(Array.from({ length: 51 }, (_, i) => file(`guide-${i}.txt`)));
+ await waitFor(() => expect(upload).toHaveBeenCalledOnce());
+ expect(upload.mock.calls[0][1]).toHaveLength(50);
+ expect(rows()).toHaveLength(50);
+ expect(
+ screen.getByText("aidpKnowledge.importFileCount 50 50"),
+ ).toBeTruthy();
+ expect(confirm()).toBeDisabled();
+ expect(
+ screen.queryByRole("button", { name: /aidpKnowledge.delete/ }),
+ ).toBeNull();
+ });
+ it("opens the file picker from the whole dropzone without a nested button", async () => {
+ const clickInput = vi.spyOn(HTMLInputElement.prototype, "click");
+ renderDrawer();
+
+ expect(
+ screen.queryByRole("button", { name: "aidpKnowledge.importSelectFiles" }),
+ ).toBeNull();
+ await userEvent.setup().click(screen.getByText("aidpKnowledge.uploadHint"));
+
+ expect(clickInput).toHaveBeenCalledOnce();
+ clickInput.mockRestore();
+ });
+ it("retains mixed results, deletes failures locally and sends only the next selection", async () => {
+ upload.mockImplementation(async (_id, files) => {
+ const good = files.filter((f) => f.name !== "bad.txt");
+ return {
+ ...accepted(good),
+ summary: {
+ total: files.length,
+ success: good.length,
+ failed: files.length - good.length,
+ },
+ failed_list: files
+ .filter((f) => f.name === "bad.txt")
+ .map((f) => ({
+ file_name: f.name,
+ reason_zh: "AIDP 拒绝上传",
+ reason_en: "Rejected",
+ })),
+ };
+ });
+ const { onClose } = renderDrawer();
+ fireEvent.drop(
+ document.body.querySelector('input[type="file"]')!.parentElement!,
+ {
+ dataTransfer: {
+ files: [file("bad.txt"), file("good.txt")],
+ types: ["Files"],
+ },
+ },
+ );
+ expect(await screen.findByText("AIDP 拒绝上传")).toBeTruthy();
+ await waitFor(() => expect(confirm()).toBeEnabled());
+ expect(rows()).toHaveLength(2);
+ expect(
+ document.body.querySelectorAll('[class*="successRow_"]'),
+ ).toHaveLength(1);
+ expect(
+ screen.queryByRole("button", { name: "aidpKnowledge.delete good.txt" }),
+ ).toBeNull();
+ await userEvent
+ .setup()
+ .click(
+ screen.getByRole("button", { name: "aidpKnowledge.delete bad.txt" }),
+ );
+ expect(rows()).toHaveLength(1);
+ expect(upload).toHaveBeenCalledOnce();
+ choose([file("second.txt")]);
+ await waitFor(() => expect(upload).toHaveBeenCalledTimes(2));
+ expect(upload.mock.calls[1][1].map((f) => f.name)).toEqual(["second.txt"]);
+ expect(rows()).toHaveLength(2);
+ expect(onClose).not.toHaveBeenCalled();
+ expect(screen.queryByRole("button", { name: /retry/i })).toBeNull();
+ });
+ it("marks a network error against every file and enables Confirm", async () => {
+ upload.mockRejectedValue(new Error("Network interrupted"));
+ const { onClose, onDocsUploaded } = renderDrawer();
+ choose([file("one.txt"), file("two.txt")]);
+ expect(await screen.findAllByText("Network interrupted")).toHaveLength(2);
+ await waitFor(() => expect(confirm()).toBeEnabled());
+ expect(
+ screen.getAllByRole("button", { name: /aidpKnowledge.delete/ }),
+ ).toHaveLength(2);
+ expect(onDocsUploaded).not.toHaveBeenCalled();
+ await userEvent.setup().click(confirm());
+ expect(onClose).toHaveBeenCalledOnce();
+ expect(upload).toHaveBeenCalledOnce();
+ });
+ it("fails missing response entries instead of claiming success", async () => {
+ upload.mockImplementation(async (_id, files) => accepted(files.slice(1)));
+ renderDrawer();
+ choose([file("missing.txt"), file("accepted.txt")]);
+ expect(await screen.findByText("aidpKnowledge.uploadFailed")).toBeTruthy();
+ expect(
+ screen.getByRole("button", { name: "aidpKnowledge.delete missing.txt" }),
+ ).toBeTruthy();
+ expect(
+ document.body.querySelectorAll('[class*="successRow_"]'),
+ ).toHaveLength(1);
+ });
+ it("shows local validation failures without uploading", async () => {
+ vi.mocked(validateAidpFiles).mockImplementation((files) => ({
+ valid: [],
+ invalidType: files,
+ oversized: [],
+ exceededCount: [],
+ }));
+ renderDrawer();
+ choose([file("invalid.txt")]);
+ expect(
+ await screen.findByText("aidpKnowledge.invalidFileType 1"),
+ ).toBeTruthy();
+ expect(upload).not.toHaveBeenCalled();
+ expect(confirm()).toBeEnabled();
+ expect(
+ screen.getByRole("button", { name: "aidpKnowledge.delete invalid.txt" }),
+ ).toBeTruthy();
+ });
+ it("aborts the browser request on unmount", async () => {
+ const { unmount } = renderDrawer();
+ choose([file("guide.txt")]);
+ await waitFor(() => expect(upload).toHaveBeenCalledOnce());
+ const signal = upload.mock.calls[0][3];
+ expect(signal.aborted).toBe(false);
+ unmount();
+ expect(signal.aborted).toBe(true);
+ });
+});
diff --git a/test/automation/d1/AidpUploadService.test.tsx b/test/automation/d1/AidpUploadService.test.tsx
new file mode 100644
index 0000000000..c9caffec38
--- /dev/null
+++ b/test/automation/d1/AidpUploadService.test.tsx
@@ -0,0 +1,144 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import aidpKnowledgeService from "@/ext_components/aidp/services/aidpKnowledgeService";
+import { handleSessionExpired } from "@/lib/session";
+
+vi.mock("@/services/api", () => ({
+ API_ENDPOINTS: {
+ aidpMgmt: {
+ kbDocuments: (id: string) =>
+ `/api/aidp-mgmt/knowledge-bases/${id}/documents`,
+ },
+ },
+ fetchWithErrorHandling: vi.fn(),
+}));
+vi.mock("@/lib/auth", () => ({ getAuthHeaders: vi.fn() }));
+vi.mock("@/lib/logger", () => ({ default: { error: vi.fn() } }));
+vi.mock("@/lib/session", () => ({ handleSessionExpired: vi.fn() }));
+
+class XhrMock {
+ static last: XhrMock;
+ constructor() {
+ XhrMock.last = this;
+ }
+ upload = {
+ onprogress: null as
+ | ((event: {
+ lengthComputable: boolean;
+ loaded: number;
+ total: number;
+ }) => void)
+ | null,
+ onload: null as (() => void) | null,
+ };
+ open = vi.fn();
+ send = vi.fn();
+ setRequestHeader = vi.fn();
+ abort = vi.fn(() => this.onabort?.());
+ withCredentials = false;
+ status = 200;
+ statusText = "";
+ responseText = "";
+ onload: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ onabort: (() => void) | null = null;
+}
+
+describe("AKPR-D1-015 AIDP multipart progress transport", () => {
+ beforeEach(() => {
+ vi.stubGlobal("XMLHttpRequest", XhrMock);
+ vi.mocked(handleSessionExpired).mockClear();
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+ it("sends repeated files in one authenticated multipart request and waits for the response", async () => {
+ const files = [new File(["one"], "one.txt"), new File(["two"], "two.txt")];
+ const progress = vi.fn();
+ const controller = new AbortController();
+ const abortListener = vi.spyOn(controller.signal, "removeEventListener");
+ const promise = aidpKnowledgeService.uploadDocsWithProgress(
+ "kb-1",
+ files,
+ progress,
+ controller.signal,
+ );
+ const xhr = XhrMock.last;
+ expect(xhr.open).toHaveBeenCalledWith(
+ "POST",
+ expect.stringContaining("/knowledge-bases/kb-1/documents"),
+ );
+ expect(xhr.send).toHaveBeenCalledOnce();
+ expect((xhr.send.mock.calls[0][0] as FormData).getAll("files")).toEqual(
+ files,
+ );
+ expect(xhr.withCredentials).toBe(true);
+ expect(xhr.setRequestHeader).not.toHaveBeenCalledWith(
+ "Content-Type",
+ expect.anything(),
+ );
+ xhr.upload.onprogress?.({ lengthComputable: true, loaded: 50, total: 100 });
+ expect(progress).toHaveBeenLastCalledWith(50, 100);
+ xhr.upload.onload?.();
+ expect(progress).toHaveBeenLastCalledWith(1, 1);
+ let resolved = false;
+ void promise.then(() => {
+ resolved = true;
+ });
+ await Promise.resolve();
+ expect(resolved).toBe(false);
+ xhr.responseText = JSON.stringify({
+ summary: { total: 2, success: 0, failed: 2 },
+ success_list: [],
+ failed_list: [
+ { file_name: "one.txt", reason_zh: "Rejected" },
+ { file_name: "two.txt", reason_zh: "Rejected" },
+ ],
+ });
+ xhr.onload?.();
+ expect((await promise).failed_list).toHaveLength(2);
+ expect(abortListener).toHaveBeenCalledWith("abort", expect.any(Function));
+ });
+ it("preserves upstream rejection reasons", async () => {
+ const promise = aidpKnowledgeService.uploadDocsWithProgress(
+ "kb",
+ [new File(["x"], "x.txt")],
+ vi.fn(),
+ new AbortController().signal,
+ );
+ const rejected = expect(promise).rejects.toThrow("AIDP rejected request");
+ XhrMock.last.status = 502;
+ XhrMock.last.responseText = JSON.stringify({
+ details: { upstream_reason: "AIDP rejected request" },
+ });
+ XhrMock.last.onload?.();
+ await rejected;
+ });
+ it("triggers existing session handling on an unauthorized response", async () => {
+ const promise = aidpKnowledgeService.uploadDocsWithProgress(
+ "kb",
+ [],
+ vi.fn(),
+ new AbortController().signal,
+ );
+ const rejected = expect(promise).rejects.toThrow("HTTP 401");
+ XhrMock.last.status = 401;
+ XhrMock.last.onload?.();
+ await rejected;
+ expect(handleSessionExpired).toHaveBeenCalledOnce();
+ });
+ it("aborts on cancellation without pretending the request succeeded", async () => {
+ const controller = new AbortController();
+ const promise = aidpKnowledgeService.uploadDocsWithProgress(
+ "kb",
+ [],
+ vi.fn(),
+ controller.signal,
+ );
+ const rejected = expect(promise).rejects.toMatchObject({
+ name: "AbortError",
+ });
+ controller.abort();
+ await rejected;
+ expect(XhrMock.last.abort).toHaveBeenCalledOnce();
+ });
+});
diff --git a/test/backend/database/test_user_tenant_db.py b/test/backend/database/test_user_tenant_db.py
index b8b8bcb153..41c8accabe 100644
--- a/test/backend/database/test_user_tenant_db.py
+++ b/test/backend/database/test_user_tenant_db.py
@@ -175,6 +175,19 @@ def test_get_user_email_map_returns_only_non_empty_emails(monkeypatch, mock_sess
}
query.filter.assert_called_once()
+def test_get_user_email_map_applies_optional_tenant_filter(monkeypatch, mock_session):
+ session, query = mock_session
+ filtered = query.filter.return_value
+ scoped = filtered.filter.return_value
+ scoped.all.return_value = [("user-1", "creator@example.com")]
+ context = MagicMock()
+ context.__enter__.return_value = session
+ monkeypatch.setattr("backend.database.user_tenant_db.get_db_session", lambda: context)
+ assert get_user_email_map(["user-1"], tenant_id="tenant-a") == {"user-1": "creator@example.com"}
+ filtered.filter.assert_called_once()
+ scoped.all.assert_called_once()
+
+
def test_get_user_tenant_by_user_id_not_found(monkeypatch, mock_session):
"""Test retrieval of user tenant relationship when record does not exist"""
session, query = mock_session
diff --git a/test/cases/d1/aidp-knowledge-detail.yaml b/test/cases/d1/aidp-knowledge-detail.yaml
new file mode 100644
index 0000000000..15b0898169
--- /dev/null
+++ b/test/cases/d1/aidp-knowledge-detail.yaml
@@ -0,0 +1,85 @@
+schema_version: '1.0'
+stage: D1
+module: AIDP-KNOWLEDGE
+cases:
+ - case_id: AKDT-D1-001
+ feature_id: AKDT-001
+ requirement_ids: [AKDT-001]
+ business_rule_ids: [AKDT-RULE-002, AKDT-RULE-004, AKDT-RULE-006]
+ title: Task state display separates filter values from returned status
+ type: FE-COMP
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify each documented numeric filter stays separate from returned status strings, provisional mappings remain explicit, and unknown raw values are preserved.
+ preconditions:
+ - Render the task status helpers and table with statuses COMPLETED, EXTRACTING, FAILED, UPLOADING, GRAPHFAILED and an unknown provider value.
+ test_data: {profile: mock, filter_values: [0, 1, 2, 3, 4, 5]}
+ steps:
+ - order: 1
+ action: Select each task status filter.
+ expected: The request receives the matching integer 0, 1, 2, 3, 4 or 5, including 0 for All.
+ - order: 2
+ action: Render records with the listed provider statuses.
+ expected: Known statuses show their documented labels, FAILED and UPLOADING use the recorded provisional mappings, and an unknown value remains visible as unknown with its raw value.
+ - order: 3
+ action: Open failure details for a record without an error code or update time.
+ expected: Missing fields show an em dash and do not use retry metadata or the current time.
+ expected_results:
+ - Request filters are numeric and never use a returned status string.
+ - The provisional mappings are isolated in one conversion function.
+ - Unknown or absent provider fields are not reported as success or as a specific failure.
+ forbidden_side_effects:
+ - No status is inferred from the selected filter.
+ - No missing error code is fabricated from a retry response.
+ unit_boundary: AIDP detail task status conversion and failure drawer field projection
+ inputs: [status filter selection, raw task status, optional error fields]
+ assertions:
+ - All sends status 0 and the other labels send their defined values through 5.
+ - FAILED and UPLOADING are visibly documented as provisional mappings.
+ - Unknown raw statuses are retained and missing error values render as an em dash.
+ - case_id: AKDT-D1-002
+ feature_id: AKDT-001
+ requirement_ids: [AKDT-001]
+ business_rule_ids: [AKDT-RULE-001, AKDT-RULE-007, AKDT-RULE-009]
+ title: Detail information reads configuration and keeps edit controls scoped
+ type: FE-COMP
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify the detail page projects returned configuration, preserves false and zero values, and keeps metadata, permission and Top K edits in their intended controls.
+ preconditions:
+ - Render a KB detail response with graph_config represented once as JSON text and once as an object.
+ - Include explicit false and zero values, private/shared permission variants and one missing safety value.
+ - Grant edit access and provide an available user-group option for the edit-control checks.
+ test_data: {profile: mock, graph_config_forms: [json_string, object], topk_range: [1, 100]}
+ steps:
+ - order: 1
+ action: Render the configuration and graph sections for each response shape.
+ expected: Values appear in their corresponding named fields and a model name is not duplicated.
+ - order: 2
+ action: Inspect false, zero and missing values.
+ expected: False and zero remain off/zero; a missing safety value is shown as unknown.
+ - order: 3
+ action: Render private and shared permission records with group IDs.
+ expected: Scope and group names follow local permission data and the viewer's group-read permission.
+ - order: 4
+ action: Open the header edit, lower permission edit and vector Top K edit.
+ expected: The header form shows name, description and visible permission/group fields without a collapsed advanced section; the lower editor contains only permission and group fields; Top K uses the create-page slider and numeric value control.
+ expected_results:
+ - JSON text and object graph settings render consistently.
+ - Boolean false and numeric zero remain distinct from missing values.
+ - Effective permission and available group names come from the permission layer.
+ - The lower permission save sends only permission/group fields; the header form remains the only name/description editor.
+ - The Top K editor uses a 1-100 slider with a numeric value control.
+ forbidden_side_effects:
+ - No group IDs or AIDP task history are written during rendering.
+ - No missing setting is presented as enabled or disabled.
+ unit_boundary: AIDP knowledge detail configuration, permission presentation and edit controls
+ inputs: [AIDP detail payload, graph_config value, local group permission record, header and permission edit modes, vector Top K value]
+ assertions:
+ - Graph fields and the two separate Top K values render under the correct labels.
+ - Explicit false and zero are preserved while absent values remain unknown.
+ - Group names render only with group:read permission.
+ - Header and lower permission edit forms expose their separate allowed fields and omit the advanced-options collapse.
+ - The vector Top K edit control matches the creation page slider layout and range.
diff --git a/test/cases/d1/aidp-knowledge-page-visuals.yaml b/test/cases/d1/aidp-knowledge-page-visuals.yaml
new file mode 100644
index 0000000000..1e66144434
--- /dev/null
+++ b/test/cases/d1/aidp-knowledge-page-visuals.yaml
@@ -0,0 +1,128 @@
+schema_version: '1.0'
+stage: D1
+module: AIDP-KNOWLEDGE
+cases:
+ - case_id: AKPR-D1-013
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-001, AKPR-RULE-002, AKPR-RULE-015]
+ title: Overview tutorial and knowledge cards follow the approved visual hierarchy
+ type: FE-COMP
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify the overview structure keeps clear guide spacing and the prototype card layout without changing the shared list state.
+ preconditions:
+ - Render the AIDP overview with four knowledge bases and a 1382-pixel desktop viewport.
+ test_data: {profile: mock, viewport_width: 1382, card_count: 4}
+ steps:
+ - order: 1
+ action: Render the expanded guide.
+ expected: The explanation heading and paragraph are distinct from three step cards with at least 24 pixels of separation.
+ - order: 2
+ action: Inspect one knowledge card.
+ expected: The fixed icon, name and menu occupy the first row, description uses the full card width, and update time, file count and capacity share one footer row.
+ - order: 3
+ action: Render a final page with fewer cards.
+ expected: Remaining cards keep the same width and height and the page footer stays at the bottom of the content area.
+ expected_results:
+ - Tutorial content has readable vertical separation and matches the three-step design.
+ - Card descriptions do not share a narrow icon column.
+ - Card metadata aligns on one row and cards do not stretch to fill unused grid space.
+ forbidden_side_effects:
+ - No fabricated timestamps, file counts or capacity values are rendered.
+ - No ES page component or global style changes are required.
+ unit_boundary: AIDP guide and card DOM structure at desktop viewport
+ inputs: [expanded guide, card metadata, short final page]
+ assertions:
+ - The guide paragraph and step cards have a rendered gap of at least 24 pixels.
+ - Every card exposes one footer row containing update time, document count and capacity.
+ - Card grid uses auto-fit columns and a short last page leaves card dimensions unchanged.
+ - case_id: AKPR-D1-014
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-010, AKPR-RULE-016, AKPR-RULE-017]
+ title: Creation page uses 18px titles and submits directly from its single form
+ type: FE-COMP
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify the AIDP create page uses the approved title sizes and single-page submit flow while keeping section controls aligned.
+ preconditions:
+ - Render the AIDP creation page at desktop width with a valid mock create response.
+ test_data: {profile: mock, viewport_width: 1382, chunk_modes: [0, 1]}
+ steps:
+ - order: 1
+ action: Render the page header and form.
+ expected: The creation form uses the available content width; breadcrumb labels are 18px and no step indicator or optional upload stage is rendered.
+ - order: 2
+ action: Inspect graph and VLM toggle groups.
+ expected: Each switch sits beside its corresponding label and help icon.
+ - order: 3
+ action: Inspect section headings and fields that share a row.
+ expected: Both controls use the same label structure and their input or slider starts differ by no more than one pixel.
+ - order: 4
+ action: Fill required fields and submit the create form.
+ expected: One create request is sent, no upload request is sent, and success navigates to the created knowledge base detail page.
+ - order: 5
+ action: Render the detail information tab for a seeded knowledge base.
+ expected: The detail breadcrumb and configuration, graph and permission section headings use 18px text.
+ expected_results:
+ - No fixed narrow modal-sized content column constrains the create page.
+ - The create breadcrumb title and form section headings use 18px text.
+ - The form creates the knowledge base directly without an intermediate step or upload stage.
+ - Switches are adjacent to their own labels rather than the far edge of the row.
+ - Paired model, chunking and retrieval controls align consistently.
+ forbidden_side_effects:
+ - No global Ant Design or ES knowledge-base style override is introduced.
+ - No change to values submitted for chunk_mode 0 or 1.
+ unit_boundary: AIDP create form title, direct-submit flow, section layout and toggle grouping
+ inputs: [desktop viewport, graph toggle, VLM toggle, paired model controls, valid create response]
+ assertions:
+ - The creation header and form fill the available page content width.
+ - The current create breadcrumb and section heading computed font sizes are 18px.
+ - The current detail breadcrumb and detail information section heading computed font sizes are 18px.
+ - No stepper or optional-upload control is rendered; one submit calls create once and does not call upload.
+ - Switches are grouped within 12 pixels of their labels.
+ - Paired controls use consistent label and field alignment.
+ - case_id: AKPR-D1-015
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-018]
+ title: AIDP import drawer uploads on selection and keeps results until confirmation
+ type: FE-COMP
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify selection immediately uploads valid files, actual transfer progress is distinct from AIDP acceptance, and Confirm closes without a second upload.
+ preconditions:
+ - Render the import drawer with a writable mock knowledge base and a mocked upload API response.
+ test_data: {profile: mock, file: guide.txt, upload_bytes: [716800, 2621440]}
+ steps:
+ - order: 1
+ action: Select multiple supported files together.
+ expected: One multipart batch starts automatically; every row has a dashed gray border and Confirm is disabled while the request is active.
+ - order: 2
+ action: Report intermediate progress, then complete browser transfer without resolving the upload response.
+ expected: Each uploading ring shows the same batch percentage; the second line only shows the file size. Transfer completion changes rings to waiting indicators, borders stay dashed, Confirm remains disabled, and no delete action is present.
+ - order: 3
+ action: Resolve the upload response as accepted.
+ expected: The row changes to a solid near-black border and the accepted file id is passed to the parent refresh flow; the drawer remains open.
+ - order: 4
+ action: Click Confirm.
+ expected: The drawer closes and no additional upload request is sent.
+ expected_results:
+ - Choosing a valid file immediately uploads it through the existing AIDP route.
+ - Progress text and ring are derived from the upload event, not a timer.
+ - A successful response changes the row border to solid near-black.
+ - Results remain visible until Confirm, which never sends files again.
+ forbidden_side_effects:
+ - No per-file transferred-byte text or batch-progress label is rendered.
+ - No retry action is rendered in the import row.
+ - No change to the ES upload component.
+ unit_boundary: AIDP import drawer automatic upload, progress, result retention and confirmation
+ inputs: [mock knowledge base, supported file, progress event, accepted upload response]
+ assertions:
+ - The upload API is called on selection and not on confirmation.
+ - The rows share batch percentage rings and display only their own total file sizes while uploading.
+ - Mixed responses map by file name; accepted rows become solid near-black and are reported to their parent. Only failed rows show delete; deleting removes a frontend row without any network call. Network errors mark all rows failed and enable Confirm. A 50-file selection makes one request, and further batches contain only newly selected files.
diff --git a/test/cases/d1/aidp-knowledge-pages-refactor.yaml b/test/cases/d1/aidp-knowledge-pages-refactor.yaml
new file mode 100644
index 0000000000..873d16f123
--- /dev/null
+++ b/test/cases/d1/aidp-knowledge-pages-refactor.yaml
@@ -0,0 +1,502 @@
+schema_version: '1.0'
+stage: D1
+module: AIDP-KNOWLEDGE
+cases:
+ - case_id: AKPR-D1-001
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-001]
+ title: Overview guide expands, collapses and keeps the fixed three steps
+ type: FE-COMP
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify the AIDP-only guide renders the knowledge base explanation with the fixed steps and toggles without disturbing page state.
+ preconditions:
+ - Render the AIDP overview component with an empty knowledge base list.
+ test_data: {profile: mock, locale: zh-CN}
+ steps:
+ - order: 1
+ action: Mount the overview page.
+ expected: The guide is expanded and lists create knowledge base, upload files and configure the knowledge base in an agent.
+ - order: 2
+ action: Collapse the guide.
+ expected: The guide body is hidden while the reopen entry stays visible and operable.
+ - order: 3
+ action: Reopen the guide.
+ expected: The explanation and the three steps are visible again with unchanged search and view state.
+ expected_results:
+ - The explanation and the three documented steps are visible when expanded.
+ - Collapse hides only the guide body.
+ - Reopening restores the same content order.
+ forbidden_side_effects:
+ - No backend request is issued by expanding or collapsing the guide.
+ - No search, page or view mode change.
+ unit_boundary: AIDP knowledge guide expansion state and fixed step labels
+ inputs:
+ - initial expanded state
+ - collapse click
+ - reopen click
+ assertions:
+ - Guide content is visible on first entry.
+ - The reopen entry remains visible after collapse.
+ - The three step labels match the fixed order.
+ - Guide toggling issues no request.
+ - case_id: AKPR-D1-002
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-002, AKPR-RULE-004]
+ title: Cards are the default view and switching or hiding columns keeps the result set
+ type: FE-COMP
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify default card rendering, table switching and column visibility control over one shared paginated result set.
+ preconditions:
+ - Render the overview with a fixed page of knowledge bases and a recorded search and page number.
+ test_data: {profile: mock, page_size: 10, total: 23}
+ steps:
+ - order: 1
+ action: Mount the overview with existing results.
+ expected: Knowledge bases render as cards.
+ - order: 2
+ action: Switch to the table view.
+ expected: The same records render as table rows with the current keyword and page preserved.
+ - order: 3
+ action: Hide one optional column and then restore defaults.
+ expected: The column disappears and returns while the name and action columns stay present throughout.
+ expected_results:
+ - The card view is the default.
+ - Switching views keeps keyword, page and totals identical.
+ - Hiding and restoring columns changes only local visibility.
+ forbidden_side_effects:
+ - No mutating endpoint is called by view or column changes.
+ - No record is dropped or duplicated by switching views.
+ - Required columns cannot be hidden.
+ unit_boundary: Overview view mode and column visibility state
+ inputs:
+ - default view mode
+ - card to table switch
+ - hidden column set
+ - restore defaults action
+ assertions:
+ - Initial view mode is cards.
+ - Table switch preserves keyword, page and total.
+ - Hidden optional column is absent and restorable.
+ - Name and action columns stay visible.
+ - case_id: AKPR-D1-003
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-003]
+ title: Independent name search drives the keyword query and resets the page
+ type: FE-COMP
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify the single search box reuses the existing keyword query, resets to page one and keeps table headers free of search controls.
+ preconditions:
+ - Render the overview with a recorded non-first page and a stubbed list service.
+ test_data: {profile: mock, keyword: finance, page: 3}
+ steps:
+ - order: 1
+ action: Type a keyword into the page search box and wait for the debounce.
+ expected: The list service is called with the keyword and page one for both views.
+ - order: 2
+ action: Clear the search box.
+ expected: The unfiltered permission-scoped set is requested again.
+ - order: 3
+ action: Inspect the table header row.
+ expected: No column exposes a search or filter control.
+ expected_results:
+ - The keyword query is reused with a page reset.
+ - Clearing restores the unfiltered set.
+ - Column headers contain no search entry.
+ forbidden_side_effects:
+ - No request per keystroke outside the debounce.
+ - No column filter parameter is sent.
+ unit_boundary: Overview search debounce and page reset with the existing keyword parameter
+ inputs:
+ - keyword text
+ - debounce interval elapse
+ - clear action
+ - table header render
+ assertions:
+ - listKbs receives keyword and page one after debounce.
+ - Clearing the box requests the unfiltered set.
+ - No header search control exists.
+ - case_id: AKPR-D1-004
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-005, AKPR-RULE-009]
+ title: File view targets one knowledge base and restores the previous list state on return
+ type: FE-COMP
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify the full-page file view scoping, the preserved list state on return and the cleanup of the previous base document state.
+ preconditions:
+ - Render the overview with two knowledge bases and a recorded search, page, view mode and column setting.
+ test_data: {profile: mock, selected_base: kb-alpha, other_base: kb-beta}
+ steps:
+ - order: 1
+ action: Select a knowledge base.
+ expected: Only that base files render and the overview is replaced by the full-page file view.
+ - order: 2
+ action: Return to the overview.
+ expected: The previous keyword, page, view mode and column setting are intact.
+ - order: 3
+ action: Resolve a delayed document response from the first base after selecting the second.
+ expected: The delayed payload is discarded and the second base files remain.
+ expected_results:
+ - Files belong to the selected base only.
+ - Returning restores the previous list state.
+ - A stale response never overwrites the current base.
+ forbidden_side_effects:
+ - No cross-base document leakage.
+ - No polling left running after leaving the file view.
+ - No list state reset on return.
+ unit_boundary: List and file view switching, document state reset and polling cleanup
+ inputs:
+ - selected base id
+ - return action
+ - delayed response for a previous base
+ assertions:
+ - File list requests carry the selected base id.
+ - Return renders the preserved list state.
+ - A late response for the previous base is ignored.
+ - Polling state is cleared when the view is left.
+ - case_id: AKPR-D1-005
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-006]
+ title: AIDP list metadata normalizes real values and marks unknown ones as absent
+ type: BE-UT
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify the list response normalization of private and public type, capacity and missing values without fabricating data.
+ preconditions:
+ - Provide raw AIDP list rows with mixed boolean, numeric, string and missing metadata values.
+ test_data: {profile: mock, cases: ['is_private true', 'is_private 0', 'is_private string true', 'missing is_private', 'current_cap 2.5', 'missing current_cap', 'missing document count']}
+ steps:
+ - order: 1
+ action: Normalize rows with boolean, numeric and string forms of is_private.
+ expected: A stable personal or enterprise type is produced for every form.
+ - order: 2
+ action: Normalize rows without a type, capacity or count value.
+ expected: Those fields are reported as unknown instead of a fabricated default.
+ - order: 3
+ action: Normalize a valid personal capacity.
+ expected: The documented unit value is preserved unchanged.
+ expected_results:
+ - Real values survive normalization.
+ - Unknown values are distinguishable from a real zero.
+ - Capacity is not estimated from file sizes.
+ forbidden_side_effects:
+ - No Nexent group permission is used to infer the base type.
+ - No synthetic zero count or capacity is produced.
+ - No extra upstream request is issued per row.
+ unit_boundary: AIDP list metadata normalization helpers
+ inputs:
+ - is_private variants
+ - current_cap values
+ - document count presence
+ assertions:
+ - Boolean, integer and string is_private forms normalize consistently.
+ - A missing count is distinguishable from zero.
+ - A missing capacity is reported as unknown.
+ - A valid capacity keeps its unit.
+ - case_id: AKPR-D1-006
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-007]
+ title: Read-only and unavailable knowledge bases expose no modification entry
+ type: FE-COMP
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify that permission and lifecycle restrictions remove every executable modification entry in cards, table rows and the file view.
+ preconditions:
+ - Render the overview with one editable, one read-only and one unavailable knowledge base.
+ test_data: {profile: mock, permissions: [EDIT, READ_ONLY], resource_status: [available, unavailable]}
+ steps:
+ - order: 1
+ action: Render the card menu and table actions for each permission.
+ expected: Only the editable base offers executable edit and delete entries.
+ - order: 2
+ action: Open the file view of a read-only base.
+ expected: Upload and delete entries are absent or disabled while browsing stays possible.
+ - order: 3
+ action: Render an unavailable base.
+ expected: No executable modification entry is offered and its state stays visible.
+ expected_results:
+ - Modification entries follow the effective permission.
+ - Unavailable bases stay visible without modification entries.
+ - No request is emitted from a disabled entry.
+ forbidden_side_effects:
+ - No modification request for read-only or unavailable bases.
+ - No hidden menu item remains clickable.
+ - No downgrade of the existing visibility rules.
+ unit_boundary: Card menu and table action rendering under permission and lifecycle states
+ inputs:
+ - effective permission
+ - resource_status
+ - menu open state
+ assertions:
+ - Read-only rows render no executable modification entry.
+ - Unavailable rows keep their lifecycle indicator.
+ - No request leaves a disabled entry.
+ - case_id: AKPR-D1-007
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-002]
+ title: First-load empty, filtered empty, loading and failure states stay distinct
+ type: FE-COMP
+ priority: P1
+ status: active
+ automation: automated
+ objective: Verify the four overview states render distinct messages and remain localized.
+ preconditions:
+ - Render the overview with a stubbed service returning an empty set, an empty filtered set, a pending promise and an error.
+ test_data: {profile: mock, states: ['first empty', 'filtered empty', loading, failed], locales: [zh, en]}
+ steps:
+ - order: 1
+ action: Resolve an empty list without a keyword.
+ expected: A first-use empty state renders instead of a search-empty state.
+ - order: 2
+ action: Resolve an empty list with an active keyword.
+ expected: A search-empty state renders with the keyword context.
+ - order: 3
+ action: Keep the request pending and then reject it.
+ expected: Loading and failure states render distinctly and the failure state offers a retry path.
+ expected_results:
+ - All four states are distinguishable.
+ - Both locales resolve every state string.
+ forbidden_side_effects:
+ - No fabricated placeholder rows.
+ - No silent failure rendering as an empty state.
+ unit_boundary: Overview empty, loading and failure state rendering
+ inputs:
+ - empty payload
+ - empty filtered payload
+ - pending request
+ - rejected request
+ - locale
+ assertions:
+ - First empty and filtered empty differ.
+ - Loading state precedes data.
+ - Failure state is distinct and localized.
+ - No placeholder rows are invented.
+ - case_id: AKPR-D1-008
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-010]
+ title: Two-step creation validates locally, preserves state and creates exactly once
+ type: FE-COMP
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify step navigation without creation, state preservation across steps and single creation before upload.
+ preconditions:
+ - Render the AIDP creation page with stubbed create and upload services and a selected local file.
+ test_data: {profile: mock, files: 2, failing_uploads: 1}
+ steps:
+ - order: 1
+ action: Fill step one and press next.
+ expected: Validation runs with no create request issued and step two renders.
+ - order: 2
+ action: Return to step one.
+ expected: Form values, conditional sections and the selected files are preserved.
+ - order: 3
+ action: Submit with one failing upload.
+ expected: Creation happens once and the created base and the failed upload are reported separately.
+ - order: 4
+ action: Submit again after the failed upload.
+ expected: No second knowledge base is created and the retry targets the failed file only.
+ expected_results:
+ - Step one never creates a resource.
+ - State survives backward navigation.
+ - Creation happens exactly once before uploads.
+ forbidden_side_effects:
+ - No create request from the next action.
+ - No duplicate base after retry or double click.
+ - No upload before a successful create.
+ unit_boundary: Creation page step state, submit guard and create-then-upload sequencing
+ inputs:
+ - step one form values
+ - selected files
+ - upload failure
+ - repeated submit
+ assertions:
+ - Next issues no create request.
+ - Backward navigation keeps values and files.
+ - Exactly one create request precedes uploads.
+ - A failed upload does not trigger a second create.
+ - case_id: AKPR-D1-009
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-011]
+ title: Creation defaults and chunking mapping follow the confirmed values
+ type: BE-UT
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify preserved creation defaults, the chunk mode enumeration and the overlap-to-token conversion with its bounds.
+ preconditions:
+ - Prepare create payloads with missing defaults, both chunk modes and boundary overlap percentages.
+ test_data: {profile: mock, chunk_tokens: [1024, 512], percents: [12.5, 50], modes: [smart, legal]}
+ steps:
+ - order: 1
+ action: Submit a payload without explicit chunking or retrieval values.
+ expected: The existing defaults apply unchanged and the new graph parameters use their documented defaults.
+ - order: 2
+ action: Submit each chunk mode.
+ expected: The chunk mode serializes as 0 for smart splitting and 1 for legal clauses.
+ - order: 3
+ action: Submit overlap percentages including the boundaries.
+ expected: The overlap becomes the floor conversion in integer tokens and any value above half the chunk size is rejected.
+ expected_results:
+ - Defaults remain 1024 tokens, 128 overlap, 0.0 similarity and Top K 10.
+ - Chunk modes serialize 0 and 1.
+ - Overlap conversion is exact and bounded.
+ forbidden_side_effects:
+ - No silent clamping of an invalid overlap.
+ - No renamed legacy field in the outgoing payload.
+ - No change to the ES knowledge base contract.
+ unit_boundary: Create default application, chunk mode mapping and overlap conversion
+ inputs:
+ - payload without defaults
+ - chunk mode selection
+ - chunk tokens and overlap percent
+ assertions:
+ - Defaults match the existing Nexent values.
+ - Overlap equals the floor conversion.
+ - Overlap above 50 percent of the chunk size is rejected.
+ - Legacy field names stay unchanged.
+ - case_id: AKPR-D1-010
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-013]
+ title: Graph configuration serializes documented keys with an inverse thinking switch
+ type: BE-UT
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify live graph configuration serialization, the inverse Chinese thinking mapping, absence of graph Top K and the 1 to 4096 character prompt limit.
+ preconditions:
+ - Prepare graph payloads with the switch on and off, both languages and Unicode prompt lengths around the character limit.
+ test_data: {profile: mock, domains: [常规, 医疗, 法律法规], prompt_characters: [0, 1, 4096, 4097], languages: [中文, 英文]}
+ steps:
+ - order: 1
+ action: Submit a graph configuration with the thinking switch enabled.
+ expected: The no_think_mode field is the Chinese string 否 and only the seven template keys plus llm_model_name are forwarded.
+ - order: 2
+ action: Submit a knowledge base Top K with an obsolete graph Top K key.
+ expected: The outer topk is preserved and the obsolete graph key is not forwarded.
+ - order: 3
+ action: Submit prompts at and beyond the character boundary together with a disabled graph.
+ expected: The limit passes at the boundary, longer input is rejected and a disabled graph omits its configuration entirely.
+ expected_results:
+ - The thinking switch maps inversely onto the thinking mode field.
+ - Only the outer knowledge-base topk is submitted.
+ - The prompt limit is enforced as 1 to 4096 Unicode characters.
+ forbidden_side_effects:
+ - No hidden previous graph configuration is submitted when the graph is off.
+ - No graph Top K key reaches AIDP.
+ - No invented JSON key names.
+ unit_boundary: Graph configuration validation and serialization
+ inputs:
+ - graph switch
+ - domain
+ - obsolete graph top k
+ - hop count
+ - thinking switch
+ - prompt language
+ - prompt text
+ assertions:
+ - The thinking mode field is 否 when thinking is enabled.
+ - Only outer knowledge-base topk survives.
+ - Prompt length is measured in Unicode characters.
+ - Disabled graph omits its configuration.
+ - case_id: AKPR-D1-011
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-012]
+ title: One model query populates three categories and graph templates preserve edited prompts
+ type: FE-COMP
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify one model query, category separation by model_type, label and identifier mapping, hidden value omission and graph template interaction.
+ preconditions:
+ - Render the creation form with one model response, graph templates in both languages and both capability switches toggled.
+ test_data: {profile: mock, categories: [llm, vlm, embedding]}
+ steps:
+ - order: 1
+ action: Enable the graph section.
+ expected: One all-category query fills separate embedding, llm and vlm controls by model_type; graph activation reads the template endpoint.
+ - order: 2
+ action: Enable multimodal captioning.
+ expected: The multimodal field uses the vlm entries from the existing model result without another request.
+ - order: 3
+ action: Disable each capability after a selection.
+ expected: The hidden model value is absent from the submitted payload.
+ expected_results:
+ - Each field is populated from its model_type, displays display_name and submits model_name.
+ - A shared model cache never mixes category selections. Domain and language changes preserve manual prompts, and restore uses the current template. Template failures block graph-enabled creation.
+ - Disabled capabilities submit no model value.
+ forbidden_side_effects:
+ - No llm result used for the multimodal or embedding field.
+ - No hidden previous model resubmitted.
+ - No unverified model name silently defaulted.
+ unit_boundary: Creation form model queries, caches and hidden value handling
+ inputs:
+ - graph switch
+ - multimodal switch
+ - selected models
+ - query cache keys
+ assertions:
+ - One query returns all categories and controls filter by model_type.
+ - Model data uses one shared query cache; templates are cached by language.
+ - Disabled capability clears its submitted model.
+ - case_id: AKPR-D1-012
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: []
+ title: Safety guard serializes integer 1 and 0 and never omits an explicit disabled value
+ type: BE-UT
+ priority: P0
+ status: retired
+ automation: automated
+ objective: Retired because AIDP knowledge-base safety guard support was removed from the product scope.
+ preconditions:
+ - Prepare create and update payloads with the guard enabled, explicitly disabled and absent.
+ test_data: {profile: mock, values: ['enabled 1', 'explicit 0', absent]}
+ steps:
+ - order: 1
+ action: Build a create payload with the guard enabled and disabled.
+ expected: The field is present as integer 1 or explicit 0.
+ - order: 2
+ action: Build an update payload that only changes the guard to off.
+ expected: The field survives the update whitelist and is forwarded as 0.
+ - order: 3
+ action: Map a query response that lacks the field.
+ expected: The state is reported as unknown rather than confirmed off.
+ expected_results:
+ - Integer 1 and 0 are both serialized.
+ - An explicit 0 is never dropped by filtering.
+ - Missing data is not reported as a confirmed disabled state.
+ forbidden_side_effects:
+ - No boolean or string encoding of the field.
+ - No field nesting inside graph configuration.
+ - No Nexent-side content interception added.
+ unit_boundary: Safety guard request serialization and query mapping
+ inputs:
+ - safety switch state
+ - create payload
+ - update payload
+ - query response without the field
+ assertions:
+ - Enabled serializes 1 and disabled serializes 0.
+ - The update whitelist forwards the field.
+ - A missing field maps to unknown.
diff --git a/test/cases/d2/aidp-knowledge-detail.yaml b/test/cases/d2/aidp-knowledge-detail.yaml
new file mode 100644
index 0000000000..a2d3515c65
--- /dev/null
+++ b/test/cases/d2/aidp-knowledge-detail.yaml
@@ -0,0 +1,131 @@
+schema_version: '1.0'
+stage: D2
+module: AIDP-KNOWLEDGE
+cases:
+ - case_id: AKDT-D2-001
+ feature_id: AKDT-001
+ requirement_ids: [AKDT-001]
+ business_rule_ids: [AKDT-RULE-002, AKDT-RULE-003]
+ title: Ingested-file endpoint queries only the AIDP file listing
+ type: CONTRACT
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify file search and paging proxy only successfully ingested AIDP files and never substitutes History records or local PG rows.
+ preconditions:
+ - An authenticated caller has READ access to one AIDP knowledge base.
+ - The AIDP fixture contains one completed document and one processing History record with different IDs.
+ test_data: {profile: mock, page: 2, page_size: 20, keyword: handbook}
+ steps:
+ - order: 1
+ action: Request the file page with a keyword and page size.
+ expected: The request is forwarded to the AIDP KnowledgeFiles listing with page, page_size and keyword.
+ - order: 2
+ action: Inspect the file result and total.
+ expected: Only records from the ingested-file listing appear and the response reports its paging fields.
+ - order: 3
+ action: Deny READ access and repeat the request.
+ expected: The proxy rejects the request before contacting AIDP.
+ expected_results:
+ - File endpoint data comes only from the live ingested-file endpoint.
+ - Supported query fields are forwarded and totals are not computed from a task page.
+ - READ permission is enforced.
+ forbidden_side_effects:
+ - No History request is used as a file-list fallback.
+ - No file or task record is read from Nexent PostgreSQL.
+ contract:
+ protocol: HTTP
+ method: GET
+ endpoint: /aidp-mgmt/knowledge-bases/{kds_id}/files
+ request_expectations:
+ - page and page_size are positive; page_size is bounded to 100.
+ - keyword is forwarded when non-empty.
+ - READ permission is checked before the upstream request.
+ response_expectations:
+ - value contains only ingested-file records.
+ - total_count, has_more and total_reliable reflect the upstream listing/count result.
+ - case_id: AKDT-D2-002
+ feature_id: AKDT-001
+ requirement_ids: [AKDT-001]
+ business_rule_ids: [AKDT-RULE-002, AKDT-RULE-003, AKDT-RULE-004]
+ title: Upload-task endpoint forwards page, search, numeric status and local-upload directory
+ type: CONTRACT
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify task queries use AIDP History with the knowledge-base file-system ID, dir_path /{kds_id}, and the supported request fields while returning AIDP statistics.
+ preconditions:
+ - An authenticated caller has READ access and the AIDP channel lookup returns an fs_id for the selected knowledge base.
+ test_data: {profile: mock, page: 2, page_size: 10, keyword: policy, status: 0, dir_path: /12}
+ steps:
+ - order: 1
+ action: Request task page 2 with a name keyword and status 0.
+ expected: The proxy sends page, page_size, keyword, status, fs_id and dir_path /{kds_id} to the AIDP History endpoint.
+ - order: 2
+ action: Repeat for statuses 1 through 5.
+ expected: Each numeric filter is forwarded unchanged and does not replace the response-record status.
+ - order: 3
+ action: Inspect total, processing, failed and success counts.
+ expected: Each count comes from the corresponding History response field, not the current page.
+ expected_results:
+ - All supported task request fields are passed to AIDP History.
+ - dir_path is constructed as a slash followed by the current knowledge base ID.
+ - Statistics and records remain upstream values and file records are separate.
+ forbidden_side_effects:
+ - No page-one-only result is sliced locally and labeled as an upstream page.
+ - No count is calculated from visible page rows.
+ - No task data is persisted in Nexent PostgreSQL.
+ contract:
+ protocol: HTTP
+ method: GET
+ endpoint: /aidp-mgmt/knowledge-bases/{kds_id}/upload-tasks
+ request_expectations:
+ - page, page_size, keyword and status are forwarded; status accepts integer 0 through 5, including 0.
+ - History body includes fs_id returned for the current KB and dir_path /{kds_id}.
+ - READ permission is checked before querying History.
+ response_expectations:
+ - value contains the requested page of History records.
+ - total_record_count, processing_record_count, failed_record_count and success_record_count preserve AIDP statistics.
+ - total_count and pagination flags match the History response.
+ - case_id: AKDT-D2-003
+ feature_id: AKDT-001
+ requirement_ids: [AKDT-001]
+ business_rule_ids: [AKDT-RULE-005, AKDT-RULE-007]
+ title: Retry forwards one or more selected file UUIDs and rejects unauthorized or empty requests
+ type: CONTRACT
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify the retry proxy checks EDIT permission, validates an explicit non-empty UUID array and uses the AIDP documented request example.
+ preconditions:
+ - An authenticated caller and a valid knowledge base exist.
+ test_data: {profile: mock, file_uuids: [uuid-a, uuid-b]}
+ steps:
+ - order: 1
+ action: Submit one valid file UUID and then multiple valid UUIDs.
+ expected: The upstream body contains file_uuids as the same explicit array and no speculative fields.
+ - order: 2
+ action: Submit an empty array or malformed UUID.
+ expected: The request is rejected and AIDP is not called.
+ - order: 3
+ action: Submit as a READ_ONLY caller.
+ expected: The request is rejected before AIDP is called.
+ expected_results:
+ - Both single-file and multiple-file arrays are accepted when valid.
+ - Empty or malformed arrays are rejected.
+ - EDIT permission is required.
+ forbidden_side_effects:
+ - Empty input never means retry all files.
+ - No retry request contains retry_file_info or only_retry_graph.
+ - No retry request for a different knowledge base is issued.
+ contract:
+ protocol: HTTP
+ method: POST
+ endpoint: /aidp-mgmt/knowledge-bases/{kds_id}/upload-tasks/retry
+ request_expectations:
+ - Body requires a non-empty file_uuids array of UUIDs and deduplicates repeated IDs.
+ - EDIT permission is checked before upstream access.
+ - Upstream endpoint is POST KnowledgeFiles/Retry with only file_uuids in the body.
+ response_expectations:
+ - AIDP success_list and failed_list are preserved per file.
+ - Partial success is not collapsed into a blanket success response.
diff --git a/test/cases/d2/aidp-knowledge-pages-refactor.yaml b/test/cases/d2/aidp-knowledge-pages-refactor.yaml
new file mode 100644
index 0000000000..6a1153031b
--- /dev/null
+++ b/test/cases/d2/aidp-knowledge-pages-refactor.yaml
@@ -0,0 +1,173 @@
+schema_version: '1.0'
+stage: D2
+module: AIDP-KNOWLEDGE
+cases:
+ - case_id: AKPR-D2-001
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-002, AKPR-RULE-006]
+ title: Knowledge base list keeps its contract and adds optional display fields
+ type: CONTRACT
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify the paginated AIDP list proxy keeps its existing request and response contract while exposing the optional display metadata.
+ preconditions:
+ - An authenticated request reaches the AIDP management proxy with a controllable upstream fixture.
+ test_data: {profile: mock, page: 1, page_size: 10, keyword: alpha}
+ steps:
+ - order: 1
+ action: Request the first page with a keyword.
+ expected: The proxy forwards page, page_size and keyword and applies the existing permission intersection.
+ - order: 2
+ action: Inspect the response payload.
+ expected: Existing fields keep their names and types and the optional display fields appear only when the upstream returns them.
+ - order: 3
+ action: Request a later page.
+ expected: Ordering and totals remain consistent with the unfiltered permission-scoped set.
+ expected_results:
+ - Existing request parameters and response fields are unchanged.
+ - Optional display metadata is additive.
+ - Pagination order and totals stay stable.
+ forbidden_side_effects:
+ - No column filter parameter is introduced.
+ - No fabricated default replaces a missing metadata value.
+ - No ES knowledge base endpoint is touched.
+ contract:
+ protocol: HTTP
+ method: GET
+ endpoint: /aidp-mgmt/knowledge-bases
+ request_expectations:
+ - page, page_size and keyword behave as before.
+ - The permission-scoped access set is resolved before pagination.
+ - No new required parameter is added.
+ response_expectations:
+ - Existing fields keep their current names and types.
+ - Optional display fields are additive and omit a value the upstream did not return.
+ - Page totals equal the permission-scoped count.
+ - case_id: AKPR-D2-002
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-010, AKPR-RULE-011, AKPR-RULE-012, AKPR-RULE-013]
+ title: Creation request carries the new configuration and keeps legacy field names
+ type: API-IT
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify the create proxy forwards the verified chunking, retrieval and model fields and graph JSON string, omits obsolete graph Top K and smartsplit, and preserves existing product defaults.
+ preconditions:
+ - An authenticated create request reaches the AIDP management proxy with a recorded upstream payload.
+ test_data: {profile: mock, chunk_mode: [0, 1], graph_enabled: [true, false], multimodal: [true, false]}
+ steps:
+ - order: 1
+ action: Submit the minimal creation payload.
+ expected: Existing defaults still apply and the legacy field names remain unchanged.
+ - order: 2
+ action: Submit a payload with chunking, retrieval and graph configuration.
+ expected: The chunk mode, chunk token fields, similarity, knowledge base Top K and a serialized graph configuration reach the upstream payload.
+ - order: 3
+ action: Submit with the graph and multimodal capabilities disabled.
+ expected: The graph configuration and the hidden model fields are absent from the upstream payload.
+ expected_results:
+ - New configuration fields reach the upstream request.
+ - Legacy field names and defaults are preserved.
+ - Disabled capabilities submit no hidden configuration.
+ forbidden_side_effects:
+ - No ES knowledge base request shape change.
+ - No invented graph JSON keys.
+ - No model value submitted for a disabled capability.
+ contract:
+ protocol: HTTP
+ method: POST
+ endpoint: /aidp-mgmt/knowledge-bases
+ request_expectations:
+ - Chunk mode, chunk token and overlap values follow the documented types and bounds.
+ - Graph configuration is serialized as the documented string payload.
+ - Legacy field names remain in place for existing callers.
+ response_expectations:
+ - The created knowledge base identifier is returned as before.
+ - Permission and group handling keeps its existing behavior.
+ - A failed upstream create surfaces a stable error without partial local state.
+ - case_id: AKPR-D2-003
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: []
+ title: Update request changes metadata and reports partial failure
+ type: CONTRACT
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify that permission success stays distinguishable from a failed knowledge-base name or description update.
+ preconditions:
+ - An authenticated update reaches the AIDP management proxy for a base the caller may edit.
+ test_data: {profile: mock, metadata_change: [name, description], metadata_failure: true}
+ steps:
+ - order: 1
+ action: Change the knowledge-base name or description and save.
+ expected: Only the changed name or description is sent to AIDP.
+ - order: 2
+ action: Save again without changing metadata.
+ expected: No unchanged metadata field is resent.
+ - order: 3
+ action: Make the upstream metadata update fail after the permission write.
+ expected: The response distinguishes saved permissions from a failed metadata update and readback keeps the previous name and description.
+ expected_results:
+ - Only changed metadata is forwarded and unchanged values are omitted.
+ - Permission and metadata outcomes are separately reported.
+ - A metadata failure is never presented as a successful metadata save.
+ forbidden_side_effects:
+ - No claimed success on upstream failure.
+ contract:
+ protocol: HTTP
+ method: PATCH
+ endpoint: /aidp-mgmt/knowledge-bases/{kds_id}/permission
+ request_expectations:
+ - Only explicitly changed metadata fields are sent.
+ - Permission and group fields keep their existing rules.
+ response_expectations:
+ - Permission save status and metadata update status are reported separately.
+ - A failed metadata update yields a non-success result with the unchanged readback value.
+ - Cache invalidation follows a successful metadata update.
+ - case_id: AKPR-D2-004
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-012]
+ title: Model query returns all advertised types when no service filter is supplied
+ type: CONTRACT
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify the model query proxy omits the service filter for the unified query and returns models applicable to KnowledgeBase with their types, display names and identifiers.
+ preconditions:
+ - A controllable upstream model fixture serves an all-category payload and optional category-filtered responses.
+ test_data: {profile: mock, categories: [llm, vlm, embedding]}
+ steps:
+ - order: 1
+ action: Query all models without a service parameter.
+ expected: The request has app=KnowledgeBase without service and exposes embedding, llm and vlm models with model_type.
+ - order: 2
+ action: Query models with an explicit vlm service filter.
+ expected: The request carries the vlm category and the response exposes only vlm models.
+ - order: 3
+ action: Query an unsupported or failing category.
+ expected: A stable error or an empty result is returned without falling back to another category.
+ expected_results:
+ - One query populates all three controls.
+ - Controls split the returned model types without changing identifiers.
+ - Failures do not silently substitute another category.
+ forbidden_side_effects:
+ - No model_type category mixing.
+ - No fabricated model identifier on failure.
+ - No change to existing non-AIDP model endpoints.
+ contract:
+ protocol: HTTP
+ method: GET
+ endpoint: /aidp-mgmt/models
+ request_expectations:
+ - The service parameter is optional; omitting it retrieves all categories.
+ - The application parameter keeps the knowledge base scope.
+ - No credential is returned to the browser.
+ response_expectations:
+ - Model response preserves model_type, model_name and display_name for applicable models.
+ - An unavailable category yields a stable error or an empty list.
+ - Results are filtered to the knowledge base application scope.
diff --git a/test/cases/d3/aidp-knowledge-detail.yaml b/test/cases/d3/aidp-knowledge-detail.yaml
new file mode 100644
index 0000000000..d36e5a0161
--- /dev/null
+++ b/test/cases/d3/aidp-knowledge-detail.yaml
@@ -0,0 +1,50 @@
+schema_version: '1.0'
+stage: D3
+module: AIDP-KNOWLEDGE
+cases:
+ - case_id: AKDT-D3-001
+ feature_id: AKDT-001
+ requirement_ids: [AKDT-001]
+ business_rule_ids: [AKDT-RULE-002, AKDT-RULE-003, AKDT-RULE-005]
+ title: AIDP mock keeps completed files separate from paged upload history and retries
+ type: INTEGRATION
+ priority: P0
+ status: active
+ automation: automated
+ objective: Verify the local AIDP mock exercises the separate file and History sources, request paging and filtering, and array-based retry without local database history.
+ preconditions:
+ - Nexent config service and the AIDP management mock run in the declared local mock profile.
+ - Mock data includes completed and non-completed statuses for the same knowledge base.
+ test_data: {profile: mock, kds_id: aidp-kb-faq, page_size: 10, status: [0, 1, 2, 3, 4, 5]}
+ steps:
+ - order: 1
+ action: Query the files and upload-tasks endpoints for one knowledge base.
+ expected: The file response contains only completed records while task response includes History statuses and AIDP counts.
+ - order: 2
+ action: Query tasks with page, keyword and each numeric status.
+ expected: The mock applies the request fields, returns the matching rows and provides correct pagination/statistics.
+ - order: 3
+ action: Retry an explicit array containing one file UUID.
+ expected: The mock accepts the documented file_uuids body and records only the selected retry.
+ expected_results:
+ - Mock behavior makes accidental History/file mixing observable.
+ - Mock task filters and statistics match its fixture data.
+ - Mock retry behavior matches the adapter's documented sample shape.
+ forbidden_side_effects:
+ - No Nexent PG file or task-history record is created.
+ - No retry outside the requested UUID array is changed.
+ integration_boundary:
+ - Nexent FastAPI AIDP management routes.
+ - Local AIDP management mock KnowledgeFiles endpoints.
+ - Existing Nexent permission data only for authorization decisions.
+ observations:
+ - Captured request path and JSON body at the mock boundary.
+ - File and task response rows, counts and pagination metadata.
+ - Database writes limited to existing permission behavior; no file-history writes.
+ cleanup:
+ - Restore mock fixture state using the repository's isolated test state directory.
+ - Do not reset the user's persistent local mock state during browser acceptance.
+ execution:
+ profiles:
+ - name: mock
+ required_services: [knowledge-provider]
diff --git a/test/cases/d3/aidp-knowledge-pages-refactor.yaml b/test/cases/d3/aidp-knowledge-pages-refactor.yaml
new file mode 100644
index 0000000000..1eb4d3355b
--- /dev/null
+++ b/test/cases/d3/aidp-knowledge-pages-refactor.yaml
@@ -0,0 +1,93 @@
+schema_version: '1.0'
+stage: D3
+module: AIDP-KNOWLEDGE
+cases:
+ - case_id: AKPR-D3-001
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-011, AKPR-RULE-013]
+ title: Real AIDP create and readback close the loop
+ type: INTEGRATION
+ priority: P0
+ status: blocked
+ automation: automated
+ objective: Verify against a reachable AIDP service that a created knowledge base returns the submitted chunking, retrieval and graph configuration.
+ preconditions:
+ - A reachable AIDP service accepts Nexent credentials.
+ - A disposable tenant and user group are available.
+ test_data: {profile: real_smoke, chunk_mode: 0}
+ steps:
+ - order: 1
+ action: Create a knowledge base with chunking, retrieval and graph configuration through the Nexent proxy.
+ expected: The upstream accepts the payload and returns a knowledge base identifier.
+ - order: 2
+ action: Read the created knowledge base back.
+ expected: The chunking, retrieval and graph values match the submitted configuration.
+ expected_results:
+ - Submitted configuration is readable from the upstream.
+ forbidden_side_effects:
+ - No duplicate knowledge base created by retries.
+ - No permission row created without its knowledge base.
+ - No test data left in a shared tenant.
+ integration_boundary:
+ - Nexent AIDP knowledge base proxy
+ - Upstream AIDP knowledge base API
+ - Nexent AIDP permission store
+ observations:
+ - Outgoing create payload field names and types
+ - Readback values per configuration group
+ - Permission row and configuration readback after creation
+ cleanup:
+ - Delete the created knowledge base through the proxy.
+ - Remove local permission rows and document tag associations created by the case.
+ execution:
+ profiles:
+ - name: real_smoke
+ required_services: [knowledge-provider]
+ - case_id: AKPR-D3-002
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-012]
+ title: Real model categories return only their own models and survive readback
+ type: INTEGRATION
+ priority: P0
+ status: blocked
+ automation: automated
+ objective: Verify against a reachable AIDP service that each model control receives its own category and that a configuration selected from real models is accepted and readable. This case stays blocked until a real AIDP environment with a model inventory is reachable.
+ preconditions:
+ - A reachable AIDP service exposes llm, vlm and embedding model inventories.
+ - At least one model exists in the llm and vlm categories.
+ test_data: {profile: real_smoke, categories: [llm, vlm, embedding]}
+ steps:
+ - order: 1
+ action: Query each model category through the Nexent proxy.
+ expected: Each response contains only models of the requested category and scope.
+ - order: 2
+ action: Create a knowledge base using a real llm model for the graph and a real vlm model for multimodal captioning.
+ expected: The upstream accepts both model fields.
+ - order: 3
+ action: Read the knowledge base back.
+ expected: The graph and multimodal model fields match the submitted values.
+ expected_results:
+ - Category queries stay separated.
+ - Both model fields are accepted and readable.
+ - No unverified default model replaces a missing selection.
+ forbidden_side_effects:
+ - No model from one category submitted through another category control.
+ - No credential exposed to the browser.
+ - No change to non-AIDP model configuration.
+ integration_boundary:
+ - Nexent AIDP model query proxy
+ - Upstream AIDP model inventory API
+ - Nexent AIDP knowledge base create path
+ observations:
+ - Requested category per query
+ - Returned model identifiers and their categories
+ - Readback values for both model fields
+ cleanup:
+ - Delete the created knowledge base through the proxy.
+ - Discard recorded model identifiers that are environment specific.
+ execution:
+ profiles:
+ - name: real_smoke
+ required_services: [knowledge-provider]
diff --git a/test/cases/d4/aidp-knowledge-detail.yaml b/test/cases/d4/aidp-knowledge-detail.yaml
new file mode 100644
index 0000000000..87b4ae8f56
--- /dev/null
+++ b/test/cases/d4/aidp-knowledge-detail.yaml
@@ -0,0 +1,61 @@
+schema_version: '1.0'
+stage: D4
+module: AIDP-KNOWLEDGE
+cases:
+ - case_id: AKDT-D4-001
+ feature_id: AKDT-001
+ requirement_ids: [AKDT-001]
+ business_rule_ids: [AKDT-RULE-001, AKDT-RULE-002, AKDT-RULE-004, AKDT-RULE-005, AKDT-RULE-006, AKDT-RULE-007]
+ title: Knowledge base detail journey separates files, tasks and settings
+ type: E2E
+ priority: P0
+ status: blocked
+ automation: automated
+ objective: Verify the complete detail-page journey against the AIDP mock, including navigation, task filtering, failure details, row retry, configuration and permission controls.
+ preconditions:
+ - The authenticated test account has EDIT access to one seeded AIDP knowledge base and READ access to one shared base.
+ - The mock returns completed files and a mixture of recent task statuses, including one retryable failure with a reason.
+ - The browser has the AIDP knowledge-base feature enabled.
+ test_data: {profile: mock, locale: zh-CN, task_retention_days: 30, status_values: [0, 1, 2, 3, 4, 5], vector_topk_range: [1, 100]}
+ steps:
+ - order: 1
+ action: Open the overview and click a card or knowledge-base name.
+ expected: A full-width detail page opens with a breadcrumb, header and the three tabs Files, Upload tasks and Details; no list edit action remains, and the console has no deprecated AIDP Modal or Drawer API warnings.
+ - order: 2
+ action: Inspect Files and search by file name.
+ expected: Only ingested files appear; download and delete follow the user's permission; pagination and table controls stay in the detail page.
+ - order: 3
+ action: Open Upload tasks, search a name and choose each status filter.
+ expected: The page states tasks are retained for 30 days, status values 0-5 are sent, statistics remain independent from the current page, and processing rows do not appear as ingested files.
+ - order: 4
+ action: Open a failed task's failure details and retry it from the row action.
+ expected: The drawer shows returned values only; retry submits a one-item file_uuids array and subsequent refresh reads the changed task status.
+ - order: 5
+ action: Open Details and edit metadata from the header, a supported setting, then permission and groups from the permission section.
+ expected: Header edit shows name, description and directly visible permission/group controls; the lower editor contains only permission/group controls and sends no metadata fields; vector Top K uses the create-page slider style; unsupported similarity edits are absent.
+ expected_results:
+ - The page has the exact three tabs and full content width.
+ - Ingested files and upload history remain separate live AIDP sources.
+ - Status filters, raw status rendering, failure details and single-file retry follow the contract.
+ - Detail settings and group permissions use separate authorized save operations.
+ - The header edit is the only path that submits a changed name or description; the lower permission editor submits only permission and group values.
+ - The vector Top K editor uses the same slider and numeric value control as the creation form.
+ forbidden_side_effects:
+ - No task-history record is written to Nexent PostgreSQL.
+ - No operation from a READ_ONLY user changes upstream or local configuration.
+ - No list/card menu edits a knowledge base.
+ actor: Authenticated AIDP tenant user
+ journey:
+ - Enter the detail page from the overview.
+ - Inspect and search completed files.
+ - Inspect task statistics, filters and failure details.
+ - Retry one failed task and verify refresh behavior.
+ - Review and modify supported detail settings and permission groups.
+ final_business_result:
+ - The user can identify successfully ingested files separately from recent upload work and manage the knowledge base using the returned configuration and permission values.
+ failure_evidence:
+ - Retain screenshots for the three tabs and failure drawer, browser request evidence, and console errors.
+ execution:
+ profiles:
+ - name: mock
+ required_services: [knowledge-provider]
diff --git a/test/cases/d4/aidp-knowledge-page-visuals.yaml b/test/cases/d4/aidp-knowledge-page-visuals.yaml
new file mode 100644
index 0000000000..2ca9355109
--- /dev/null
+++ b/test/cases/d4/aidp-knowledge-page-visuals.yaml
@@ -0,0 +1,59 @@
+schema_version: '1.0'
+stage: D4
+module: AIDP-KNOWLEDGE
+cases:
+ - case_id: AKPR-D4-005
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-010, AKPR-RULE-015, AKPR-RULE-016, AKPR-RULE-017]
+ title: Overview and creation pages match their approved desktop layout
+ type: E2E
+ priority: P0
+ status: blocked
+ automation: automated
+ objective: Verify the rendered guide, knowledge cards, pagination, inline switches, title typography and direct create flow against the AIDP design at desktop and narrow widths.
+ preconditions:
+ - The AIDP-enabled instance is reachable with the authenticated test account.
+ - The instance has multiple knowledge bases with realistic metadata and an empty creation form.
+ - A fixed Playwright browser profile can access the local AIDP mock.
+ test_data: {profile: mock, desktop_viewport: [1920, 1080], narrow_viewport: [1024, 768]}
+ steps:
+ - order: 1
+ action: Open the overview and capture the expanded guide and first card row.
+ expected: At desktop width each row contains four 148px-high cards with 16px horizontal gaps and 12px vertical gaps. Cards have 24px horizontal and 16px vertical padding, a 40px icon, a 16px/500/24px title, a full-width two-line 12px/20px description, and one 12px/18px metadata row. The toolbar search is 360px by 32px, the primary button is 32px high, and the selected view uses the primary blue background. Pagination is aligned right, uses 32px controls, puts page-size selection before page numbers, and has a pale-blue selected page with no primary border.
+ - order: 2
+ action: Switch to table view and inspect the toolbar and column controls.
+ expected: The create and view buttons align at the right, table column control follows them, and no edit operation appears in the overview. The table header and data rows are 40px high with 14px/22px body text and pale #F3F3F3 separators. Permission tags stay on one line and row actions use compact inline links. Resize the description column by 100px and verify every other column keeps its measured width; horizontal scrolling moves the middle columns while name and actions stay fixed. Hide and restore the creator column without hiding the required name or actions columns.
+ - order: 3
+ action: Open creation and inspect the page title, section titles, switches and paired fields at both viewport sizes.
+ expected: Breadcrumb labels and form section headings use 18px text, there is no step indicator or optional import stage, the page uses the available width, switches sit beside their labels, and paired inputs or sliders align without page-level horizontal overflow. The browser console has no deprecated Ant Design Modal, Drawer or Alert API warnings.
+ - order: 4
+ action: Fill the required configuration and submit the create form.
+ expected: The knowledge base is created directly with one create request, the browser opens its detail page, and no upload request is issued from creation.
+ - order: 5
+ action: Open the detail page's information tab.
+ expected: The detail breadcrumb and configuration, graph and permission section headings render at 18px.
+ expected_results:
+ - The running AIDP pages match the accepted layout and preserve Nexent visual conventions.
+ - The create and detail current page titles and detail information section headings use 18px text.
+ - The create flow has no stepper or optional upload stage; file upload remains in the knowledge base detail page.
+ - The expanded guide uses the pale-blue gradient introduction panel with 24px padding, a configuration-flow heading and three 156px-high step cards; the step content stays create, upload and configure an agent, and collapse or reopen preserves the overview state.
+ - The narrow viewport remains usable without overlapping labels or page-level horizontal scrolling.
+ - Collapsing the guide preserves the keyword and page; changing page size resets to page one; the last partial page keeps the same content-panel height; a valid page jump navigates without a mutating request.
+ - No write request is issued while inspecting pages before the explicit create submit.
+ forbidden_side_effects:
+ - No knowledge base is created before the explicit submit, edited, or deleted; the submit issues exactly one create request and no upload request.
+ - No global style or ES page behavior is changed.
+ actor: Authenticated AIDP tenant user
+ journey:
+ - Inspect the overview guide and cards.
+ - Inspect the table toolbar and pagination.
+ - Inspect the full-page creation layout at desktop and narrow widths.
+ final_business_result:
+ - Users can inspect the intended list and create composition, then create a knowledge base directly and continue to its detail page.
+ failure_evidence:
+ - Save screenshots, computed layout measurements and browser console errors for both viewport sizes.
+ execution:
+ profiles:
+ - name: mock
+ required_services: [knowledge-provider]
diff --git a/test/cases/d4/aidp-knowledge-pages-refactor.yaml b/test/cases/d4/aidp-knowledge-pages-refactor.yaml
new file mode 100644
index 0000000000..a510917d8a
--- /dev/null
+++ b/test/cases/d4/aidp-knowledge-pages-refactor.yaml
@@ -0,0 +1,209 @@
+schema_version: '1.0'
+stage: D4
+module: AIDP-KNOWLEDGE
+cases:
+ - case_id: AKPR-D4-001
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-001, AKPR-RULE-002, AKPR-RULE-003, AKPR-RULE-004, AKPR-RULE-005]
+ title: Overview journey keeps the guide, search, view and column state across the file view
+ type: E2E
+ priority: P0
+ status: blocked
+ automation: automated
+ objective: Verify the complete overview journey in a browser against a deployed instance. This case stays blocked until a disposable Nexent deployment with the AIDP flag enabled and reachable AIDP data is available.
+ preconditions:
+ - A deployed Nexent instance runs this worktree with the AIDP flag enabled.
+ - The test tenant can see more than one knowledge base.
+ - A writable knowledge base with at least one document exists.
+ test_data: {profile: real_smoke, keyword: alpha, page_size: 10}
+ steps:
+ - order: 1
+ action: Open the knowledge base page and inspect the guide.
+ expected: The explanation and the three steps are visible, collapse and reopen work, and cards are the default view.
+ - order: 2
+ action: Search a name, move to a later page, switch to the table and hide one column.
+ expected: The table shows the filtered result set on the same page and the hidden column disappears.
+ - order: 3
+ action: Open a knowledge base from the table.
+ expected: The full-page file view shows only that base files.
+ - order: 4
+ action: Return to the overview.
+ expected: The keyword, page, table view and hidden column state are restored.
+ expected_results:
+ - The guide, card default, search, view switch and column setting all work in the browser.
+ - The file view is scoped to the selected base.
+ - Returning restores the earlier overview state.
+ forbidden_side_effects:
+ - No modification request from view or column changes.
+ - No state reset after returning.
+ - No ES knowledge base page rendered while the AIDP flag is enabled.
+ actor: Authenticated test tenant user
+ journey:
+ - Open the knowledge base overview.
+ - Expand and collapse the guide.
+ - Search, page and switch views while hiding a column.
+ - Open a knowledge base and return to the overview.
+ final_business_result:
+ - A user can browse, filter and configure the knowledge base overview and reach one base file view without losing context.
+ failure_evidence:
+ - Retain browser screenshot, network log and console trace on failure.
+ execution:
+ profiles:
+ - name: real_smoke
+ required_services: [knowledge-provider]
+ - case_id: AKPR-D4-002
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-009]
+ title: File view journey uploads, polls, downloads and deletes under permission control
+ type: E2E
+ priority: P0
+ status: blocked
+ automation: automated
+ objective: Verify the restored file operations in a browser against a deployed instance. This case stays blocked until a disposable Nexent deployment with reachable AIDP upload and document services is available.
+ preconditions:
+ - A deployed Nexent instance runs this worktree with the AIDP flag enabled.
+ - The test user owns an editable knowledge base and can read another one.
+ - A small local document is prepared for upload.
+ test_data: {profile: real_smoke, documents: 2, one_failing_upload: true}
+ steps:
+ - order: 1
+ action: Open the writable knowledge base and choose Import files.
+ expected: A 764px right-side drawer opens with the click-or-drop area, supported-format notes, a scrollable file list and fixed Cancel/Confirm actions.
+ - order: 2
+ action: Select or drop a supported document, wait for the upload response and then activate Confirm.
+ expected: Selection starts upload immediately; transfer completion shows waiting until AIDP responds. Accepted rows become solid near-black and remain visible; Confirm closes without uploading again.
+ - order: 3
+ action: Wait for the accepted document's processing status to reach a terminal state.
+ expected: The document appears and its processing status reaches a terminal state through polling.
+ - order: 4
+ action: Download the processed document.
+ expected: The browser receives the original file with its name.
+ - order: 5
+ action: Delete the document and confirm the dialog.
+ expected: The document disappears from the list and the deletion reports its own result.
+ - order: 6
+ action: Open the read-only knowledge base and attempt the same operations.
+ expected: Upload and delete are unavailable while browsing and download remain permitted.
+ expected_results:
+ - Upload reaches a terminal processing state.
+ - The import drawer keeps completed and failed rows visible, with independent scrolling and fixed Confirm action; it exposes no retry action.
+ - The downloaded file matches the uploaded original.
+ - Deletion removes the document after confirmation.
+ - Permission gates match the documented read and edit rules.
+ forbidden_side_effects:
+ - No upload or delete entry for a read-only base.
+ - No polling continues after leaving the file view.
+ - No document of another base rendered in the current view.
+ actor: Authenticated test tenant user
+ journey:
+ - Open a writable knowledge base file view and its import drawer.
+ - Select a supported document, observe its automatic upload, confirm the drawer and wait for processing to finish.
+ - Download the document.
+ - Delete the document with confirmation.
+ - Check the same controls on a read-only base.
+ final_business_result:
+ - A user with edit permission can add, retrieve and remove documents while a read-only user can only browse and download them.
+ failure_evidence:
+ - Retain browser screenshot, network log, downloaded file metadata and console trace on failure.
+ execution:
+ profiles:
+ - name: real_smoke
+ required_services: [knowledge-provider]
+ - case_id: AKPR-D4-003
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-010, AKPR-RULE-011, AKPR-RULE-012, AKPR-RULE-013]
+ title: Creation page journey configures two steps and reports creation and upload results separately
+ type: E2E
+ priority: P0
+ status: blocked
+ automation: automated
+ objective: Verify the dedicated two-step creation page in a browser including conditional graph and multimodal sections and the separate creation and upload outcomes. This case stays blocked until a disposable Nexent deployment with reachable AIDP services is available.
+ preconditions:
+ - A deployed Nexent instance runs this worktree with the AIDP flag enabled.
+ - The test user belongs to a valid group for a shared permission.
+ - A local document and an intentionally rejected document are prepared.
+ test_data: {profile: real_smoke, chunk_mode: 1, graph_enabled: true, multimodal: true}
+ steps:
+ - order: 1
+ action: Open the creation page, fill the first step and toggle the graph and multimodal sections.
+ expected: The conditional sections appear with their own model selections and permission fields stay in the first step.
+ - order: 2
+ action: Press next and then return to the first step.
+ expected: No knowledge base exists yet and the form values and selected files are preserved.
+ - order: 3
+ action: Submit with one accepted and one rejected file.
+ expected: The knowledge base is created once, the accepted file processes and the rejected file is reported as a separate failure.
+ - order: 4
+ action: Retry only the failed file.
+ expected: No second knowledge base is created and the retry targets the failed file only.
+ expected_results:
+ - The two-step flow holds its state and creates nothing early.
+ - Conditional graph and multimodal configuration is submitted with the chosen models.
+ - Creation and upload outcomes are reported separately and retrying never duplicates the base.
+ forbidden_side_effects:
+ - No knowledge base created before final submission or twice after a retry.
+ - No hidden graph or multimodal configuration submitted when disabled.
+ - No upload attempted before a successful creation.
+ actor: Authenticated test tenant user
+ journey:
+ - Open the dedicated creation page.
+ - Configure the first step including permissions and capabilities.
+ - Advance to the upload step and back.
+ - Submit with a partially failing file set.
+ - Retry the failed file.
+ final_business_result:
+ - A user can create one properly configured knowledge base and see which files were accepted and which failed without creating duplicates.
+ failure_evidence:
+ - Retain browser screenshot, network log of create and upload requests, and console trace on failure.
+ execution:
+ profiles:
+ - name: real_smoke
+ required_services: [knowledge-provider]
+ - case_id: AKPR-D4-004
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-008]
+ title: ES deployment still renders the original knowledge base page
+ type: E2E
+ priority: P0
+ status: blocked
+ automation: automated
+ objective: Verify that with the AIDP flag disabled the original ES knowledge base page renders unchanged. This case stays blocked until a disposable deployment or a flag configuration switch is available for browser verification.
+ preconditions:
+ - A deployed Nexent instance can run with the AIDP flag disabled.
+ - The same instance serves the ES knowledge base data.
+ test_data: {profile: real_smoke, aidp_flag: false}
+ steps:
+ - order: 1
+ action: Open the knowledge base page with the AIDP flag disabled.
+ expected: The original ES knowledge base page renders without the AIDP guide, card overview or AIDP file view.
+ - order: 2
+ action: Exercise the original list, creation and document interactions.
+ expected: Existing ES behavior is unchanged.
+ - order: 3
+ action: Inspect the requests issued by the page.
+ expected: No AIDP management endpoint is called.
+ expected_results:
+ - The ES page is untouched.
+ - No AIDP component renders in the ES path.
+ - No AIDP request is issued.
+ forbidden_side_effects:
+ - No AIDP request from the ES page.
+ - No visual or behavioral change to the ES list and document flows.
+ - No new ES write request.
+ actor: Authenticated test tenant user
+ journey:
+ - Switch the deployment flag off.
+ - Open the knowledge base page.
+ - Exercise the original list and document interactions.
+ final_business_result:
+ - An ES deployment keeps its original knowledge base experience with no AIDP leakage.
+ failure_evidence:
+ - Retain browser screenshot, network log and console trace on failure.
+ execution:
+ profiles:
+ - name: real_smoke
+ required_services: []
diff --git a/test/cases/d5/aidp-knowledge-detail.yaml b/test/cases/d5/aidp-knowledge-detail.yaml
new file mode 100644
index 0000000000..ee9b9dd52e
--- /dev/null
+++ b/test/cases/d5/aidp-knowledge-detail.yaml
@@ -0,0 +1,60 @@
+schema_version: '1.0'
+stage: D5
+module: AIDP-KNOWLEDGE
+cases:
+ - case_id: AKDT-D5-001
+ feature_id: AKDT-001
+ requirement_ids: [AKDT-001]
+ business_rule_ids: [AKDT-RULE-003, AKDT-RULE-005, AKDT-RULE-007, AKDT-RULE-008]
+ title: Detail operations preserve tenant boundaries and keep files live-only
+ type: SECURITY
+ priority: P0
+ status: blocked
+ automation: automated
+ objective: Verify user-group authorization protects detail reads and writes, retries cannot target another knowledge base's files, and no task/file history is written to Nexent storage.
+ preconditions:
+ - A disposable AIDP test tenant has private, shared-read and shared-edit knowledge bases with distinct file UUIDs.
+ - The AIDP mock and Nexent services are configured to record upstream calls and persistence operations.
+ test_data: {profile: mock, users: [owner, group_reader, group_editor, unrelated_tenant], cross_tenant_retry: true}
+ steps:
+ - order: 1
+ action: Query details, files, tasks and retry as each user role.
+ expected: Reads and edits match the local group permission matrix, and a retry with a UUID belonging to another base is rejected before upstream access.
+ - order: 2
+ action: Inspect Nexent database writes during file/task queries, retry, and detail edits.
+ expected: Only the existing group-permission/config records that are explicitly changed are persisted; file and task history are not stored.
+ - order: 3
+ action: Render the same overview after disabling the AIDP feature flag in a disposable ES deployment.
+ expected: The original ES page renders without AIDP detail components or calls.
+ expected_results:
+ - Unauthorized group members cannot read or mutate private or unrelated resources.
+ - Retry is scoped to files of the selected knowledge base and rejects invalid ownership.
+ - File/task records are queried live and are absent from Nexent PG history tables.
+ - ES knowledge-base UI and endpoints are unaffected.
+ forbidden_side_effects:
+ - No cross-tenant or cross-KB file disclosure or retry.
+ - No persistent copy of AIDP files, task records or task statistics.
+ - No AIDP request from the ES page.
+ risk_type: SECURITY
+ risk: A missing permission check or accidentally persisted remote record could expose another group's AIDP data or create a misleading stale local copy.
+ condition:
+ - Users with different group membership access the same tenant.
+ - A retry body contains an ID not belonging to the selected knowledge base.
+ - The page is rendered with the AIDP feature flag disabled.
+ metrics:
+ - Unauthorized detail, file, task and retry requests accepted.
+ - Cross-knowledge-base UUIDs forwarded to AIDP.
+ - Writes to local file/task-history storage during read or retry flows.
+ - AIDP requests issued by the ES page.
+ thresholds:
+ - Zero unauthorized reads or writes accepted.
+ - Zero cross-KB retry UUIDs forwarded upstream.
+ - Zero file/task history writes to Nexent PG.
+ - Zero AIDP endpoint calls from the ES route.
+ recovery_expectations:
+ - Rejected requests leave remote files and permission state unchanged and return an actionable authorization error.
+ - The ES page continues to work with its original local knowledge base services.
+ execution:
+ profiles:
+ - name: mock
+ required_services: [knowledge-provider]
diff --git a/test/cases/d5/aidp-knowledge-pages-refactor.yaml b/test/cases/d5/aidp-knowledge-pages-refactor.yaml
new file mode 100644
index 0000000000..ba326ab6e8
--- /dev/null
+++ b/test/cases/d5/aidp-knowledge-pages-refactor.yaml
@@ -0,0 +1,199 @@
+schema_version: '1.0'
+stage: D5
+module: AIDP-KNOWLEDGE
+cases:
+ - case_id: AKPR-D5-001
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-007]
+ title: Restricted users never reach a modification endpoint
+ type: SECURITY
+ priority: P0
+ status: blocked
+ automation: automated
+ objective: Verify across the overview, file view and creation page that permission and group rules block modification requests at the boundary. This case stays blocked until a disposable deployment with multiple permission roles is available.
+ preconditions:
+ - A deployed instance has a read-only user, a group-less user and an edit user on one knowledge base.
+ - Request logging is available.
+ test_data: {profile: real_smoke, roles: [READ_ONLY, 'USER without group', EDIT]}
+ steps:
+ - order: 1
+ action: As the read-only user, open the base and attempt edit, upload and delete.
+ expected: No modification entry is executable and no modification request is issued.
+ - order: 2
+ action: As a user without a valid group, submit the creation first step with a shared permission.
+ expected: The submission is rejected and no knowledge base is created.
+ - order: 3
+ action: As the edit user, perform the same operations.
+ expected: Only the permitted operations issue requests and they succeed.
+ expected_results:
+ - Disabled or hidden entries emit no request.
+ - Group validation rejects an invalid shared permission before creation.
+ - Permitted operations behave normally.
+ forbidden_side_effects:
+ - No modification request from a restricted role.
+ - No local-only permission change without the upstream counterpart.
+ - No exposure of upstream credentials in any error path.
+ risk_type: SECURITY
+ risk: A user without edit permission could modify a knowledge base through a stale or unguarded UI entry.
+ condition:
+ - Read-only permission on the selected knowledge base.
+ - Shared permission without a valid same-tenant group.
+ - Unavailable resource state.
+ metrics:
+ - Count of modification requests by a restricted role.
+ - Count of knowledge bases created from an invalid group submission.
+ thresholds:
+ - Zero modification requests from restricted roles.
+ - Zero resources created from an invalid group submission.
+ recovery_expectations:
+ - After the rejected attempt the UI keeps the previous state and reports the failure without claiming success.
+ execution:
+ profiles:
+ - name: real_smoke
+ required_services: [knowledge-provider]
+ - case_id: AKPR-D5-002
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-005, AKPR-RULE-009]
+ title: Delayed responses and polling never cross knowledge base boundaries
+ type: RELIABILITY
+ priority: P1
+ status: blocked
+ automation: automated
+ objective: Verify that switching or leaving the file view discards in-flight results, stops polling and never renders another base document list. This case stays blocked until a deployment with a controllable slow upstream is available.
+ preconditions:
+ - A deployed instance can delay document responses.
+ - Two knowledge bases with distinct documents exist.
+ test_data: {profile: real_smoke, delay_seconds: 5, bases: 2}
+ steps:
+ - order: 1
+ action: Open the first base and immediately switch to the second.
+ expected: The first base documents never render in the second and the later response is discarded.
+ - order: 2
+ action: Upload a document and leave the file view before processing completes.
+ expected: Polling stops after leaving and no late update reaches the overview.
+ - order: 3
+ action: Repeat the switch several times and inspect the rendered list.
+ expected: Only the current base documents appear and the list state matches the last selection.
+ expected_results:
+ - No stale document list is rendered.
+ - Polling stops when the view is left.
+ - The current base is always the one displayed.
+ forbidden_side_effects:
+ - No orphan polling loop after navigation.
+ - No document count or status from another base.
+ - No duplicate document rows after repeated switching.
+ risk_type: RELIABILITY
+ risk: A late response from a previously opened knowledge base can overwrite the current file view or leave polling running.
+ condition:
+ - Slow upstream document responses.
+ - Rapid switching between knowledge bases.
+ - Navigating away while uploads are processing.
+ metrics:
+ - Count of discarded late responses.
+ - Polling cycles after leaving the file view.
+ - Rendered document count per base.
+ thresholds:
+ - Zero cross-base document renders.
+ - Zero polling cycles after leaving the view.
+ recovery_expectations:
+ - Reopening a base shows its own current documents and a correct processing state.
+ execution:
+ profiles:
+ - name: real_smoke
+ required_services: [knowledge-provider]
+ - case_id: AKPR-D5-003
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-012, AKPR-RULE-013]
+ title: Disabled capabilities and missing remote values are never reported as active or saved
+ type: SECURITY
+ priority: P0
+ status: blocked
+ automation: automated
+ objective: Verify that hidden configuration is not resubmitted and that a failed metadata save is never presented as success. This case stays blocked until a deployment with a controllable upstream failure mode is available.
+ preconditions:
+ - A deployed instance can reject a metadata update on demand.
+ test_data: {profile: real_smoke, graph: [true, false], multimodal: [true, false]}
+ steps:
+ - order: 1
+ action: Create a knowledge base with the graph and multimodal capabilities disabled.
+ expected: No graph configuration and no hidden model field reaches the upstream request.
+ - order: 2
+ action: Change the knowledge-base name or description, fail the metadata update and inspect the UI.
+ expected: The change is reported as not saved and the previous value remains displayed.
+ expected_results:
+ - Hidden configuration stays out of requests.
+ - Failed saves are visible as failures.
+ forbidden_side_effects:
+ - No resubmission of a previously selected hidden model.
+ - No success message after a failed upstream update.
+ risk_type: SECURITY
+ risk: A user could believe a graph or multimodal configuration is active while remote state is unknown, or believe metadata was saved when the update failed.
+ condition:
+ - Graph or multimodal capability disabled after a selection.
+ - Metadata update failure after a permission save.
+ metrics:
+ - Count of hidden configuration fields in outgoing payloads.
+ - Count of success messages after failed updates.
+ thresholds:
+ - Zero hidden configuration fields in requests.
+ - Zero false success reports.
+ recovery_expectations:
+ - After a failed save the previous remote value stays displayed and a retry re-sends the explicit value.
+ execution:
+ profiles:
+ - name: real_smoke
+ required_services: [knowledge-provider]
+ - case_id: AKPR-D5-004
+ feature_id: AKPR-001
+ requirement_ids: [AKPR-001]
+ business_rule_ids: [AKPR-RULE-010]
+ title: Creation and upload failures leave exactly one consistent knowledge base
+ type: RELIABILITY
+ priority: P1
+ status: blocked
+ automation: automated
+ objective: Verify that failures around creation and upload never produce duplicate knowledge bases or permission rows without their knowledge base. This case stays blocked until a deployment with controllable upstream create and upload failures is available.
+ preconditions:
+ - A deployed instance can fail creation and file uploads on demand.
+ - Permission rows are inspectable for the test tenant.
+ test_data: {profile: real_smoke, upload_failures: 2, create_failures: 1}
+ steps:
+ - order: 1
+ action: Fail the create request and inspect local state.
+ expected: No knowledge base and no orphan permission row remain.
+ - order: 2
+ action: Create successfully with two failing uploads and retry them.
+ expected: One knowledge base exists and the retries never create another.
+ - order: 3
+ action: Interrupt the submission and reload the creation page.
+ expected: No duplicate resource appears and the user can identify the outcome of the previous attempt.
+ expected_results:
+ - Exactly one knowledge base exists after retries.
+ - Permission rows always belong to an existing knowledge base.
+ - Interrupted submissions do not silently duplicate resources.
+ forbidden_side_effects:
+ - No duplicate knowledge base after retry or reload.
+ - No orphan permission row.
+ - No upload executed before a successful create.
+ risk_type: RELIABILITY
+ risk: Retrying a partially failed creation can create duplicate knowledge bases or inconsistent permission records.
+ condition:
+ - Create failure.
+ - Partial upload failure with retry.
+ - Submission interrupted before a response arrives.
+ metrics:
+ - Knowledge bases created per submission attempt.
+ - Orphan permission rows.
+ - Upload attempts before a successful create.
+ thresholds:
+ - At most one knowledge base per intended submission.
+ - Zero orphan permission rows.
+ recovery_expectations:
+ - The created knowledge base stays usable and the failed files can be retried without creating a new base.
+ execution:
+ profiles:
+ - name: real_smoke
+ required_services: [knowledge-provider]
diff --git a/test/changes/bugs/aidp-knowledge-detail.yaml b/test/changes/bugs/aidp-knowledge-detail.yaml
new file mode 100644
index 0000000000..ecd26c528d
--- /dev/null
+++ b/test/changes/bugs/aidp-knowledge-detail.yaml
@@ -0,0 +1,30 @@
+schema_version: '1.0'
+changes:
+ - change_id: AKDT-BUG-001
+ change_type: bugfix
+ bug_id: AKDT-BUG-001
+ title: AIDP detail page emits deprecated Drawer and Modal warnings
+ description: Replace the deprecated Drawer width and Modal destroyOnClose props on the AIDP detail page.
+ observed_behavior: Opening the AIDP detail page emits Ant Design deprecation warnings for Drawer width and Modal destroyOnClose.
+ expected_behavior: The detail page uses Drawer size and Modal destroyOnHidden and produces no related deprecation warnings.
+ escaped_stage: D4
+ coverage_gap:
+ type: missing_assertion
+ description: The detail browser journey did not assert that opening the page produces no deprecated Modal or Drawer API warnings.
+ feature_contract_changed: false
+ affected_features: {existing: [], added: [], modified: [], retired: []}
+ affected_cases: {existing: [], added: [], modified: [AKDT-D4-001], retired: []}
+ - change_id: AKDT-BUG-002
+ change_type: bugfix
+ bug_id: AKDT-BUG-002
+ title: AIDP file and upload-task pages avoid local full-history scans
+ description: Forward the requested page and supported filters to AIDP for ingested files and upload-task history, and use AIDP's own 30-day history and summary counts.
+ observed_behavior: File-name search reads all ingested-file pages before filtering. Upload-task listing reads multiple History pages and filters, sorts, and paginates locally, including a duplicate 30-day cutoff.
+ expected_behavior: Each page request reads only the requested AIDP page, forwards keyword and status filters, and uses AIDP history summary counts. Retry delegates retry eligibility to AIDP without pre-reading history.
+ escaped_stage: D3
+ coverage_gap:
+ type: missing_assertion
+ description: Direct route tests did not assert single-page upstream forwarding, History filter forwarding, use of AIDP summary counts, or the absence of pre-retry history scans.
+ feature_contract_changed: false
+ affected_features: {existing: [AKDT-001], added: [], modified: [], retired: []}
+ affected_cases: {existing: [AKDT-D2-001, AKDT-D2-002, AKDT-D2-003], added: [], modified: [], retired: []}
diff --git a/test/changes/bugs/aidp-knowledge-page-visuals.yaml b/test/changes/bugs/aidp-knowledge-page-visuals.yaml
new file mode 100644
index 0000000000..fe122548da
--- /dev/null
+++ b/test/changes/bugs/aidp-knowledge-page-visuals.yaml
@@ -0,0 +1,44 @@
+schema_version: '1.0'
+changes:
+ - change_id: AKPR-BUG-001
+ change_type: bugfix
+ bug_id: AKPR-BUG-001
+ title: AIDP overview and creation pages miss the approved visual layout
+ description: Restore the approved spacing, card composition and full-width aligned controls on the AIDP knowledge base overview and creation page.
+ observed_behavior: The initial guide text touches the step indicator; initial cards put descriptions beside the icon and split metadata across rows; creation switches sit at the far edge and model fields do not align vertically. A later UCD inspection also found incorrect card typography and internal distribution, a 384px search instead of 360px, and 24px pagination controls with the size selector after the page numbers.
+ expected_behavior: The guide uses three spaced step cards; knowledge cards use a fixed 40px icon, a 16px medium title, a full-width 12px description and one aligned metadata row inside a 148px card with 24px/16px padding; the search is 360px by 32px; pagination uses 32px controls with size selection first and a pale-blue selected page. Creation controls occupy the full content width, place switches beside their labels and align fields in the same row.
+ escaped_stage: D4
+ coverage_gap:
+ type: missing_assertion
+ description: Existing checks cover guide content and form payloads but did not assert the approved visual hierarchy, card composition, switch grouping or field alignment in the rendered AIDP pages.
+ feature_contract_changed: true
+ affected_features: {existing: [], added: [], modified: [AKPR-001], retired: []}
+ affected_cases: {existing: [], added: [AKPR-D1-013, AKPR-D1-014, AKPR-D4-005], modified: [], retired: []}
+ - change_id: AKPR-BUG-002
+ change_type: bugfix
+ bug_id: AKPR-BUG-002
+ title: AIDP create and detail page headings use inconsistent font sizes
+ description: Apply the approved 18px typography to the current create/detail breadcrumb titles and detail information section headings.
+ observed_behavior: The create and detail breadcrumb labels inherit Ant Design's 14px default, and detail information section headings are 14px, while equivalent page and section titles are specified at 18px.
+ expected_behavior: The breadcrumb labels on the create and detail pages use 18px text with the current page title visually emphasized; detail information section headings use 18px text. The knowledge-base list title remains 18px.
+ escaped_stage: D4
+ coverage_gap:
+ type: missing_assertion
+ description: Existing visual cases checked form layout but did not assert computed font sizes for create/detail page titles or detail information section headings.
+ feature_contract_changed: false
+ affected_features: {existing: [], added: [], modified: [], retired: []}
+ affected_cases: {existing: [], added: [], modified: [AKPR-D1-014, AKPR-D4-005], retired: []}
+ - change_id: AKPR-BUG-003
+ change_type: bugfix
+ bug_id: AKPR-BUG-003
+ title: AIDP overview and create pages emit deprecated Ant Design warnings
+ description: Replace deprecated Modal and Alert props in the AIDP overview and creation pages.
+ observed_behavior: The overview Modal and creation page Alerts emit Ant Design warnings for destroyOnClose and message.
+ expected_behavior: The AIDP overview and creation pages use destroyOnHidden and Alert title without those deprecation warnings.
+ escaped_stage: D4
+ coverage_gap:
+ type: missing_assertion
+ description: The existing browser visual journey did not check that the overview and creation page render without deprecated Ant Design warnings.
+ feature_contract_changed: false
+ affected_features: {existing: [], added: [], modified: [], retired: []}
+ affected_cases: {existing: [], added: [], modified: [AKPR-D4-005], retired: []}
diff --git a/test/changes/bugs/aidp-live-create-contract.yaml b/test/changes/bugs/aidp-live-create-contract.yaml
new file mode 100644
index 0000000000..dc930c702f
--- /dev/null
+++ b/test/changes/bugs/aidp-live-create-contract.yaml
@@ -0,0 +1,16 @@
+schema_version: '1.0'
+changes:
+ - change_id: AKPR-BUG-004
+ change_type: bugfix
+ bug_id: AKPR-BUG-004
+ title: AIDP live creation rejects graph keys and value formats accepted by the mock
+ description: Align models, graph templates, create serialization and mock validation with the live requests supplied during integration. Resolve creator names from Nexent permission owners and display local import in both languages.
+ observed_behavior: Creation returns 400 for graph Top K and boolean or English graph values. The mock drops graph fields without validation. The UI queries model categories separately, limits prompts to 2048 bytes and displays empty remote creator and import metadata.
+ expected_behavior: Creation submits seven template graph keys plus the LLM identifier using Chinese strings and a 1 to 4096 character prompt. Model categories share one query; server templates switch by language and domain without overwriting manual text. Creators resolve from Nexent users and import source is localized local import. Unknown creator records display an em dash. Update time, file count and capacity are deferred.
+ escaped_stage: D3
+ coverage_gap:
+ type: missing_contract
+ description: The old baseline encoded graph Top K, booleans and a byte limit. The mock accepted these incorrect requests and ignored graph fields, so successful mock execution did not prove live request compatibility.
+ feature_contract_changed: true
+ affected_features: {existing: [], added: [], modified: [AKPR-001], retired: []}
+ affected_cases: {existing: [], added: [], modified: [AKPR-D1-010, AKPR-D1-011, AKPR-D2-002, AKPR-D2-004], retired: []}
diff --git a/test/changes/requirements/aidp-knowledge-detail.yaml b/test/changes/requirements/aidp-knowledge-detail.yaml
new file mode 100644
index 0000000000..87c5a011c8
--- /dev/null
+++ b/test/changes/requirements/aidp-knowledge-detail.yaml
@@ -0,0 +1,24 @@
+schema_version: '1.0'
+changes:
+ - change_id: AKDT-REQ-001
+ change_type: requirement
+ requirement_id: AKDT-001
+ title: AIDP knowledge base detail page
+ description: Add a full-page AIDP knowledge base detail experience with separate live lists for ingested files and 30-day upload history, file retry and failure details, configuration display, editable supported settings and user-group permissions. Leave the ES knowledge-base path unchanged.
+ feature_contract_changed: true
+ affected_features: {existing: [], added: [AKDT-001], modified: [], retired: []}
+ affected_cases:
+ existing: []
+ added: [AKDT-D1-001, AKDT-D1-002, AKDT-D2-001, AKDT-D2-002, AKDT-D2-003, AKDT-D3-001, AKDT-D4-001, AKDT-D5-001]
+ modified: []
+ retired: []
+ notes: The FAILED-to-vector-failure and UPLOADING-to-queued response mappings are provisional per user authorization. History filter values 0-5 and dir_path=/{knowledge_base_id} are confirmed. Retry uses the documented file_uuids example; real AIDP behavior remains to be checked by the user.
+ - change_id: AKDT-REQ-002
+ change_type: requirement
+ requirement_id: AKDT-001
+ title: Separate knowledge-base metadata and permission edits in detail
+ description: Show group permission controls directly from the header edit action, keep the dedicated permission-section action limited to permission and accessible groups, and edit vector retrieval Top K with the creation page slider control.
+ feature_contract_changed: true
+ affected_features: {existing: [], added: [], modified: [AKDT-001], retired: []}
+ affected_cases: {existing: [], added: [], modified: [AKDT-D1-002, AKDT-D4-001], retired: []}
+ notes: Applies only to the AIDP detail page. The header edit remains the name/description entry point; the lower permission section never submits metadata fields.
diff --git a/test/changes/requirements/aidp-knowledge-pages-refactor.yaml b/test/changes/requirements/aidp-knowledge-pages-refactor.yaml
new file mode 100644
index 0000000000..1f2ab1050e
--- /dev/null
+++ b/test/changes/requirements/aidp-knowledge-pages-refactor.yaml
@@ -0,0 +1,93 @@
+schema_version: '1.0'
+changes:
+ - change_id: AKPR-REQ-001
+ change_type: requirement
+ requirement_id: AKPR-001
+ title: AIDP knowledge base list and creation page refactor
+ description: Split the AIDP knowledge base overview and its file management into separate views with a card or table overview, an independent name search, a collapsible three-step guide, a column visibility setting and a full-page file view that restores list state. Creation moves to a dedicated two-step page carrying permission, safety guard, graph, chunking, model and retrieval configuration, and the previously hidden document download and delete entries become available again under the documented permission gates.
+ feature_contract_changed: true
+ affected_features: {existing: [], added: [AKPR-001], modified: [], retired: []}
+ affected_cases:
+ existing: []
+ added:
+ - AKPR-D1-001
+ - AKPR-D1-002
+ - AKPR-D1-003
+ - AKPR-D1-004
+ - AKPR-D1-005
+ - AKPR-D1-006
+ - AKPR-D1-007
+ - AKPR-D1-008
+ - AKPR-D1-009
+ - AKPR-D1-010
+ - AKPR-D1-011
+ - AKPR-D1-012
+ - AKPR-D2-001
+ - AKPR-D2-002
+ - AKPR-D2-003
+ - AKPR-D2-004
+ - AKPR-D3-001
+ - AKPR-D3-002
+ - AKPR-D4-001
+ - AKPR-D4-002
+ - AKPR-D4-003
+ - AKPR-D4-004
+ - AKPR-D5-001
+ - AKPR-D5-002
+ - AKPR-D5-003
+ - AKPR-D5-004
+ modified: []
+ retired: []
+ notes: The AIDP-only scope is preserved; the ES knowledge base path keeps its current components and endpoints. D3, D4 and D5 cases remain blocked until a disposable deployment and the upstream safety guard contract are available.
+ - change_id: AKPR-REQ-002
+ change_type: requirement
+ requirement_id: AKPR-001
+ title: Remove AIDP knowledge-base safety guard
+ description: Remove the safety guard from AIDP knowledge-base creation, update and detail interfaces because the feature is no longer in scope. Keep unrelated agent safety controls unchanged.
+ feature_contract_changed: true
+ affected_features: {existing: [], added: [], modified: [AKPR-001, AKDT-001], retired: []}
+ affected_cases:
+ existing: []
+ added: []
+ modified: [AKPR-D2-003, AKPR-D3-001, AKPR-D4-003, AKPR-D5-001, AKPR-D5-003]
+ retired: [AKPR-D1-012]
+ notes: The API request and response models, AIDP-facing UI and mock no longer expose sensitive_intercept_enalbe. The ES knowledge-base path and unrelated agent safety controls are outside this change.
+ - change_id: AKPR-REQ-003
+ change_type: requirement
+ requirement_id: AKPR-001
+ title: Create AIDP knowledge bases directly from the configuration page
+ description: Remove the creation step indicator and optional import stage. Keep all knowledge-base configuration on one full-page form; submitting it creates the knowledge base and opens its detail page, where users can upload files. The create and detail page titles and detail information section headings use 18px text.
+ feature_contract_changed: true
+ affected_features: {existing: [], added: [], modified: [AKPR-001], retired: []}
+ affected_cases:
+ existing: []
+ added: []
+ modified: [AKPR-D1-014, AKPR-D4-005]
+ retired: []
+ notes: The create API payload and permission/configuration fields remain unchanged; file upload remains available from the detail page. No ES page behavior changes.
+ - change_id: AKPR-REQ-004
+ change_type: requirement
+ requirement_id: AKPR-001
+ title: Stage AIDP file imports in a scrollable drawer with visible per-file progress
+ description: Replace the AIDP import modal with one shared right-side drawer used from the overview and detail page. Selecting or dropping files stages them for review; Import sends them to AIDP. Keep a 50-file cap including failures, independent scrolling, browser-measured progress, status-specific dashed/solid borders, and delete-only failure actions.
+ feature_contract_changed: true
+ affected_features: {existing: [], added: [], modified: [AKPR-001], retired: []}
+ affected_cases:
+ existing: []
+ added: [AKPR-D1-015]
+ modified: [AKPR-D4-002]
+ retired: []
+ notes: Superseded by AKPR-REQ-005 for upload timing, confirmation and in-flight deletion. The new drawer is AIDP-only; the ES upload UI is unchanged.
+ - change_id: AKPR-REQ-005
+ change_type: requirement
+ requirement_id: AKPR-001
+ title: Restore immediate AIDP upload on selection and use Confirm to dismiss results
+ description: Selecting or dropping files immediately starts the existing Nexent-to-AIDP upload. Confirm only closes the drawer; transfer completion must wait for the actual AIDP result before success. Preserve successful and failed rows for review, and disable deletion of in-flight files because cancelling a browser request cannot reliably withdraw an upstream AIDP upload.
+ feature_contract_changed: true
+ affected_features: {existing: [], added: [], modified: [AKPR-001], retired: []}
+ affected_cases:
+ existing: []
+ added: []
+ modified: [AKPR-D1-015, AKPR-D4-002]
+ retired: []
+ notes: One selection sends one multipart batch with repeated files fields. Copy batch progress to each uploading ring, show total file size without transferred-byte text, and expose delete only on failed rows. Confirm, Cancel, Escape and mask/close actions are disabled until the upload response returns; transfer 100% alone does not enable them. No separate backend staging, MinIO, ES or AIDP API changes.
diff --git a/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py b/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py
index ff08e4aca5..5a986ba41b 100644
--- a/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py
+++ b/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py
@@ -12,10 +12,15 @@
- GET /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/Channels (ingestion channels)
- POST /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles/History (all-status file history)
- POST /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles/Remove (remove docs)
+ - POST /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles/Retry (retry failed docs)
- POST /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles/Download (download doc)
- POST /KnowledgeBase/Tenants/{tenant}/Retrieval/FusionSearch (search - preserved from reference)
Document status simulation (drives the "processing status" UI):
+ * The CLI delays upload responses by 10 seconds to expose the upload UI's
+ waiting state. Override with ``--upload-seconds 0`` or at runtime with
+ ``POST /_mock/upload-seconds?seconds=N``. This delays the AIDP response,
+ not the browser-to-Nexent request body transfer.
* Uploaded documents start as ``PROCESSING`` and flip to ``COMPLETED`` once
``_PROCESSING_SECONDS`` have elapsed, so polling behaviour can be observed
end to end. Tune it with ``POST /_mock/processing-seconds?seconds=N``.
@@ -24,26 +29,31 @@
non-terminal ``UPLOADING`` / ``EXTRACTING`` stages.
* ``GET .../KnowledgeFiles`` keeps returning COMPLETED documents only (mirrors
real AIDP), while ``POST .../KnowledgeFiles/History`` returns every status.
- * ``POST .../KnowledgeFiles/History`` is paginated (body ``page``, ten entries
- per page) and lists files that are still being processed first, so a burst of
- simultaneous uploads spills onto the next page and the caller has to walk the
- pages. Tune the page size with ``POST /_mock/history-page-size?size=N``.
+ * ``POST .../KnowledgeFiles/History`` returns records from the last 30 days
+ and supports page, page_size, file-name keyword, and numeric status filters.
+ Tune the default page size with ``POST /_mock/history-page-size?size=N``.
Knowledge base + document state is persisted to ``_state/knowledge_bases.json``
(next to this file). On restart the mock loads the file, so KBs created by
tests or frontend sessions survive across restarts without re-creation
(which was otherwise the cause of spurious 404s in list endpoints against
stale Nexent permission rows). ``POST /_reset`` clears the file and
-rebuilds the seed data. Run with:
+rebuilds the seed data. ``AIDP_MOCK_STATE_FILE`` can point to an existing local
+state file when testing another checkout. On startup, the mock upgrades the
+existing FAQ seed to a fully populated Chinese knowledge base with recent file
+and upload-task examples without clearing other persisted state. Run with:
python aidp_mgmt_mock_server.py --port 30081
"""
import argparse
+import asyncio
import json
import logging
import mimetypes
import os
+import re
import time
import uuid
+from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Literal, Optional
from urllib.parse import quote
@@ -81,7 +91,7 @@
STATUS_PROCESSING = "PROCESSING"
STATUS_COMPLETED = "COMPLETED"
STATUS_FAILED = "FAILED"
-_TERMINAL_STATUSES = {STATUS_COMPLETED, STATUS_FAILED}
+_TERMINAL_STATUSES = {STATUS_COMPLETED, STATUS_FAILED, 1, 3, 5, "1", "3", "5"}
# One ingestion channel per knowledge base, exposed at the KB-scoped path the
# real AIDP uses (``.../KnowledgeBases/{kds_id}/Channels``). ``src_dir`` embeds
@@ -94,8 +104,11 @@
# Overridable at runtime through POST /_mock/processing-seconds.
_PROCESSING_SECONDS = 8.0
-# Entries one history page returns. Real AIDP pages the channel directory, so the
-# backend has to walk the pages; keep this small to exercise that locally.
+# Response latency per upload request; CLI defaults to 10 seconds for UI review.
+# Imported test apps start without latency and opt in through the mock endpoint.
+_UPLOAD_SECONDS = 0.0
+
+# Default number of history records returned by each AIDP page.
_HISTORY_PAGE_SIZE = 10
# Directory for persisted runtime state. Lives next to this file so the mock
@@ -103,8 +116,9 @@
# ``.gitignore``. Only KB + document state is persisted; failure-injection
# counters deliberately stay in-memory so each restart starts with a clean
# failure plan.
-_STATE_DIR = Path(__file__).with_suffix("").with_name("_state")
-_STATE_FILE = _STATE_DIR / "knowledge_bases.json"
+_DEFAULT_STATE_FILE = Path(__file__).with_suffix("").with_name("_state") / "knowledge_bases.json"
+_STATE_FILE = Path(os.environ.get("AIDP_MOCK_STATE_FILE", str(_DEFAULT_STATE_FILE))).expanduser()
+_STATE_DIR = _STATE_FILE.parent
# =============================================================================
# In-memory state
@@ -128,7 +142,14 @@ def _seed_initial_data() -> None:
seeds = [
{"kds_id": "aidp-kb-product", "kds_name": "AIDP Product Handbook", "description": "Product documents for AIDP search capability.", "state": 4, "create_time": 1718000100, "update_time": 1718000100},
{"kds_id": "aidp-kb-api", "kds_name": "AIDP API Guide", "description": "API and integration guide for the AIDP platform.", "state": 4, "create_time": 1718000200, "update_time": 1718000200},
- {"kds_id": "aidp-kb-faq", "kds_name": "AIDP FAQ", "description": "Frequently asked questions and troubleshooting notes.", "state": 4, "create_time": 1718000300, "update_time": 1718000300},
+ {
+ "kds_id": "aidp-kb-faq",
+ "kds_name": "政务服务与城市治理知识库",
+ "description": "汇集政务办事指南、公共服务事项、城市运行制度和应急处置资料,供工作人员快速检索与答疑。",
+ "state": 4,
+ "create_time": 1718000300,
+ "update_time": 1718000300,
+ },
{"kds_id": "aidp-kb-04", "kds_name": "Customer Support Playbook", "description": "Standard operating procedures for support teams.", "state": 4, "create_time": 1718001004, "update_time": 1718001004},
{"kds_id": "aidp-kb-05", "kds_name": "Data Privacy Guidelines", "description": "GDPR/CCPA compliance and data handling policies.", "state": 4, "create_time": 1718001005, "update_time": 1718001005},
{"kds_id": "aidp-kb-06", "kds_name": "Engineering Onboarding", "description": "New engineer ramp-up materials and tooling setup.", "state": 4, "create_time": 1718001006, "update_time": 1718001006},
@@ -156,7 +177,8 @@ def _seed_initial_data() -> None:
_KNOWLEDGE_BASES[kb["kds_id"]] = kb
_DOCUMENTS_BY_KB[kb["kds_id"]] = []
- # Seed some documents for the FAQ KB so list_docs is non-empty by default.
+ # Seed completed files and recent tasks for both detail-page tabs.
+ now = int(time.time())
_DOCUMENTS_BY_KB["aidp-kb-faq"] = [
{
"file_uuid": "00000000-0000-4000-8000-000000000001",
@@ -174,7 +196,73 @@ def _seed_initial_data() -> None:
"file_type": "md",
"create_time": 1718000500,
},
+ {
+ "file_uuid": "00000000-0000-4000-8000-000000000003",
+ "file_ino_no": 1003,
+ "file_name": "向量入库失败示例.docx",
+ "file_size": 3072,
+ "file_type": "docx",
+ "first_upload_time": now - 3600,
+ "update_time": now - 1800,
+ "status": 3,
+ "error_code": "VEC101",
+ "extraction_failure_reason": "向量服务暂时不可用,任务未能完成向量入库。",
+ },
+ {
+ "file_uuid": "00000000-0000-4000-8000-000000000004",
+ "file_ino_no": 1004,
+ "file_name": "排队中的制度说明.pdf",
+ "file_size": 5120,
+ "file_type": "pdf",
+ "first_upload_time": now - 900,
+ "update_time": now - 600,
+ "status": 4,
+ },
+ {
+ "file_uuid": "00000000-0000-4000-8000-000000000005",
+ "file_ino_no": 1005,
+ "file_name": "图谱入库失败示例.pdf",
+ "file_size": 8192,
+ "file_type": "pdf",
+ "first_upload_time": now - 7200,
+ "update_time": now - 5400,
+ "status": 5,
+ "error_code": "GRAPH201",
+ "extraction_failure_reason": "图谱任务处理失败,请检查知识图谱配置后重试。",
+ },
+ {
+ "file_uuid": "00000000-0000-4000-8000-000000000006",
+ "file_ino_no": 1006,
+ "file_name": "正在提取的服务手册.txt",
+ "file_size": 4096,
+ "file_type": "txt",
+ "first_upload_time": now - 300,
+ "update_time": now - 120,
+ "status": 2,
+ },
]
+ _KNOWLEDGE_BASES["aidp-kb-faq"].update({
+ "embedding_model": "bge-m3",
+ "vlm_model": "Qwen3-VL-8B-Instruct",
+ "rerank_model": "bge-reranker-v2-m3",
+ "chunk_mode": 0,
+ "chunk_token_num": 512,
+ "chunk_overlap_num": 10,
+ "topk": 10,
+ "similarity": 0.6,
+ "is_exist_graph": True,
+ "llm_model_name": "Qwen3-8B-Instruct",
+ "graph_config": {
+ "domain": "general",
+ "retrieve_default_topk": 10,
+ "retrieve_subgraph_hop": 2,
+ "no_think_mode": True,
+ "prompt_language": "chinese",
+ "prompt_text": "作为专业知识图谱构建引擎,仅提取文本实体、关系、属性三元组,输出固定 JSON 数组。",
+ "synonym_merge_enable": False,
+ "disambiguation_enable": False,
+ },
+ })
def _save_state() -> None:
@@ -217,6 +305,9 @@ def _load_state() -> None:
raise ValueError("state file payload is not {dict, dict}")
_KNOWLEDGE_BASES.update(kb_data)
_DOCUMENTS_BY_KB.update(doc_data)
+ for kb in _KNOWLEDGE_BASES.values():
+ if isinstance(kb, dict):
+ kb.pop("sensitive_intercept_enalbe", None)
logger.info(
"STATE LOAD restored %d KBs from %s",
len(_KNOWLEDGE_BASES), _STATE_FILE,
@@ -250,6 +341,268 @@ def _ensure_document_uuids() -> None:
_load_state()
+
+
+def _ensure_detail_demo_tasks() -> None:
+ """Retain the legacy opt-in task fixture for existing local test workflows."""
+ if os.environ.get("AIDP_MOCK_ENABLE_DETAIL_FIXTURES") != "1":
+ return
+ kds_id = "aidp-kb-faq"
+ if kds_id not in _KNOWLEDGE_BASES:
+ return
+ docs = _DOCUMENTS_BY_KB.setdefault(kds_id, [])
+ known_uuids = {str(doc.get("file_uuid")) for doc in docs if doc.get("file_uuid")}
+ now = int(time.time())
+ fixtures = [
+ {
+ "file_uuid": "00000000-0000-4000-8000-000000000003",
+ "file_ino_no": 1003,
+ "file_name": "向量入库失败示例.docx",
+ "file_size": 3072,
+ "file_type": "docx",
+ "first_upload_time": now - 3600,
+ "update_time": now - 1800,
+ "status": 3,
+ "error_code": "VEC101",
+ "extraction_failure_reason": "向量服务暂时不可用,任务未能完成向量入库。",
+ },
+ {
+ "file_uuid": "00000000-0000-4000-8000-000000000004",
+ "file_ino_no": 1004,
+ "file_name": "排队中的制度说明.pdf",
+ "file_size": 5120,
+ "file_type": "pdf",
+ "first_upload_time": now - 900,
+ "update_time": now - 600,
+ "status": 4,
+ },
+ {
+ "file_uuid": "00000000-0000-4000-8000-000000000005",
+ "file_ino_no": 1005,
+ "file_name": "图谱入库失败示例.pdf",
+ "file_size": 8192,
+ "file_type": "pdf",
+ "first_upload_time": now - 7200,
+ "update_time": now - 5400,
+ "status": 5,
+ "error_code": "GRAPH201",
+ "extraction_failure_reason": "图谱任务处理失败,请检查知识图谱配置后重试。",
+ },
+ {
+ "file_uuid": "00000000-0000-4000-8000-000000000006",
+ "file_ino_no": 1006,
+ "file_name": "正在提取的服务手册.txt",
+ "file_size": 4096,
+ "file_type": "txt",
+ "first_upload_time": now - 300,
+ "update_time": now - 120,
+ "status": 2,
+ },
+ ]
+ additions = [fixture for fixture in fixtures if fixture["file_uuid"] not in known_uuids]
+ if additions:
+ docs.extend(additions)
+ logger.info("DETAIL FIXTURES added %d sample upload tasks to %s", len(additions), kds_id)
+ _save_state()
+
+
+def _ensure_chinese_demo_data() -> None:
+ """Keep one persisted AIDP sample fully populated for Chinese UI review."""
+ kds_id = "aidp-kb-faq"
+ kb = _KNOWLEDGE_BASES.get(kds_id)
+ if kb is None:
+ return
+
+ now = int(time.time())
+ name = "政务服务与城市治理知识库"
+ description = "汇集政务办事指南、公共服务事项、城市运行制度和应急处置资料,供工作人员快速检索与答疑。"
+ if not kb.get("kds_name") or kb.get("kds_name") == "AIDP FAQ":
+ kb["kds_name"] = name
+ if not kb.get("description") or kb.get("description") == "Frequently asked questions and troubleshooting notes.":
+ kb["description"] = description
+
+ defaults = {
+ "state": 4,
+ "create_time": now - 45 * 24 * 60 * 60,
+ "update_time": now - 24 * 60 * 60,
+ "created_by": "王晓明",
+ "user_name": "王晓明",
+ "is_private": 0,
+ "current_cap": 0.84,
+ "document_count": 8,
+ "chunk_count": 286,
+ "embedding_model": "bge-m3",
+ "is_multimodal": True,
+ "caption_enable": 1,
+ "vlm_model": "Qwen3-VL-8B-Instruct",
+ "rerank_model": "bge-reranker-v2-m3",
+ "chunk_mode": 0,
+ "chunk_token_num": 1024,
+ "chunk_overlap_num": 128,
+ "topk": 10,
+ "similarity": 0.62,
+ "is_exist_graph": True,
+ "llm_model_name": "Qwen3-8B-Instruct",
+ }
+ if kb.get("create_time") == 1718000300:
+ kb["create_time"] = now - 45 * 24 * 60 * 60
+ if kb.get("update_time") == 1718000300:
+ kb["update_time"] = now - 24 * 60 * 60
+ for key, value in defaults.items():
+ current = kb.get(key)
+ if current is None or (isinstance(current, str) and not current.strip()):
+ kb[key] = value
+
+ graph_defaults = {
+ "domain": "general",
+ "retrieve_default_topk": 10,
+ "retrieve_subgraph_hop": 2,
+ "no_think_mode": False,
+ "prompt_language": "chinese",
+ "prompt_text": (
+ "你是政务服务与城市治理领域的知识图谱构建助手。请从材料中识别办事事项、办理部门、"
+ "申请条件、所需材料、办理流程、办理时限、服务地点、政策依据、城市设施、责任单位和应急事件。"
+ "仅根据原文建立准确的实体与关系;实体名称保持原文,合并明确指向同一对象的别名,"
+ "对简称或同名实体结合上下文消歧。不要补充材料中没有的信息,按接口要求输出结构化 JSON。"
+ ),
+ "synonym_merge_enable": True,
+ "disambiguation_enable": True,
+ }
+ graph_config = kb.get("graph_config")
+ if isinstance(graph_config, str):
+ try:
+ graph_config = json.loads(graph_config)
+ except json.JSONDecodeError:
+ graph_config = {}
+ if not isinstance(graph_config, dict):
+ graph_config = {}
+ for key, value in graph_defaults.items():
+ current = graph_config.get(key)
+ if current is None or (isinstance(current, str) and not current.strip()):
+ graph_config[key] = value
+ kb["graph_config"] = graph_config
+
+ def demo_file(
+ number: int,
+ file_name: str,
+ file_type: str,
+ file_size: int,
+ days_ago: int,
+ status: int,
+ *,
+ hours_since_update: int = 2,
+ error_code: str | None = None,
+ failure_reason: str | None = None,
+ ) -> dict[str, Any]:
+ uploaded_at = now - days_ago * 24 * 60 * 60
+ updated_at = now - hours_since_update * 60 * 60
+ item: dict[str, Any] = {
+ "file_uuid": f"00000000-0000-4000-8000-{number:012d}",
+ "file_ino_no": 1000 + number,
+ "file_name": file_name,
+ "file_size": file_size,
+ "file_type": file_type,
+ "import_mode": "本地上传",
+ "first_upload_time": uploaded_at,
+ "update_time": updated_at,
+ "status": status,
+ }
+ if error_code:
+ item["error_code"] = error_code
+ if failure_reason:
+ item["extraction_failure_reason"] = failure_reason
+ item["reason"] = failure_reason
+ return item
+
+ vector_failure = (
+ "文档解析和分片已完成,但向量索引服务暂时不可用,未能写入向量库。请稍后重新提取。"
+ )
+ graph_failure = (
+ "文件已完成向量化,但实体关系抽取未能完成,图谱数据没有写入。请检查知识图谱配置后重试。"
+ )
+ fixtures = [
+ demo_file(1, "政务服务办事指南.pdf", "pdf", 1_835_008, 18, 1, hours_since_update=24),
+ demo_file(2, "城市运行管理制度汇编.docx", "docx", 3_276_800, 16, 1, hours_since_update=12),
+ demo_file(
+ 3, "公共服务目录更新.docx", "docx", 482_304, 2, 3,
+ hours_since_update=1, error_code="VEC101", failure_reason=vector_failure,
+ ),
+ demo_file(4, "新市民服务政策手册.pdf", "pdf", 2_146_304, 0, 4, hours_since_update=0),
+ demo_file(
+ 5, "城市设施关联关系数据.xlsx", "xlsx", 786_432, 1, 5,
+ hours_since_update=3, error_code="GRAPH201", failure_reason=graph_failure,
+ ),
+ demo_file(6, "医保异地办理流程说明.txt", "txt", 36_864, 0, 2, hours_since_update=0),
+ demo_file(7, "公共服务事项清单.xlsx", "xlsx", 917_504, 14, 1, hours_since_update=48),
+ demo_file(8, "12345热线常见问题与答复.md", "md", 58_368, 12, 1, hours_since_update=30),
+ demo_file(9, "基层治理工作周报.csv", "csv", 393_216, 10, 1, hours_since_update=20),
+ demo_file(10, "应急预案与值班流程.pptx", "pptx", 6_815_744, 8, 1, hours_since_update=16),
+ demo_file(11, "市民服务大厅导览.png", "png", 2_752_512, 6, 1, hours_since_update=8),
+ demo_file(12, "城市运行平台使用说明.txt", "txt", 44_032, 4, 1, hours_since_update=4),
+ ]
+
+ documents = _DOCUMENTS_BY_KB.setdefault(kds_id, [])
+ documents_by_uuid = {
+ str(document.get("file_uuid")): document
+ for document in documents
+ if isinstance(document, dict) and document.get("file_uuid")
+ }
+ legacy_names = {
+ "00000000-0000-4000-8000-000000000001": {"常见问题汇总.txt"},
+ "00000000-0000-4000-8000-000000000002": {"troubleshooting.md"},
+ "00000000-0000-4000-8000-000000000003": {"向量入库失败示例.docx"},
+ "00000000-0000-4000-8000-000000000004": {"排队中的制度说明.pdf"},
+ "00000000-0000-4000-8000-000000000005": {"图谱入库失败示例.pdf"},
+ "00000000-0000-4000-8000-000000000006": {"正在提取的服务手册.txt"},
+ }
+ for fixture in fixtures:
+ existing = documents_by_uuid.get(fixture["file_uuid"])
+ if existing is None:
+ documents.append(fixture)
+ documents_by_uuid[fixture["file_uuid"]] = fixture
+ else:
+ if not existing.get("file_name") or existing.get("file_name") in legacy_names.get(
+ fixture["file_uuid"], set()
+ ):
+ existing["file_name"] = fixture["file_name"]
+ if existing.get("status") is None or existing.get("status") == "":
+ existing["status"] = fixture["status"]
+ raw_status = str(existing.get("status", "")).strip().upper()
+ status_aliases = {
+ "COMPLETED": "1",
+ "SUCCESS": "1",
+ "PROCESSING": "2",
+ "EXTRACTING": "2",
+ "FAILED": "3",
+ "VECTOR_INGESTION_FAILED": "3",
+ "UPLOADING": "4",
+ "QUEUED": "4",
+ "GRAPH_INGESTION_FAILED": "5",
+ }
+ status_matches_fixture = (
+ status_aliases.get(raw_status, raw_status) == str(fixture["status"])
+ )
+ for key, value in fixture.items():
+ if key in {"file_name", "status"}:
+ continue
+ if key in {"error_code", "extraction_failure_reason", "reason"}:
+ if status_matches_fixture and not existing.get(key):
+ existing[key] = value
+ continue
+ current = existing.get(key)
+ if current is None or (isinstance(current, str) and not current.strip()):
+ existing[key] = value
+
+ logger.info(
+ "DEMO FIXTURE ensured Chinese knowledge base %s with %d files",
+ kds_id,
+ len(documents),
+ )
+ _save_state()
+
+
+_ensure_detail_demo_tasks()
+_ensure_chinese_demo_data()
_ensure_document_uuids()
_save_state()
@@ -295,30 +648,51 @@ def _channel_src_dir(kds_id: str) -> str:
def _kds_id_from_dir_path(dir_path: Optional[str]) -> Optional[str]:
- """Reverse ``_channel_src_dir`` so a History request maps back to one KB."""
+ """Accept the source path and the confirmed AIDP request path ``/{kds_id}``."""
if not isinstance(dir_path, str):
return None
+ normalized = dir_path.strip("/")
+ if normalized in _KNOWLEDGE_BASES:
+ return normalized
prefix = f"{_CHANNEL_ROOT}/"
if not dir_path.startswith(prefix):
return None
return dir_path[len(prefix):].strip("/") or None
-def _doc_effective_status(doc: Dict[str, Any]) -> str:
+def _doc_effective_status(doc: Dict[str, Any]) -> int | str:
"""Return the document's current status, advancing the processing timer.
- Documents persisted before status simulation existed (and the seed data)
- carry no status at all and are treated as already ingested.
+ AIDP's recent status contract uses numeric strings: 1 success, 2 extracting,
+ 3 vector-ingestion failure, 4 queued, and 5 graph-ingestion failure.
+ Documents persisted before status simulation existed are treated as success.
"""
status = doc.get("status")
if status is None or status == "":
- return STATUS_COMPLETED
+ return 1
if status == STATUS_PROCESSING:
deadline = doc.get("processing_until")
if isinstance(deadline, (int, float)) and time.time() < deadline:
- return STATUS_PROCESSING
- return STATUS_COMPLETED
- return str(status)
+ return 2
+ return 1
+
+ normalized = str(status).strip().upper()
+ aliases = {
+ "COMPLETED": 1,
+ "SUCCESS": 1,
+ "PROCESSING": 2,
+ "EXTRACTING": 2,
+ "FAILED": 3,
+ "VECTOR_INGESTION_FAILED": 3,
+ "UPLOADING": 4,
+ "QUEUED": 4,
+ "GRAPH_INGESTION_FAILED": 5,
+ }
+ if normalized in aliases:
+ return aliases[normalized]
+ if normalized.isdigit():
+ return int(normalized)
+ return normalized
def _visible_in_completed_listing(doc: Dict[str, Any]) -> bool:
@@ -327,7 +701,29 @@ def _visible_in_completed_listing(doc: Dict[str, Any]) -> bool:
Real AIDP only exposes ingested files there; files still being processed are
invisible, which is exactly the behaviour the history endpoint replaces.
"""
- return _doc_effective_status(doc) == STATUS_COMPLETED
+ return _doc_effective_status(doc) == 1
+
+
+def _document_timestamp(document: Dict[str, Any]) -> float | None:
+ """Return the best available upload timestamp in epoch seconds."""
+ value = (
+ document.get("first_upload_time")
+ or document.get("create_time")
+ or document.get("created_at")
+ or document.get("update_time")
+ or document.get("updated_at")
+ )
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
+ return float(value)
+ if isinstance(value, str) and value.strip():
+ try:
+ parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed.timestamp()
+ except ValueError:
+ return None
+ return None
# =============================================================================
@@ -339,11 +735,69 @@ class CreateKbBody(BaseModel):
embedding_model: Optional[str] = None
is_multimodal: Optional[bool] = None
vision_model: Optional[str] = None
+ chunk_token_num: int = 1024
+ chunk_overlap_num: int = 128
+ chunk_mode: int = 0
+ is_personal: int = 0
+ topk: int = 10
+ similarity: float = 0.6
+ caption_enable: int = 0
+ vlm_model: str = ""
+ is_exist_graph: bool = False
+ graph_config: Optional[str] = None
+
+
+def _graph_template(language: str) -> Dict[str, Any]:
+ """Use the captured Chinese response; English templates remain synthetic fixtures."""
+ if language == "chinese":
+ fixture = Path(__file__).with_name("graph_config_template_chinese.json")
+ return json.loads(fixture.read_text(encoding="utf-8"))
+ english = True
+ prompts = {
+ domain: (f"Extract entity relationship triples for the {domain} domain."
+ if english else f"你是{domain}领域的信息抽取专家,请抽取明确的实体及其关系三元组。")
+ for domain in ("医疗", "金融", "常规", "法律法规")
+ }
+ entries = [
+ ("retrieve_subgraph_hop", "子图扩展跳数", "2", "^[1-3]$", True),
+ ("no_think_mode", "禁用LLM思考过程", "是", "^(是|否)$", True),
+ ("prompt_language", "提示词语言", "英文" if english else "中文", "^(中文|英文)$", False),
+ ("domain", "领域类型", "常规", "^(医疗|金融|常规|法律法规)$", False),
+ ("prompt_text", "知识抽取提示词", prompts["常规"], r"^[\s\S]{1,4096}$", False),
+ ("synonym_merge_enable", "同义词检测开关", "否", "^(是|否)$", True),
+ ("disambiguation_enable", "实体消歧开关", "否", "^(是|否)$", True),
+ ]
+ value = [dict(param_key=key, param_name=name, param_value=default,
+ regexp=regexp, is_modifiable=modifiable, param_desc=name)
+ for key, name, default, regexp, modifiable in entries]
+ value[4]["template"] = prompts
+ return {"value": value}
+
+
+def _validate_graph_request(body: CreateKbBody) -> None:
+ if not body.is_exist_graph:
+ return
+ try:
+ config = json.loads(body.graph_config or "")
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(400, "graph_config must be a JSON string") from exc
+ parameters = _graph_template("chinese")["value"]
+ allowed = {p["param_key"] for p in parameters} | {"llm_model_name"}
+ if not isinstance(config, dict) or set(config) - allowed:
+ raise HTTPException(400, "Unrecognized graph configuration parameter")
+ for parameter in parameters:
+ value = config.get(parameter["param_key"])
+ if not isinstance(value, str) or re.fullmatch(parameter["regexp"], value) is None:
+ raise HTTPException(400, f"Invalid graph parameter: {parameter['param_key']}")
+ if "llm_model_name" in config and not isinstance(config["llm_model_name"], str):
+ raise HTTPException(400, "Invalid llm_model_name")
class UpdateKbBody(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
+ chunk_mode: Optional[int] = None
+ topk: Optional[int] = None
class DocHistoryBody(BaseModel):
@@ -352,6 +806,13 @@ class DocHistoryBody(BaseModel):
fs_id: Optional[str] = None
dir_path: Optional[str] = None
page: int = 1
+ page_size: Optional[int] = Field(default=None, ge=1, le=100)
+ status: Optional[int] = Field(default=None, ge=0, le=5)
+ keyword: Optional[str] = Field(default=None, max_length=200)
+
+
+class RetryDocumentsBody(BaseModel):
+ file_uuids: List[str] = Field(..., min_length=1)
class DocStatusBody(BaseModel):
@@ -483,6 +944,17 @@ def reset_failures() -> JSONResponse:
})
+@app.post("/_mock/upload-seconds")
+def set_upload_seconds(
+ seconds: float = Query(10.0, ge=0.0, le=600.0, description="Upload response delay in seconds"),
+) -> JSONResponse:
+ """Delay each batch once without blocking other mock requests."""
+ global _UPLOAD_SECONDS
+ _UPLOAD_SECONDS = seconds
+ logger.info("MOCK CONFIG upload response delay = %s", seconds)
+ return JSONResponse(content={"upload_seconds": _UPLOAD_SECONDS})
+
+
@app.post("/_mock/processing-seconds")
def set_processing_seconds(
seconds: float = Query(8.0, ge=0.0, le=600.0, description="Seconds a new upload stays PROCESSING"),
@@ -568,7 +1040,13 @@ def list_knowledge_bases(
start = (page - 1) * page_size
end = start + page_size
# Enrich each item with document_count (same as detail endpoint does)
- enriched = [{**kb, "document_count": len(_DOCUMENTS_BY_KB.get(kb["kds_id"], []))} for kb in all_items]
+ enriched = [{
+ **kb,
+ "document_count": sum(
+ 1 for doc in _DOCUMENTS_BY_KB.get(kb["kds_id"], [])
+ if _visible_in_completed_listing(doc)
+ ),
+ } for kb in all_items]
items = enriched[start:end]
next_link = None
@@ -624,10 +1102,12 @@ def create_knowledge_base(
) -> JSONResponse:
"""Create a new knowledge base. AIDP uses PUT on the collection endpoint."""
_check_auth(authorization)
+ _validate_graph_request(body)
kds_id = f"aidp-kb-{uuid.uuid4().hex[:8]}"
now = int(time.time())
new_kb = {
+ **body.model_dump(exclude_none=True),
"kds_id": kds_id,
"kds_name": body.name,
"description": body.description or "",
@@ -650,6 +1130,15 @@ def create_knowledge_base(
return JSONResponse(content=new_kb)
+@app.get(f"{_KB_PREFIX}/GraphConfigTemplate")
+def graph_config_template(
+ language: str = Query("chinese", pattern="^(chinese|english)$"),
+ authorization: Optional[str] = Header(default=None),
+) -> JSONResponse:
+ _check_auth(authorization)
+ return JSONResponse(content=_graph_template(language))
+
+
@app.get(f"{_KB_PREFIX}/{{kds_id}}")
def get_knowledge_base(
kds_id: str,
@@ -664,7 +1153,10 @@ def get_knowledge_base(
# Augment with document count for richer responses
docs = _DOCUMENTS_BY_KB.get(kds_id, [])
- result = {**kb, "document_count": len(docs)}
+ result = {
+ **kb,
+ "document_count": sum(1 for doc in docs if _visible_in_completed_listing(doc)),
+ }
logger.info("GET kds_id=%s", kds_id)
return JSONResponse(content=result)
@@ -687,6 +1179,10 @@ def update_knowledge_base(
kb["kds_name"] = body.name
if body.description is not None:
kb["description"] = body.description
+ if body.chunk_mode is not None:
+ kb["chunk_mode"] = body.chunk_mode
+ if body.topk is not None:
+ kb["topk"] = body.topk
kb["update_time"] = int(time.time())
logger.info("UPDATE kds_id=%s name=%r description=%r", kds_id, body.name, body.description)
@@ -730,6 +1226,9 @@ async def upload_documents(
if kds_id not in _KNOWLEDGE_BASES:
raise HTTPException(status_code=404, detail=f"Knowledge base {kds_id} not found")
+ if _UPLOAD_SECONDS > 0:
+ await asyncio.sleep(_UPLOAD_SECONDS)
+
success_docs: List[Dict[str, Any]] = []
failed: List[Dict[str, str]] = []
for f in files:
@@ -793,7 +1292,8 @@ async def upload_documents(
def list_documents(
kds_id: str,
page: int = Query(1, ge=1),
- page_size: int = Query(20, ge=1, le=100),
+ page_size: int = Query(10, ge=1, le=100),
+ keyword: Optional[str] = Query(default=None, max_length=200),
authorization: Optional[str] = Header(default=None),
) -> JSONResponse:
"""List documents in a knowledge base with pagination.
@@ -811,16 +1311,24 @@ def list_documents(
doc for doc in _DOCUMENTS_BY_KB.get(kds_id, [])
if _visible_in_completed_listing(doc)
]
+ normalized_keyword = keyword.strip().casefold() if isinstance(keyword, str) else ""
+ if normalized_keyword:
+ all_docs = [
+ doc for doc in all_docs
+ if normalized_keyword in str(doc.get("file_name") or "").casefold()
+ ]
start = (page - 1) * page_size
end = start + page_size
items = [_public_document(doc) for doc in all_docs[start:end]]
# Real AIDP returns `next_link` as the authoritative "more pages exist"
# signal. When there are no more docs, next_link is simply absent.
- # `total_count` is the current page count, not the true total.
+ # `total_count` mirrors AIDP's page count; next_link signals another page.
next_link = None
if end < len(all_docs):
next_link = f"{_KB_PREFIX}/{kds_id}/KnowledgeFiles?page={page + 1}&page_size={page_size}"
+ if normalized_keyword:
+ next_link += f"&keyword={quote(keyword.strip(), safe='')}"
logger.info("LIST DOCS kds_id=%s page=%d returned=%d total=%d", kds_id, page, len(items), len(all_docs))
return JSONResponse(content={
@@ -957,12 +1465,12 @@ def knowledge_file_history(
body: DocHistoryBody,
authorization: Optional[str] = Header(default=None),
) -> JSONResponse:
- """List every file in a channel directory, whatever its processing status.
+ """Return one filtered page of this channel's recent file history.
The endpoint is knowledge-base scoped, like the channel catalog and the
document list; the body still addresses the request to one channel directory
- of that KB. Documents that are still PROCESSING are included with their live
- status, and a directory pointing outside the KB answers with an empty list.
+ of that KB. AIDP retains the last 30 days of history, supports status and
+ file-name filtering, and includes summary counts independent of the filters.
"""
_check_auth(authorization)
@@ -981,29 +1489,57 @@ def knowledge_file_history(
)
return JSONResponse(content={"value": []})
- items = [
+ cutoff = time.time() - 30 * 24 * 60 * 60
+ all_recent_items = [
{
**doc,
"dir_path": _channel_src_dir(kds_id),
"status": _doc_effective_status(doc),
}
for doc in _DOCUMENTS_BY_KB.get(kds_id, [])
+ if (_document_timestamp(doc) is not None
+ and _document_timestamp(doc) >= cutoff)
]
- # Real AIDP lists files that are still being processed first and pages the
- # directory, which is what lets more simultaneous uploads than fit in one
- # page spill onto the next. Mirrored here, so a caller that reads only the
- # first page is caught locally instead of in production. The sort is stable,
- # so documents keep their insertion order inside each group.
- items.sort(key=lambda item: item["status"] in _TERMINAL_STATUSES)
+ all_recent_items.sort(key=lambda item: item["status"] in _TERMINAL_STATUSES)
+ stats = {
+ "total_record_count": len(all_recent_items),
+ "success_record_count": sum(1 for item in all_recent_items if item["status"] == 1),
+ "processing_record_count": sum(1 for item in all_recent_items if item["status"] == 2),
+ "failed_record_count": sum(1 for item in all_recent_items if item["status"] in (3, 5)),
+ "queued_record_count": sum(1 for item in all_recent_items if item["status"] == 4),
+ }
+ items = list(all_recent_items)
+ if body.status not in (None, 0):
+ status_aliases = {
+ 1: {1, "1", STATUS_COMPLETED, "SUCCESS"},
+ 2: {2, "2", "PROCESSING", "EXTRACTING"},
+ 3: {3, "3", STATUS_FAILED, "VECTOR_INGESTION_FAILED"},
+ 4: {4, "4", "UPLOADING", "QUEUED"},
+ 5: {5, "5", "GRAPH_INGESTION_FAILED"},
+ }
+ accepted = status_aliases.get(body.status, set())
+ items = [item for item in items if item["status"] in accepted]
+ normalized_keyword = body.keyword.strip().casefold() if isinstance(body.keyword, str) else ""
+ if normalized_keyword:
+ items = [
+ item for item in items
+ if normalized_keyword in str(item.get("file_name") or "").casefold()
+ ]
page = body.page if isinstance(body.page, int) and body.page > 0 else 1
- start = (page - 1) * _HISTORY_PAGE_SIZE
- end = start + _HISTORY_PAGE_SIZE
+ page_size = body.page_size or _HISTORY_PAGE_SIZE
+ start = (page - 1) * page_size
+ end = start + page_size
page_items = items[start:end]
next_link = (
f"{_KB_PREFIX}/{kds_id}/KnowledgeFiles/History?page={page + 1}"
+ f"&page_size={page_size}"
if end < len(items)
else None
)
+ if next_link and body.status not in (None, 0):
+ next_link += f"&status={body.status}"
+ if next_link and normalized_keyword:
+ next_link += f"&keyword={quote(body.keyword.strip(), safe='')}"
logger.info(
"FILE HISTORY kds_id=%s fs_id=%s dir_path=%s page=%d returned=%d total=%d",
kds_id, body.fs_id, body.dir_path, page, len(page_items), len(items),
@@ -1012,6 +1548,45 @@ def knowledge_file_history(
"value": page_items,
"total_count": len(items),
"next_link": next_link,
+ **stats,
+ })
+
+
+@app.post(f"{_KB_PREFIX}/{{kds_id}}/KnowledgeFiles/Retry")
+def retry_documents(
+ kds_id: str,
+ body: RetryDocumentsBody,
+ authorization: Optional[str] = Header(default=None),
+) -> JSONResponse:
+ """Queue one or more failed files using the AIDP array request shape."""
+ _check_auth(authorization)
+ if kds_id not in _KNOWLEDGE_BASES:
+ raise HTTPException(status_code=404, detail=f"Knowledge base {kds_id} not found")
+
+ success_list: list[dict[str, str]] = []
+ failed_list: list[dict[str, str]] = []
+ retryable = {3, 5, "3", "5", STATUS_FAILED, "VECTOR_INGESTION_FAILED", "GRAPH_INGESTION_FAILED"}
+ for file_uuid in dict.fromkeys(body.file_uuids):
+ doc = _find_document(kds_id, file_uuid)
+ if doc is None or doc.get("status") not in retryable:
+ failed_list.append({"file_uuid": file_uuid, "reason": "File is not in a retryable failed state"})
+ continue
+ doc["status"] = 4
+ doc["update_time"] = int(time.time())
+ doc.pop("error_code", None)
+ doc.pop("extraction_failure_reason", None)
+ doc.pop("reason", None)
+ success_list.append({"file_uuid": file_uuid})
+
+ _save_state()
+ return JSONResponse(content={
+ "summary": {
+ "total": len(success_list) + len(failed_list),
+ "success": len(success_list),
+ "failed": len(failed_list),
+ },
+ "success_list": success_list,
+ "failed_list": failed_list,
})
@@ -1146,12 +1721,18 @@ def fusion_search(
# ``application`` field determines which AIDP app a model can serve (the
# backend's ``_is_kb_applicable`` post-filters by "All" or the requested app).
_MOCK_MODELS: List[Dict[str, Any]] = [
+ {
+ "model_name": "/models/FileEmbeddingModel_v0_1_1/v1.0.0/FileEmbeddingModel_v0_1_1",
+ "display_name": "bge-m3", "model_type": "embedding",
+ "application": ["KnowledgeBase", "MemoryBase"], "service": "embedding",
+ },
{
"api_key": "",
"application": "All",
"created_at": 1782716626,
"max_tokens": 32768,
"model_name": "model_1",
+ "display_name": "Qwen3-8B", "model_type": "llm",
"properties": {"description": "General purpose LLM.", "model_type": "external"},
"service": "llm",
"temperature": 0.6,
@@ -1162,6 +1743,7 @@ def fusion_search(
{
"application": ["KnowledgeBase"],
"model_name": "Qwen3-VL-8B-Instruct",
+ "display_name": "Qwen3-VL-8B-Instruct", "model_type": "vlm",
"properties": {"description": "Vision-language model served internally for caption generation.", "model_type": "internal"},
"service": "llm",
"url": "http://caption-service.model-service.svc.cluster.local:8111/v1/chat/completions",
@@ -1172,6 +1754,7 @@ def fusion_search(
"created_at": 1783070801,
"max_tokens": 32768,
"model_name": "Qwen3-VL-32B-Instruct",
+ "display_name": "Qwen3-VL-32B-Instruct", "model_type": "vlm",
"properties": {"description": "Larger vision-language model for high-quality captioning.", "model_type": "external"},
"service": "llm",
"temperature": 0.6,
@@ -1185,6 +1768,7 @@ def fusion_search(
"created_at": 1783474808,
"max_tokens": 32780,
"model_name": "InternVL2-26B",
+ "display_name": "InternVL2-26B", "model_type": "vlm",
"properties": {"description": "Open-source multimodal model.", "model_type": "external"},
"service": "llm",
"temperature": 1.5,
@@ -1199,6 +1783,7 @@ def fusion_search(
"application": ["DocumentParsing"],
"created_at": 1783062626,
"model_name": "doc-parser-only",
+ "display_name": "Document parser", "model_type": "llm",
"properties": {"description": "Should NOT appear for KnowledgeBase.", "model_type": "external"},
"service": "llm",
},
@@ -1207,7 +1792,7 @@ def fusion_search(
@app.get(_MODELS_PREFIX)
def list_models(
- service: str = Query("llm"),
+ service: str = Query(""),
app: str = Query("KnowledgeBase"),
authorization: Optional[str] = Header(default=None),
) -> JSONResponse:
@@ -1217,10 +1802,11 @@ def list_models(
raw seed data here.
"""
_check_auth(authorization)
- logger.info("LIST MODELS service=%s app=%s returned=%d", service, app, len(_MOCK_MODELS))
+ models = [m for m in _MOCK_MODELS if not service or m["model_type"] == service]
+ logger.info("LIST MODELS service=%s app=%s returned=%d", service, app, len(models))
return JSONResponse(content={
"service": service,
- "models": _MOCK_MODELS,
+ "models": models,
})
@@ -1237,6 +1823,7 @@ def health() -> Dict[str, Any]:
"platform": "aidp-mock",
"version": "1.0.0",
"knowledge_bases_count": len(_KNOWLEDGE_BASES),
+ "upload_seconds": _UPLOAD_SECONDS,
}
@@ -1251,6 +1838,9 @@ def reset_state() -> Dict[str, str]:
_KNOWLEDGE_BASES.clear()
_DOCUMENTS_BY_KB.clear()
_seed_initial_data()
+ _ensure_detail_demo_tasks()
+ _ensure_chinese_demo_data()
+ _ensure_document_uuids()
_save_state()
logger.info("RESET state restored to seeds, persisted to %s", _STATE_FILE)
return {"status": "reset"}
@@ -1264,9 +1854,13 @@ def reset_state() -> Dict[str, str]:
parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
parser.add_argument("--port", type=int, default=30081, help="Bind port (default: 30081)")
parser.add_argument("--api-key", default=EXPECTED_API_KEY, help="Expected Bearer API key")
+ parser.add_argument("--upload-seconds", type=float, default=10.0, help="Upload response delay (default: 10 seconds)")
args = parser.parse_args()
+ if not 0 <= args.upload_seconds <= 600:
+ parser.error("--upload-seconds must be between 0 and 600")
EXPECTED_API_KEY = args.api_key
+ _UPLOAD_SECONDS = args.upload_seconds
import uvicorn
diff --git a/test/ext_components/aidp/mock_servers/graph_config_template_chinese.json b/test/ext_components/aidp/mock_servers/graph_config_template_chinese.json
new file mode 100644
index 0000000000..c400770058
--- /dev/null
+++ b/test/ext_components/aidp/mock_servers/graph_config_template_chinese.json
@@ -0,0 +1,66 @@
+{
+ "value": [
+ {
+ "param_key": "retrieve_subgraph_hop",
+ "param_name": "子图扩展跳数",
+ "param_value": "2",
+ "regexp": "^[1-3]$",
+ "param_desc": "PPR检索时子图扩展的跳数,范围1-3",
+ "is_modifiable": true
+ },
+ {
+ "param_key": "no_think_mode",
+ "param_name": "禁用LLM思考过程",
+ "param_value": "是",
+ "regexp": "^(是|否)$",
+ "param_desc": "禁用LLM思考过程,LLM提取三元组时只返回提取结果,不输出完整思考过程,提升三元组提取效率,可选是或否",
+ "is_modifiable": true
+ },
+ {
+ "param_key": "prompt_language",
+ "param_name": "提示词语言",
+ "param_value": "中文",
+ "regexp": "^(中文|英文)$",
+ "param_desc": "三元组抽取提示词语言,可选中文或英文",
+ "is_modifiable": false
+ },
+ {
+ "param_key": "domain",
+ "param_name": "领域类型",
+ "param_value": "常规",
+ "regexp": "^(医疗|金融|常规|法律法规)$",
+ "param_desc": "业务领域,可选医疗|金融|常规|法律法规",
+ "is_modifiable": false
+ },
+ {
+ "param_key": "prompt_text",
+ "param_name": "知识抽取提示词",
+ "param_value": "---Role---\n你是一个信息抽取专家,请从给定文本中按要求抽取出实体关系三元组。\n\n---任务要求---\n**实体要求:** \n- 实体必须是尽可能明确和具体的词语或短语(人名、机构名、物品名、任务、计划等),不要抽取时间。\n- 不能使用代词(如\"他\"、\"她\"、\"它\"),如果遇到代词,使用具体指代的事物作为实体。\n- 不能使用类别名作为实体(如\"公司\"、\"单位\"、\"信息\"、\"问题\"等),必须使用明确指代的事物作为实体。\n**关系要求:**\n- 关系应为能明确表示头实体与尾实体联系的最精炼和最有意义的词语或短语动词。\n- 避免使用没有实质意义的助动词或介词(如\"是\"、\"在\"、\"向\")。\n- 关系中不重复包含实体信息。\n",
+ "template": {
+ "医疗": "---Role---\n你是一个资深的医学自然语言处理(NLP)专家。你的任务是从提供的非结构化医疗文本中,准确地抽取实体(Entities)和它们之间的关系(Relations),以便用于构建医疗知识图谱。\n\n---实体要求---\n- 实体必须是尽可能明确和具体的词语或短语;不能使用代词(如\"他\"、\"她\"、\"它\");不能使用抽象概念作为实体(如\"公司\"、\"单位\"、\"信息\"、\"问题\"等)。\n- 请严格按照以下定义的实体类型进行抽取:\n * **Disease (疾病)**: 明确的疾病名称或综合征(例:高血压、2型糖尿病、支气管炎)。\n * **Symptom (症状)**: 疾病的临床表现或患者主诉(例:头晕、发热、恶心)。\n * **Drug (药品)**: 治疗所用的具体药物名称或有效成分(例:阿司匹林、二甲双胍)。\n * **Treatment (治疗方案)**: 非药物的治疗手段、手术或生活方式干预(例:放射治疗、阑尾切除术、低盐饮食)。\n * **Test (检查项目)**: 用于诊断或监测的医疗设备检查、化验(例:血常规、胸部CT扫描、核磁共振)。\n * **Anatomy (身体部位)**: 相关的解剖学结构、器官或系统(例:心脏、上呼吸道、胃黏膜)。\n\n---关系要求---\n- 关系应为能明确表示头实体与尾实体联系的最精炼和最有意义的词语或短语动词。\n- 避免使用没有实质意义的助动词或介词(如\"是\"、\"在\"、\"向\")。\n- 常见医疗关系类型:症状表现、治疗、诊断、发生部位、并发症、禁忌症等。\n",
+ "金融": "---Role---\n你是一个资深的金融自然语言处理(NLP)专家。你的任务是从提供的非结构化金融文本中,准确地抽取实体(Entities)和它们之间的关系(Relations),以便用于构建金融知识图谱。\n\n---实体要求---\n- 实体必须是尽可能明确和具体的词语或短语;不能使用代词(如\"他\"、\"她\"、\"它\");不能使用抽象概念作为实体(如\"公司\"、\"单位\"、\"信息\"等),必须使用具体名称。\n- 请严格按照以下定义的实体类型进行抽取:\n * **Company (公司)**: 企业、机构名称(例:腾讯、阿里巴巴、华为)。\n * **Stock (股票)**: 股票名称或代码(例:茅台、000001平安银行)。\n * **Fund (基金)**: 基金产品名称(例:华夏成长混合、易方达蓝筹精选)。\n * **Bank (银行)**: 银行机构名称(例:工商银行、招商银行)。\n * **Index (指数)**: 证券市场指数(例:上证指数、深证成指、纳斯达克指数)。\n * **Indicator (经济指标)**: 宏观经济数据指标(例:GDP、CPI、PPI、失业率)。\n * **Currency (货币)**: 货币类型(例:人民币、美元、欧元)。\n * **Person (人物)**: 金融领域关键人物(例:巴菲特、基金经理姓名)。\n\n---关系要求---\n- 关系应为能明确表示头实体与尾实体联系的最精炼和最有意义的词语或短语动词。\n- 避免使用没有实质意义的助动词或介词(如\"是\"、\"在\"、\"向\")。\n- 常见金融关系类型:投资、持股、关联、发行、交易、涨跌、合作等。\n",
+ "常规": "---Role---\n你是一个信息抽取专家,请从给定文本中按要求抽取出实体关系三元组。\n\n---任务要求---\n**实体要求:** \n- 实体必须是尽可能明确和具体的词语或短语(人名、机构名、物品名、任务、计划等),不要抽取时间。\n- 不能使用代词(如\"他\"、\"她\"、\"它\"),如果遇到代词,使用具体指代的事物作为实体。\n- 不能使用类别名作为实体(如\"公司\"、\"单位\"、\"信息\"、\"问题\"等),必须使用明确指代的事物作为实体。\n**关系要求:**\n- 关系应为能明确表示头实体与尾实体联系的最精炼和最有意义的词语或短语动词。\n- 避免使用没有实质意义的助动词或介词(如\"是\"、\"在\"、\"向\")。\n- 关系中不重复包含实体信息。\n",
+ "法律法规": "---Role---\n 你是一个资深的法律法规自然语言处理(NLP)专家。你的任务是从提供的非结构化法律法规文本中,准确地抽取实体(Entities)和它们之间的关系(Relations),以便用于构建法律法规知识图谱。\n ---实体要求---\n - 实体必须是尽可能明确和具体的词语或短语;不能使用代词(如\"他\"、\"她\"、\"它\");不能使用抽象概念作为实体(如\"组织\"、\"机构\"、\"事项\"等)。\n - 请严格按照以下定义的实体类型进行抽取:\n * **Law (法律法规)**: 明确的法律、法规、规章或规范性文件名称(例:《中华人民共和国民法典》、《个人信息保护法》、《道路交通安全法实施条例》)。\n * **Article (法条)**: 具体的法条编号或条款(例:第1064条、第十七条第二款、第一百四十三条)。\n * **LegalEntity (法律主体)**: 法律关系中享有权利或承担义务的具体主体(例:自然人、法人、用人单位、消费者、行政机关)。\n * **Obligation (法律义务)**: 主体依法或依约必须履行的具体义务(例:如实报告义务、纳税义务、安全保障义务)。\n * **Right (法律权利)**: 主体依法享有的具体权利(例:知情权、肖像权、合同解除权、行政复议申请权)。\n * **LegalAct (法律行为)**: 产生法律后果的具体行为或事件(例:签订合同、行政处罚、提起诉讼、登记备案)。\n * **Penalty (法律责任/处罚)**: 违法行为的具体法律后果或制裁措施(例:罚款、吊销许可证、有期徒刑、行政拘留)。\n * **RegulatedObject (规制对象)**: 法律所规制的具体活动、物品或场所(例:食品生产经营、机动车驾驶、危险化学品运输)。\n\n ---关系要求---\n - 关系应为能明确表示头实体与尾实体联系的最精炼和最有意义的词语或短语动词。\n - 避免使用没有实质意义的助动词或介词(如\"是\"、\"在\"、\"向\")。\n - 常见法律法规关系类型:规定于、适用条件、违反后果、权利归属、义务主体、规制范围、参照适用、修订替代、实施细则等。\n"
+ },
+ "regexp": "^[\\s\\S]{1,4096}$",
+ "param_desc": "自定义三元组抽取提示词,可参考模板来编写,长度限制为1-4096",
+ "is_modifiable": false
+ },
+ {
+ "param_key": "synonym_merge_enable",
+ "param_name": "同义词检测开关",
+ "param_value": "否",
+ "regexp": "^(是|否)$",
+ "param_desc": "建图时是否启用同义词检测,启用后相似实体间建立关系,可选是或否",
+ "is_modifiable": true
+ },
+ {
+ "param_key": "disambiguation_enable",
+ "param_name": "实体消歧开关",
+ "param_value": "否",
+ "regexp": "^(是|否)$",
+ "param_desc": "建图时是否启用同名实体消歧,启用后相似实体会合并,可选是或否",
+ "is_modifiable": true
+ }
+ ]
+}
diff --git a/test/ext_components/aidp/test_aidp_creator_service.py b/test/ext_components/aidp/test_aidp_creator_service.py
new file mode 100644
index 0000000000..4c289f1e8f
--- /dev/null
+++ b/test/ext_components/aidp/test_aidp_creator_service.py
@@ -0,0 +1,36 @@
+"""Creator metadata remains local, batched and scoped to the current tenant."""
+import importlib.util
+import sys
+from pathlib import Path
+from types import ModuleType
+from unittest.mock import MagicMock
+
+import pytest
+
+
+@pytest.fixture
+def creator_module(monkeypatch):
+ database = ModuleType("database.user_tenant_db")
+ database.get_user_email_map = MagicMock(return_value={"u-1": "admin@example.test"})
+ monkeypatch.setitem(sys.modules, "database.user_tenant_db", database)
+ source = Path(__file__).parents[3] / "backend/ext_components/aidp/services/aidp_creator_service.py"
+ spec = importlib.util.spec_from_file_location("isolated_creator_service", source)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def test_creator_lookup_is_batched_and_tenant_scoped(creator_module):
+ result = creator_module.get_nexent_creator_names(["u-1", None, "u-1", "u-2"], "tenant-a")
+ creator_module.get_user_email_map.assert_called_once_with(["u-1", "u-2"], tenant_id="tenant-a")
+ assert result == {"u-1": "admin@example.test"}
+
+
+def test_missing_owners_skip_database_query(creator_module):
+ assert creator_module.get_nexent_creator_names([None, ""], "tenant-a") == {}
+ creator_module.get_user_email_map.assert_not_called()
+
+
+def test_lookup_failure_does_not_replace_owner_with_remote_or_current_user(creator_module):
+ creator_module.get_user_email_map.side_effect = RuntimeError("database unavailable")
+ assert creator_module.get_nexent_creator_names(["deleted-owner"], "tenant-a") == {}
diff --git a/test/ext_components/aidp/test_aidp_kb_pages_refactor.py b/test/ext_components/aidp/test_aidp_kb_pages_refactor.py
new file mode 100644
index 0000000000..c84281a94d
--- /dev/null
+++ b/test/ext_components/aidp/test_aidp_kb_pages_refactor.py
@@ -0,0 +1,187 @@
+"""Unit tests for the AIDP knowledge base page refactor service helpers.
+
+Covers the legacy implementation helpers for defaults, chunk mode, overlap
+conversion, and graph configuration validation and serialization.
+"""
+
+import json
+
+import pytest
+
+from consts.exceptions import AppException
+from ext_components.aidp.services.aidp_service import (
+ _apply_create_defaults,
+ _serialize_graph_config,
+ _validate_chunking,
+)
+
+
+class TestSerializeGraphConfig:
+ """The structured graph form is validated here and serialized for AIDP."""
+
+ def test_serializes_documented_keys(self):
+ result = _serialize_graph_config(
+ {
+ "domain": "medical",
+ "retrieve_default_topk": 7,
+ "retrieve_subgraph_hop": 3,
+ "no_think_mode": False,
+ "prompt_language": "english",
+ "prompt_text": "extract entities",
+ "synonym_merge_enable": True,
+ "disambiguation_enable": True,
+ }
+ )
+
+ assert json.loads(result) == {
+ "domain": "medical",
+ "retrieve_default_topk": 7,
+ "retrieve_subgraph_hop": 3,
+ "no_think_mode": False,
+ "prompt_language": "english",
+ "prompt_text": "extract entities",
+ "synonym_merge_enable": True,
+ "disambiguation_enable": True,
+ }
+
+ def test_applies_documented_defaults(self):
+ payload = json.loads(_serialize_graph_config({}))
+
+ assert payload["domain"] == "general"
+ assert payload["retrieve_default_topk"] == 5
+ assert payload["retrieve_subgraph_hop"] == 2
+ assert payload["no_think_mode"] is True
+ assert payload["prompt_language"] == "chinese"
+ assert payload["prompt_text"] == ""
+ assert payload["synonym_merge_enable"] is False
+ assert payload["disambiguation_enable"] is False
+
+ def test_ignores_unknown_keys(self):
+ payload = json.loads(_serialize_graph_config({"unexpected": "value"}))
+
+ assert "unexpected" not in payload
+
+ def test_rejects_unknown_domain(self):
+ with pytest.raises(AppException):
+ _serialize_graph_config({"domain": "legal"})
+
+ def test_rejects_unknown_prompt_language(self):
+ with pytest.raises(AppException):
+ _serialize_graph_config({"prompt_language": "french"})
+
+ def test_rejects_non_string_prompt(self):
+ with pytest.raises(AppException):
+ _serialize_graph_config({"prompt_text": 123})
+
+ def test_rejects_prompt_over_byte_limit(self):
+ # 700 Chinese characters are 2100 UTF-8 bytes, above the 2048 limit.
+ with pytest.raises(AppException):
+ _serialize_graph_config({"prompt_text": "中" * 700})
+
+ def test_accepts_prompt_at_byte_limit(self):
+ prompt = "中" * 682 # 2046 bytes
+
+ payload = json.loads(_serialize_graph_config({"prompt_text": prompt}))
+
+ assert payload["prompt_text"] == prompt
+
+ @pytest.mark.parametrize("value", [0, 101, "5", True])
+ def test_rejects_invalid_candidate_topk(self, value):
+ with pytest.raises(AppException):
+ _serialize_graph_config({"retrieve_default_topk": value})
+
+ @pytest.mark.parametrize("value", [0, 4, "2", True])
+ def test_rejects_invalid_hop_count(self, value):
+ with pytest.raises(AppException):
+ _serialize_graph_config({"retrieve_subgraph_hop": value})
+
+
+class TestValidateChunking:
+ """The overlap must stay within half of the chunk size."""
+
+ def test_accepts_existing_default_pair(self):
+ _validate_chunking({"chunk_token_num": 1024, "chunk_overlap_num": 128})
+
+ def test_accepts_exact_half(self):
+ _validate_chunking({"chunk_token_num": 512, "chunk_overlap_num": 256})
+
+ def test_rejects_overlap_above_half(self):
+ with pytest.raises(AppException):
+ _validate_chunking({"chunk_token_num": 512, "chunk_overlap_num": 300})
+
+ def test_rejects_negative_overlap(self):
+ with pytest.raises(AppException):
+ _validate_chunking({"chunk_token_num": 1024, "chunk_overlap_num": -1})
+
+ @pytest.mark.parametrize("tokens", [0, -5])
+ def test_skips_non_positive_chunk_size(self, tokens):
+ # A non-positive chunk size is the upstream contract's business and the
+ # ratio cannot be evaluated against it.
+ _validate_chunking({"chunk_token_num": tokens, "chunk_overlap_num": 128})
+
+ def test_skips_when_chunk_size_is_missing(self):
+ _validate_chunking({"chunk_overlap_num": 128})
+
+ def test_skips_non_integer_values(self):
+ _validate_chunking({"chunk_token_num": "1024", "chunk_overlap_num": "128"})
+
+
+class TestApplyCreateDefaults:
+ """Creation defaults and chunk mode survive the merge."""
+
+ def test_fills_chunk_mode_and_graph_defaults(self):
+ result = _apply_create_defaults({"name": "kb"})
+
+ assert result["chunk_mode"] == 0
+ assert result["is_exist_graph"] is False
+ assert result["chunk_token_num"] == 1024
+ assert result["chunk_overlap_num"] == 128
+ assert result["topk"] == 10
+ assert result["similarity"] == 0.0
+ assert result["embedding_model"] == "default"
+ assert result["caption_enable"] == 0
+
+ def test_keeps_legal_chunk_mode(self):
+ result = _apply_create_defaults({"name": "kb", "chunk_mode": 1})
+
+ assert result["chunk_mode"] == 1
+
+ def test_disabled_graph_drops_graph_fields(self):
+ result = _apply_create_defaults(
+ {
+ "name": "kb",
+ "is_exist_graph": False,
+ "graph_config": {"domain": "medical"},
+ "llm_model_name": "llm-a",
+ }
+ )
+
+ assert "graph_config" not in result
+ assert "llm_model_name" not in result
+
+ def test_enabled_graph_serializes_configuration(self):
+ result = _apply_create_defaults(
+ {
+ "name": "kb",
+ "is_exist_graph": True,
+ "graph_config": {"domain": "finance", "no_think_mode": False},
+ "llm_model_name": "llm-a",
+ }
+ )
+
+ assert isinstance(result["graph_config"], str)
+ graph_config = json.loads(result["graph_config"])
+ assert graph_config["domain"] == "finance"
+ assert graph_config["llm_model_name"] == "llm-a"
+ assert "llm_model_name" not in result
+
+ def test_enabled_graph_without_config_is_left_alone(self):
+ result = _apply_create_defaults({"name": "kb", "is_exist_graph": True})
+
+ assert "graph_config" not in result
+
+ def test_rejects_invalid_overlap_ratio(self):
+ with pytest.raises(AppException):
+ _apply_create_defaults(
+ {"name": "kb", "chunk_token_num": 512, "chunk_overlap_num": 300}
+ )
diff --git a/test/ext_components/aidp/test_aidp_live_create_contract.py b/test/ext_components/aidp/test_aidp_live_create_contract.py
new file mode 100644
index 0000000000..5fa6a6895f
--- /dev/null
+++ b/test/ext_components/aidp/test_aidp_live_create_contract.py
@@ -0,0 +1,127 @@
+"""Regression coverage for the AIDP request captured during live integration."""
+import json
+from unittest.mock import MagicMock
+
+import pytest
+from fastapi.testclient import TestClient
+
+from test.ext_components.aidp.test_aidp_service import aidp_service_module
+from test.ext_components.aidp.test_aidp_mgmt_mock_server import mock_aidp_server
+
+
+def graph_config():
+ return {
+ "retrieve_subgraph_hop": "2", "no_think_mode": "是",
+ "prompt_language": "中文", "domain": "常规", "prompt_text": "提取实体关系。",
+ "synonym_merge_enable": "否", "disambiguation_enable": "否",
+ "llm_model_name": "qwen3_8b",
+ }
+
+
+def client_for(module, data):
+ client = MagicMock()
+ response = MagicMock(status_code=200)
+ response.json.return_value = data
+ client.get.return_value = response
+ client.put.return_value = response
+ module.http_client_manager.get_sync_client.return_value = client
+ return client
+
+
+def test_forwarded_create_matches_live_contract(aidp_service_module, mock_aidp_server):
+ module = aidp_service_module
+ upstream = TestClient(mock_aidp_server.app)
+ module.http_client_manager.get_sync_client.return_value = upstream
+ result = module.create_aidp_kb_impl("http://testserver", "mock-aidp-key", {
+ "name": "中文图谱知识库", "description": "中文测试资料",
+ "embedding_model": "/models/bge-m3", "chunk_token_num": 512,
+ "chunk_overlap_num": 51, "similarity": 0.6, "topk": 10,
+ "is_exist_graph": True, "graph_config": graph_config(), "smartsplit": 1,
+ })
+ saved = upstream.get(
+ f"{mock_aidp_server._KB_PREFIX}/{result['kds_id']}",
+ headers={"Authorization": "Bearer mock-aidp-key"},
+ ).json()
+ assert json.loads(saved["graph_config"]) == graph_config()
+ assert saved["embedding_model"] == "/models/bge-m3"
+ assert saved["topk"] == 10 and saved["is_personal"] == 0
+ assert saved["vlm_model"] == "" and saved["caption_enable"] == 0
+ assert "smartsplit" not in saved
+
+
+@pytest.mark.parametrize("key,value", [
+ ("retrieve_default_topk", "5"), ("retrieve_topk", "5"),
+ ("no_think_mode", True), ("synonym_merge_enable", False),
+ ("disambiguation_enable", False), ("domain", "general"),
+ ("prompt_language", "chinese"), ("retrieve_subgraph_hop", 2),
+ ("prompt_text", ""), ("prompt_text", "字" * 4097),
+])
+def test_mock_rejects_the_live_failures(mock_aidp_server, key, value):
+ config = graph_config()
+ config[key] = value
+ response = TestClient(mock_aidp_server.app).put(mock_aidp_server._KB_PREFIX,
+ headers={"Authorization": "Bearer mock-aidp-key"},
+ json={"name": "错误参数", "is_exist_graph": True,
+ "graph_config": json.dumps(config, ensure_ascii=False)})
+ assert response.status_code == 400
+
+
+def test_prompt_limit_counts_characters_and_disabled_graph_omits_config(aidp_service_module):
+ module = aidp_service_module
+ config = graph_config()
+ config["prompt_text"] = "字😀" * 2048
+ serialized = json.loads(module._serialize_graph_config(config))
+ assert len(serialized["prompt_text"]) == 4096
+ config["prompt_text"] += "字"
+ with pytest.raises(module.AppException):
+ module._serialize_graph_config(config)
+ disabled = module._apply_create_defaults({"name": "无图谱", "is_exist_graph": False,
+ "graph_config": config, "llm_model_name": "hidden"})
+ assert "graph_config" not in disabled and "llm_model_name" not in disabled
+
+
+@pytest.mark.parametrize("language", ["chinese", "english"])
+def test_template_query_and_model_query_use_single_endpoints(aidp_service_module, mock_aidp_server, language):
+ module = aidp_service_module
+ upstream = TestClient(mock_aidp_server.app)
+ module.http_client_manager.get_sync_client.return_value = upstream
+ template = module.get_aidp_graph_template_impl("http://testserver", "mock-aidp-key", language)
+ assert len(template["value"]) == 7
+ assert next(p for p in template["value"] if p["param_key"] == "prompt_text")["template"]["法律法规"]
+ models = module.list_aidp_models_impl("http://testserver", "mock-aidp-key", "")
+ assert {"llm", "vlm", "embedding"} <= {m["model_type"] for m in models["models"]}
+ assert "doc-parser-only" not in {m["model_name"] for m in models["models"]}
+
+
+def test_all_models_does_not_send_service_filter(aidp_service_module):
+ client = client_for(aidp_service_module, {"models": []})
+ aidp_service_module.list_aidp_models_impl("http://example.test", "key", "")
+ assert "app=KnowledgeBase" in client.get.call_args.args[0]
+ assert "service=" not in client.get.call_args.args[0]
+
+
+@pytest.mark.parametrize("domain,expected", [
+ ("常规", "不能使用代词"), ("医疗", "Disease (疾病)"),
+ ("金融", "Stock (股票)"), ("法律法规", "Law (法律法规)"),
+])
+def test_chinese_template_returns_complete_captured_prompts(mock_aidp_server, domain, expected):
+ response = TestClient(mock_aidp_server.app).get(
+ f"{mock_aidp_server._KB_PREFIX}/GraphConfigTemplate?language=chinese",
+ headers={"Authorization": "Bearer mock-aidp-key"},
+ )
+ assert response.status_code == 200
+ parameter = next(p for p in response.json()["value"] if p["param_key"] == "prompt_text")
+ text = parameter["template"][domain]
+ assert 200 < len(text) <= 4096
+ assert text.startswith("---Role---\n") and expected in text
+ assert "关系" in text and text.count("\n") >= 10
+ assert parameter["param_value"] == parameter["template"]["常规"]
+
+
+def test_old_internal_aliases_translate_without_graph_topk(aidp_service_module):
+ config = {**graph_config(), "domain": "general", "prompt_language": "chinese",
+ "no_think_mode": False, "retrieve_subgraph_hop": 3, "retrieve_default_topk": 5}
+ result = json.loads(aidp_service_module._serialize_graph_config(config))
+ assert result["domain"] == "常规" and result["prompt_language"] == "中文"
+ assert result["no_think_mode"] == "否" and result["retrieve_subgraph_hop"] == "3"
+ assert "retrieve_default_topk" not in result and "retrieve_topk" not in result
diff --git a/test/ext_components/aidp/test_aidp_mgmt_app.py b/test/ext_components/aidp/test_aidp_mgmt_app.py
index ada76510b4..0961915c35 100644
--- a/test/ext_components/aidp/test_aidp_mgmt_app.py
+++ b/test/ext_components/aidp/test_aidp_mgmt_app.py
@@ -81,6 +81,9 @@ def __init__(self, **kwargs):
# Production modules under test
from ext_components.aidp.apps.aidp_mgmt_app import ( # noqa: E402
+ CreateKbRequest,
+ SetPermissionRequest,
+ UpdateKbRequest,
aidp_mgmt_router,
)
from apps.app_factory import register_exception_handlers # noqa: E402
@@ -91,6 +94,11 @@ def __init__(self, **kwargs):
TENANT_ID = "tenant-test"
+def test_knowledge_base_api_models_do_not_expose_safety_guard():
+ for model in (CreateKbRequest, UpdateKbRequest, SetPermissionRequest):
+ assert "sensitive_intercept_enalbe" not in model.model_fields
+
+
@pytest.mark.parametrize("metadata,remote_fails", [
({}, False),
({"name": "Renamed"}, False),
@@ -194,6 +202,39 @@ def _bearer() -> dict:
return {"Authorization": "Bearer fake-token"}
+def test_detail_creator_comes_from_nexent_owner_in_current_tenant(monkeypatch):
+ from ext_components.aidp.apps import aidp_mgmt_app as module
+ lookup = MagicMock(return_value={"local-owner": "admin@example.test"})
+ monkeypatch.setattr(module.perms, "require_permission", lambda *a, **kw: MagicMock(permission="EDIT"))
+ monkeypatch.setattr(module, "get_aidp_kb_impl", lambda *a: {"kds_name": "资料库", "user_name": "remote-user"})
+ monkeypatch.setattr(module.aidp_permission_db, "get_permission_by_kb_id",
+ lambda *a: {"owner_user_id": "local-owner", "group_ids": []})
+ monkeypatch.setattr(module, "get_nexent_creator_names", lookup)
+ result = _client().get("/aidp-mgmt/knowledge-bases/kb-1", headers=_bearer()).json()
+ assert result["creator_name"] == "admin@example.test"
+ assert result["created_by"] == "local-owner"
+ lookup.assert_called_once_with(["local-owner"], TENANT_ID)
+
+
+def test_models_without_service_query_requests_all_categories(monkeypatch):
+ from ext_components.aidp.apps import aidp_mgmt_app as module
+ lookup = MagicMock(return_value={"models": [], "service": ""})
+ monkeypatch.setattr(module, "list_aidp_models_impl", lookup)
+ response = _client().get("/aidp-mgmt/models?app=KnowledgeBase", headers=_bearer())
+ assert response.status_code == 200
+ assert lookup.call_args.args[2:] == ("", "KnowledgeBase")
+
+
+def test_graph_template_route_is_authenticated_and_not_a_knowledge_base_id(monkeypatch):
+ from ext_components.aidp.apps import aidp_mgmt_app as module
+ lookup = MagicMock(return_value={"value": [{"param_key": "domain"}]})
+ monkeypatch.setattr(module, "get_aidp_graph_template_impl", lookup)
+ result = _client().get("/aidp-mgmt/knowledge-bases/graph-template?language=english", headers=_bearer())
+ assert result.status_code == 200 and result.json()["value"][0]["param_key"] == "domain"
+ lookup.assert_called_once_with(SERVER_URL, API_KEY, "english")
+ assert _client().get("/aidp-mgmt/knowledge-bases/graph-template").status_code == 401
+
+
# --- Auth (401) -----------------------------------------------------------
@@ -511,6 +552,37 @@ def test_list_returns_empty_when_no_rows(self):
assert body["total_count"] == 0
assert body["has_more"] is False
+ def test_list_resolves_legacy_owner_placeholder_to_current_nexent_user(self, monkeypatch):
+ client = _client()
+ from ext_components.aidp.apps import aidp_mgmt_app
+ from types import SimpleNamespace
+
+ def creator_lookup(user_ids, _tenant_id):
+ return {USER_ID: "admin@nexent.com"} if USER_ID in user_ids else {}
+
+ monkeypatch.setattr(aidp_mgmt_app, "get_nexent_creator_names", creator_lookup)
+ with patch.object(
+ aidp_mgmt_app,
+ "_current_accessible_rows",
+ return_value=[
+ {
+ "kb_id": "kb-legacy",
+ "owner_user_id": "user_id",
+ "permission": "EDIT",
+ "ingroup_permission": "PRIVATE",
+ "group_ids": [],
+ }
+ ],
+ ), patch.object(
+ aidp_mgmt_app,
+ "get_aidp_kb_impl",
+ return_value={"kds_name": "Legacy KB", "description": "desc"},
+ ):
+ response = client.get("/aidp-mgmt/knowledge-bases", headers=_bearer())
+
+ assert response.status_code == HTTPStatus.OK
+ assert response.json()["value"][0]["creator_name"] == "admin@nexent.com"
+
def test_list_marks_kb_unavailable_when_aidp_detail_fails(self):
client = _client()
from ext_components.aidp.apps import aidp_mgmt_app
@@ -630,7 +702,9 @@ def test_list_skips_detail_when_catalog_row_has_card_metadata(self):
"kds_name": "Catalog KB",
"description": "From catalog",
"created_at": "2026-01-01T00:00:00Z",
+ "update_time": 1767225600,
"caption_enable": 0,
+ "document_count": 8,
"permission": "EDIT",
}
with patch.object(
@@ -641,9 +715,44 @@ def test_list_skips_detail_when_catalog_row_has_card_metadata(self):
response = client.get("/aidp-mgmt/knowledge-bases", headers=_bearer())
assert response.status_code == HTTPStatus.OK
- assert response.json()["value"][0]["description"] == "From catalog"
+ item = response.json()["value"][0]
+ assert item["description"] == "From catalog"
+ assert item["updated_at"] == "2026-01-01T00:00:00Z"
+ assert item["document_count"] == 8
+ assert item["document_count_reliable"] is True
mock_detail.assert_not_called()
+ def test_list_fetches_detail_when_catalog_lacks_update_time_or_document_count(self):
+ client = _client()
+ from ext_components.aidp.apps import aidp_mgmt_app
+
+ incomplete_row = {
+ "kb_id": "kb-1",
+ "kds_id": "kb-1",
+ "kds_name": "Catalog KB",
+ "description": "From catalog",
+ "created_at": "2026-01-01T00:00:00Z",
+ "caption_enable": 0,
+ "permission": "EDIT",
+ }
+ with patch.object(
+ aidp_mgmt_app,
+ "_current_accessible_rows",
+ return_value=[incomplete_row],
+ ), patch.object(
+ aidp_mgmt_app,
+ "_load_cached_kb_detail",
+ return_value={"document_count": 8, "update_time": 1767225600},
+ ) as mock_detail:
+ response = client.get("/aidp-mgmt/knowledge-bases", headers=_bearer())
+
+ assert response.status_code == HTTPStatus.OK
+ item = response.json()["value"][0]
+ assert item["updated_at"] == "2026-01-01T00:00:00Z"
+ assert item["document_count"] == 8
+ assert item["document_count_reliable"] is True
+ mock_detail.assert_called_once()
+
# Use a lazy import for AppException at module load to avoid breaking the
# fastapi exception handler fixture.
@@ -2242,3 +2351,116 @@ def test_unexpected_history_error_falls_back_to_completed_listing(self):
assert response.status_code == HTTPStatus.OK
assert response.json()["value"] == [{"file_name": "done.txt"}]
+
+
+class TestAIDPServerSideKnowledgeFileQueries:
+ """File and task page queries are delegated to AIDP without local scans."""
+
+ @staticmethod
+ def _read_only():
+ return MagicMock(permission="READ_ONLY")
+
+ def test_ingested_file_search_forwards_page_and_keyword_once(self):
+ client = _client()
+ from ext_components.aidp.apps import aidp_mgmt_app
+ from ext_components.aidp.services import aidp_permission_service
+
+ page = {
+ "value": [{"file_uuid": "f-1", "file_name": "制度手册.pdf"}],
+ "total_count": 1,
+ "next_link": "/next-page",
+ }
+ with patch.object(aidp_permission_service, "require_permission",
+ return_value=self._read_only()), \
+ patch.object(aidp_mgmt_app, "list_aidp_docs_impl",
+ return_value=page) as list_docs, \
+ patch.object(aidp_mgmt_app, "_load_ingested_documents",
+ side_effect=AssertionError("must not scan every page")):
+ response = client.get(
+ "/aidp-mgmt/knowledge-bases/kb-1/files?page=2&page_size=5&keyword=%E5%88%B6%E5%BA%A6",
+ headers=_bearer(),
+ )
+
+ assert response.status_code == HTTPStatus.OK
+ body = response.json()
+ assert body["value"] == page["value"]
+ assert body["has_more"] is True
+ assert body["total_reliable"] is False
+ list_docs.assert_called_once_with(
+ SERVER_URL, API_KEY, "kb-1", 2, 5, "制度"
+ )
+
+ def test_upload_task_query_forwards_filters_and_uses_aidp_stats(self):
+ client = _client()
+ from ext_components.aidp.apps import aidp_mgmt_app
+ from ext_components.aidp.services import aidp_permission_service
+
+ history = {
+ "value": [{
+ "file_uuid": "f-2", "file_name": "制度手册.pdf", "status": 4,
+ # The upstream API owns the 30-day retention window; the proxy
+ # must not apply a second local timestamp filter.
+ "created_at": "2000-01-01T00:00:00Z",
+ }],
+ "total_count": 12,
+ "next_link": "/next-page",
+ "total_record_count": 31,
+ "processing_record_count": 3,
+ "failed_record_count": 2,
+ "success_record_count": 24,
+ "queued_record_count": 2,
+ }
+ with patch.object(aidp_permission_service, "require_permission",
+ return_value=self._read_only()), \
+ patch.object(aidp_mgmt_app, "_resolve_doc_history_channel",
+ return_value={"fs_id": "fs-1", "src_dir": "/aidp/knowledge/kb-1"}), \
+ patch.object(aidp_mgmt_app, "list_aidp_doc_history_impl",
+ return_value=history) as list_history:
+ response = client.get(
+ "/aidp-mgmt/knowledge-bases/kb-1/upload-tasks?page=2&page_size=7&keyword=%E5%88%B6%E5%BA%A6&status=4",
+ headers=_bearer(),
+ )
+
+ assert response.status_code == HTTPStatus.OK
+ body = response.json()
+ assert body["value"] == history["value"]
+ assert body["total_count"] == 12
+ assert body["has_more"] is True
+ assert body["stats"] == {
+ "total": 31,
+ "extracting": 3,
+ "failed": 2,
+ "success": 24,
+ "queued": 2,
+ }
+ assert body["retention_days"] == 30
+ list_history.assert_called_once_with(
+ SERVER_URL, API_KEY, "fs-1", "/kb-1", "kb-1", None,
+ 2, 7, 4, "制度",
+ )
+
+ def test_retry_delegates_selected_file_validation_to_aidp(self):
+ client = _client()
+ from ext_components.aidp.apps import aidp_mgmt_app
+ from ext_components.aidp.services import aidp_permission_service
+
+ file_uuid = "00000000-0000-4000-8000-000000000003"
+ upstream_result = {
+ "success_list": [{"file_uuid": file_uuid}],
+ "failed_list": [],
+ }
+ with patch.object(aidp_permission_service, "require_permission",
+ return_value=MagicMock(permission="EDIT")), \
+ patch.object(aidp_mgmt_app, "list_aidp_doc_history_impl",
+ side_effect=AssertionError("retry must not scan history")), \
+ patch.object(aidp_mgmt_app, "retry_aidp_docs_impl",
+ return_value=upstream_result) as retry_docs:
+ response = client.post(
+ "/aidp-mgmt/knowledge-bases/kb-1/upload-tasks/retry",
+ headers=_bearer(),
+ json={"file_uuids": [file_uuid]},
+ )
+
+ assert response.status_code == HTTPStatus.OK
+ assert response.json() == upstream_result
+ retry_docs.assert_called_once_with(SERVER_URL, API_KEY, "kb-1", [file_uuid])
diff --git a/test/ext_components/aidp/test_aidp_mgmt_mock_server.py b/test/ext_components/aidp/test_aidp_mgmt_mock_server.py
new file mode 100644
index 0000000000..c085828d6e
--- /dev/null
+++ b/test/ext_components/aidp/test_aidp_mgmt_mock_server.py
@@ -0,0 +1,156 @@
+"""Tests for filtering and pagination in the standalone AIDP mock server."""
+from __future__ import annotations
+
+import importlib.util
+import sys
+from pathlib import Path
+from unittest.mock import AsyncMock
+
+import pytest
+from fastapi.testclient import TestClient
+
+
+@pytest.fixture
+def mock_aidp_server(tmp_path, monkeypatch):
+ """Load the mock with isolated persisted state so tests never touch user data."""
+ mock_path = Path(__file__).parent / "mock_servers" / "aidp_mgmt_mock_server.py"
+ monkeypatch.setenv("AIDP_MOCK_STATE_FILE", str(tmp_path / "mock-state.json"))
+ module_name = "isolated_aidp_mgmt_mock_server_for_tests"
+ spec = importlib.util.spec_from_file_location(module_name, mock_path)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[module_name] = module
+ spec.loader.exec_module(module)
+ try:
+ yield module
+ finally:
+ sys.modules.pop(module_name, None)
+
+
+def test_file_listing_filters_before_pagination(mock_aidp_server):
+ server = mock_aidp_server
+ server._KNOWLEDGE_BASES["kb-1"] = {"kds_name": "测试知识库"}
+ server._DOCUMENTS_BY_KB["kb-1"] = [
+ {"file_uuid": "f-1", "file_name": "制度-第一章.pdf", "status": 1},
+ {"file_uuid": "f-2", "file_name": "制度-第二章.pdf", "status": 1},
+ {"file_uuid": "f-3", "file_name": "普通说明.txt", "status": 1},
+ ]
+ client = TestClient(server.app)
+
+ response = client.get(
+ "/KnowledgeBase/Tenants/aidp/KnowledgeBases/kb-1/KnowledgeFiles",
+ params={"page": 1, "page_size": 1, "keyword": "制度"},
+ headers={"Authorization": "Bearer mock-aidp-key"},
+ )
+
+ assert response.status_code == 200
+ body = response.json()
+ assert [item["file_uuid"] for item in body["value"]] == ["f-1"]
+ assert body["next_link"] is not None
+ assert "keyword=%E5%88%B6%E5%BA%A6" in body["next_link"]
+
+
+def test_upload_delay_is_applied_once_per_batch(mock_aidp_server, monkeypatch):
+ server = mock_aidp_server
+ server._KNOWLEDGE_BASES["kb-1"] = {"kds_name": "测试知识库"}
+ sleep = AsyncMock()
+ monkeypatch.setattr(server.asyncio, "sleep", sleep)
+ client = TestClient(server.app)
+
+ setting = client.post("/_mock/upload-seconds", params={"seconds": 10})
+ assert setting.status_code == 200
+ assert setting.json() == {"upload_seconds": 10}
+ assert client.get("/health").json()["upload_seconds"] == 10
+
+ response = client.post(
+ "/KnowledgeBase/Tenants/aidp/KnowledgeBases/kb-1/KnowledgeFiles/Upload",
+ headers={"Authorization": "Bearer mock-aidp-key"},
+ files=[
+ ("files", ("first.txt", b"first", "text/plain")),
+ ("files", ("second.txt", b"second", "text/plain")),
+ ],
+ )
+ assert response.status_code == 200
+ assert response.json()["summary"] == {"total": 2, "success": 2, "failed": 0}
+ sleep.assert_awaited_once_with(10)
+
+ assert client.post("/_mock/upload-seconds", params={"seconds": 0}).status_code == 200
+ sleep.reset_mock()
+ response = client.post(
+ "/KnowledgeBase/Tenants/aidp/KnowledgeBases/kb-1/KnowledgeFiles/Upload",
+ headers={"Authorization": "Bearer mock-aidp-key"},
+ files={"files": ("instant.txt", b"instant", "text/plain")},
+ )
+ assert response.status_code == 200
+ sleep.assert_not_awaited()
+
+
+@pytest.mark.parametrize("seconds", [-1, 601])
+def test_upload_delay_rejects_out_of_range_values(mock_aidp_server, seconds):
+ client = TestClient(mock_aidp_server.app)
+ assert client.post("/_mock/upload-seconds", params={"seconds": seconds}).status_code == 422
+
+
+def test_history_filters_to_recent_records_and_returns_upstream_summary(mock_aidp_server):
+ server = mock_aidp_server
+ server._KNOWLEDGE_BASES["kb-1"] = {"kds_name": "测试知识库"}
+ now = int(server.time.time())
+ server._DOCUMENTS_BY_KB["kb-1"] = [
+ {
+ "file_uuid": "f-success", "file_name": "制度-成功.pdf", "status": 1,
+ "first_upload_time": now - 3600,
+ },
+ {
+ "file_uuid": "f-failed", "file_name": "制度-失败.docx", "status": 3,
+ "first_upload_time": now - 1800,
+ },
+ {
+ "file_uuid": "f-queued", "file_name": "排队任务.pdf", "status": 4,
+ "first_upload_time": now - 900,
+ },
+ {
+ "file_uuid": "f-old", "file_name": "旧失败任务.pdf", "status": 5,
+ "first_upload_time": now - 31 * 24 * 60 * 60,
+ },
+ ]
+ client = TestClient(server.app)
+
+ response = client.post(
+ "/KnowledgeBase/Tenants/aidp/KnowledgeBases/kb-1/KnowledgeFiles/History",
+ headers={"Authorization": "Bearer mock-aidp-key"},
+ json={
+ "fs_id": "fs-1", "dir_path": "/kb-1", "page": 1,
+ "page_size": 10, "keyword": "失败", "status": 3,
+ },
+ )
+
+ assert response.status_code == 200
+ body = response.json()
+ assert [item["file_uuid"] for item in body["value"]] == ["f-failed"]
+ assert body["total_count"] == 1
+ assert body["total_record_count"] == 3
+ assert body["success_record_count"] == 1
+ assert body["processing_record_count"] == 0
+ assert body["failed_record_count"] == 1
+ assert body["queued_record_count"] == 1
+
+ first_page = client.post(
+ "/KnowledgeBase/Tenants/aidp/KnowledgeBases/kb-1/KnowledgeFiles/History",
+ headers={"Authorization": "Bearer mock-aidp-key"},
+ json={
+ "fs_id": "fs-1", "dir_path": "/kb-1", "page": 1,
+ "page_size": 1, "keyword": "制度", "status": 0,
+ },
+ ).json()
+ second_page = client.post(
+ "/KnowledgeBase/Tenants/aidp/KnowledgeBases/kb-1/KnowledgeFiles/History",
+ headers={"Authorization": "Bearer mock-aidp-key"},
+ json={
+ "fs_id": "fs-1", "dir_path": "/kb-1", "page": 2,
+ "page_size": 1, "keyword": "制度", "status": 0,
+ },
+ ).json()
+ assert [item["file_uuid"] for item in first_page["value"]] == ["f-success"]
+ assert first_page["next_link"] is not None
+ assert [item["file_uuid"] for item in second_page["value"]] == ["f-failed"]
+ assert second_page["next_link"] is None
diff --git a/test/ext_components/aidp/test_aidp_service.py b/test/ext_components/aidp/test_aidp_service.py
index 6db6fb2f93..d091b0b9bb 100644
--- a/test/ext_components/aidp/test_aidp_service.py
+++ b/test/ext_components/aidp/test_aidp_service.py
@@ -1,4 +1,5 @@
import importlib.util
+import json
import logging
import os
import sys
@@ -627,7 +628,7 @@ def test_fills_all_defaults_when_payload_is_minimal(self, aidp_mod):
assert result["is_personal"] == 0
assert result["topk"] == 10
assert result["similarity"] == 0.0
- assert result["smartsplit"] == 1
+ assert "smartsplit" not in result
assert result["caption_enable"] == 0
def test_preserves_client_supplied_values(self, aidp_mod):
@@ -679,6 +680,36 @@ def test_false_value_preserved_not_replaced_by_default(self, aidp_mod):
assert result["chunk_token_num"] == 0
assert result["vlm_model"] == "my-vlm"
+ def test_graph_llm_model_is_serialized_inside_aidp_graph_config(self, aidp_mod):
+ result = aidp_mod._apply_create_defaults(
+ {
+ "name": "kb-graph",
+ "is_exist_graph": True,
+ "graph_config": {
+ "domain": "general",
+ "llm_model_name": "model-graph",
+ "prompt_text": "Extract entity relationships.",
+ },
+ }
+ )
+
+ graph_config = json.loads(result["graph_config"])
+ assert graph_config["llm_model_name"] == "model-graph"
+
+ def test_legacy_top_level_graph_llm_model_is_moved_into_graph_config(self, aidp_mod):
+ result = aidp_mod._apply_create_defaults(
+ {
+ "name": "kb-graph",
+ "is_exist_graph": True,
+ "graph_config": {"domain": "general", "prompt_text": "Extract entity relationships."},
+ "llm_model_name": "model-graph",
+ }
+ )
+
+ graph_config = json.loads(result["graph_config"])
+ assert graph_config["llm_model_name"] == "model-graph"
+ assert "llm_model_name" not in result
+
# ---------------------------------------------------------------------------
# _request_with_retry tests
@@ -2187,6 +2218,25 @@ def test_success_normalizes_docs(self, aidp_service_module):
assert result["value"][0]["file_uuid"] == "uuid-1"
assert result["value"][1]["updated_at"] is not None
+ def test_keyword_is_forwarded_to_aidp_file_listing(self, aidp_service_module):
+ mock_resp = _make_success_response({"value": [], "next_link": None})
+ mock_client = _setup_mock_client(
+ aidp_service_module, method="get", response=mock_resp
+ )
+
+ aidp_service_module.list_aidp_docs_impl(
+ server_url="http://127.0.0.1:30081",
+ api_key="jwt-token",
+ kds_id="kb-1",
+ page=2,
+ page_size=5,
+ keyword="制度手册",
+ )
+
+ request_url = mock_client.get.call_args.args[0]
+ assert "page=2&page_size=5" in request_url
+ assert "keyword=%E5%88%B6%E5%BA%A6%E6%89%8B%E5%86%8C" in request_url
+
def test_success_non_list_value_not_normalized(self, aidp_service_module):
mock_resp = _make_success_response({"value": "not-a-list", "total_count": 0})
_setup_mock_client(aidp_service_module, method="get", response=mock_resp)
@@ -2451,7 +2501,7 @@ def test_missing_models_key(self, aidp_service_module):
assert result["total_count"] == 0
def test_non_dict_response_raises(self, aidp_service_module):
- mock_resp = _make_success_response(["not-dict"])
+ mock_resp = _make_success_response("not-an-object-or-array")
_setup_mock_client(aidp_service_module, method="get", response=mock_resp)
with pytest.raises(AppException) as exc_info:
@@ -2996,6 +3046,22 @@ def test_non_string_status_values_are_ignored(self, aidp_service_module):
assert "status" not in result["value"][0]
+ def test_numeric_canonical_status_is_preserved_as_a_code(self, aidp_service_module):
+ mock_resp = _make_success_response({
+ "value": [{"file_ino_no": "f-1", "status": 3}],
+ })
+ _setup_mock_client(aidp_service_module, method="post", response=mock_resp)
+
+ result = aidp_service_module.list_aidp_doc_history_impl(
+ server_url="http://127.0.0.1:30081",
+ api_key="jwt-token",
+ fs_id="fs-1",
+ dir_path="/1",
+ kds_id=self._KB,
+ )
+
+ assert result["value"][0]["status"] == "3"
+
def test_unreadable_status_payload_is_reported(
self, aidp_service_module, caplog
):
@@ -3051,6 +3117,59 @@ def test_payload_without_any_list_raises(self, aidp_service_module, payload):
)
assert exc_info.value.error_code == ErrorCode.AIDP_RESPONSE_ERROR
+ def test_status_filter_keeps_zero_and_page_size(self, aidp_service_module):
+ mock_resp = _make_success_response({"value": []})
+ mock_client = _setup_mock_client(
+ aidp_service_module, method="post", response=mock_resp
+ )
+ aidp_service_module.list_aidp_doc_history_impl(
+ server_url="http://127.0.0.1:30081",
+ api_key="jwt-token",
+ fs_id="fs-1",
+ dir_path="/1",
+ kds_id=self._KB,
+ page=2,
+ page_size=50,
+ status=0,
+ keyword="制度手册",
+ )
+ assert mock_client.post.call_args.kwargs["json"] == {
+ "fs_id": "fs-1",
+ "dir_path": "/1",
+ "page": 2,
+ "page_size": 50,
+ "status": 0,
+ "keyword": "制度手册",
+ }
+
+
+ def test_retry_posts_uuid_array_and_preserves_response(self, aidp_service_module):
+ expected = {
+ "summary": {"total": 2, "success": 2, "failed": 0},
+ "success_list": [{"file_uuid": "f-1"}, {"file_uuid": "f-2"}],
+ "failed_list": [],
+ }
+ mock_resp = _make_success_response(expected)
+ mock_client = _setup_mock_client(
+ aidp_service_module, method="post", response=mock_resp
+ )
+
+ result = aidp_service_module.retry_aidp_docs_impl(
+ server_url="http://127.0.0.1:30081",
+ api_key="jwt-token",
+ kds_id="kb-1",
+ file_uuids=["f-1", "f-2"],
+ )
+
+ assert mock_client.post.call_args[0][0] == (
+ "http://127.0.0.1:30081/KnowledgeBase/Tenants/aidp"
+ "/KnowledgeBases/kb-1/KnowledgeFiles/Retry"
+ )
+ assert mock_client.post.call_args.kwargs["json"] == {
+ "file_uuids": ["f-1", "f-2"],
+ }
+ assert result == expected
+
def test_response_without_list_carries_empty_value(self, aidp_service_module):
"""An empty directory is a valid answer, not a payload error."""
mock_resp = _make_success_response({"value": []})
diff --git a/test/features/aidp-knowledge-detail.yaml b/test/features/aidp-knowledge-detail.yaml
new file mode 100644
index 0000000000..09f6f39c42
--- /dev/null
+++ b/test/features/aidp-knowledge-detail.yaml
@@ -0,0 +1,33 @@
+schema_version: '1.0'
+module:
+ id: AIDP-KNOWLEDGE
+ name: AIDP knowledge base
+ description: AIDP knowledge base overview, creation and detail pages inside the Nexent frontend.
+features:
+ - feature_id: AKDT-001
+ name: AIDP knowledge base detail page
+ status: active
+ description: Open an AIDP knowledge base from its name or card and manage its ingested files, upload task history, configuration and group permissions on a full-page detail view. Files and upload tasks are queried live from AIDP and are not stored in Nexent PostgreSQL.
+ business_rules:
+ - rule_id: AKDT-RULE-001
+ description: The detail header shows the knowledge base name, description, ingested file count, effective permission, creation time and creator; name/description editing and deletion remain available only to users with edit permission.
+ - rule_id: AKDT-RULE-002
+ description: The three detail tabs are Files, Upload tasks and Details; the file tab lists only successfully ingested AIDP files and the task tab lists AIDP History records retained for 30 days.
+ - rule_id: AKDT-RULE-003
+ description: File and task searches, pagination and statistics use their documented AIDP fields; unsupported column filters or sorting are not displayed, and no file or task history is persisted in Nexent PostgreSQL.
+ - rule_id: AKDT-RULE-004
+ description: Upload task status filters send numeric values 0 through 5 for All, Success, Extracting, Vector ingestion failed, Queued and Graph ingestion failed; response statuses are parsed separately, preserve the raw value and show unknown values without inventing a state.
+ - rule_id: AKDT-RULE-005
+ description: Failed task rows can retry one explicitly selected file; the backend accepts a non-empty array of one or more file UUIDs, checks edit permission and forwards only file_uuids to the documented AIDP Retry example.
+ - rule_id: AKDT-RULE-006
+ description: The failure drawer shows only returned task values and displays an em dash when an error code or update time is absent; retry remains in the failed row action.
+ - rule_id: AKDT-RULE-007
+ description: The Details tab shows configuration, permissions and graph settings; group permissions use the existing Nexent permission store, while AIDP detail/configuration values are read from AIDP and only supported settings can be edited.
+ - rule_id: AKDT-RULE-008
+ description: The page keeps table width, fixed file-name and action columns, resizable other columns, right-side pagination and stable table height without horizontal page overflow.
+ - rule_id: AKDT-RULE-009
+ description: The header edit opens name, description and directly visible permission/group controls; the separate permission-section edit changes only group permission and accessible groups. The supported vector Top K edit uses the same slider-and-value control as creation, while unsupported settings remain read-only.
+ required_test_stages: [D1, D2, D3, D4, D5]
+ source:
+ requirement_ids: [AKDT-001]
+ spec_path: uploads/knowledge-base-detail-design/proposal.md
diff --git a/test/features/aidp-knowledge-pages-refactor.yaml b/test/features/aidp-knowledge-pages-refactor.yaml
new file mode 100644
index 0000000000..6960b97b88
--- /dev/null
+++ b/test/features/aidp-knowledge-pages-refactor.yaml
@@ -0,0 +1,49 @@
+schema_version: '1.0'
+module:
+ id: AIDP-KNOWLEDGE
+ name: AIDP knowledge base
+ description: AIDP knowledge base overview, file view and single-page creation form inside the Nexent frontend, including the AIDP-only branch selected by the deployment flag.
+features:
+ - feature_id: AKPR-001
+ name: AIDP knowledge base list and creation page refactor
+ status: active
+ description: AIDP knowledge bases are listed in a card or table overview with an independent name search, a collapsible three-step guide and a full-page file view per knowledge base; creation uses one dedicated full-page form for permissions, chunking, retrieval, graph and model configuration, submits directly, and leaves file upload to the detail page. The ES knowledge base path stays untouched.
+ business_rules:
+ - rule_id: AKPR-RULE-001
+ description: The overview renders a knowledge base guide with the fixed steps "create knowledge base, upload files, configure the knowledge base in an agent"; the guide collapses and reopens without losing the rest of the page state.
+ - rule_id: AKPR-RULE-002
+ description: The overview defaults to the card view and can switch to a table; both views project the same permission-scoped, server-paginated result set and switching never calls a mutating endpoint.
+ - rule_id: AKPR-RULE-003
+ description: The independent name search reuses the existing keyword query, resets to the first page on change and restores the unfiltered set when cleared; no column-level search or filter exists in this version.
+ - rule_id: AKPR-RULE-004
+ description: The table exposes a column visibility setting that hides and restores optional columns, keeps the name and action columns available, and never mutates backend state.
+ - rule_id: AKPR-RULE-005
+ description: Selecting a knowledge base opens a full-page file view for that base only; returning restores the previous search, page, view mode and column setting, and a delayed response from a previously opened base never overwrites the current one.
+ - rule_id: AKPR-RULE-006
+ description: Displayed metadata uses real response values, except the creator name resolves the Nexent permission owner in the current tenant; unknown or missing owners render as an em dash. The personal/enterprise type comes from AIDP rather than Nexent group permission. Update time, file count and capacity remain unchanged in this integration fix.
+ - rule_id: AKPR-RULE-007
+ description: Existing EDIT and READ_ONLY permissions, unavailable resource states and the hidden modification entry points for them are preserved in both overview and file view.
+ - rule_id: AKPR-RULE-008
+ description: The AIDP page selection by the deployment flag stays isolated; ES deployments keep rendering the original ES knowledge base components and the new AIDP guide, overview and file view never enter that render path.
+ - rule_id: AKPR-RULE-009
+ description: File upload, processing-status polling, download and deletion inside the AIDP file view keep their permission gates, expose partial-failure feedback, and clean up their polling state when the view is left.
+ - rule_id: AKPR-RULE-010
+ description: Creation uses one dedicated full-page form for name, description, permission, user groups and configuration; the create action submits the knowledge base directly without a step indicator or optional upload stage, and file upload remains available from the detail page.
+ - rule_id: AKPR-RULE-011
+ description: Chunking submits chunk_mode 0 for smart splitting and 1 for legal clauses, converts the overlap percentage to floor(tokens * percent / 100) integer tokens bounded by 50 percent of the chunk size, and keeps the existing default chunking values.
+ - rule_id: AKPR-RULE-012
+ description: One KnowledgeBase ModelService query populates LLM, VLM and embedding controls by model_type; display_name is displayed and model_name is submitted. Graph defaults, choices, constraints and domain prompts come from GraphConfigTemplate, queried by language. Domain or language changes update an unchanged default prompt and preserve manually edited text, with an explicit restore action. Configuration errors block graph-enabled creation. Disabling a capability omits its hidden configuration.
+ - rule_id: AKPR-RULE-013
+ description: Graph configuration is a JSON string containing the seven live template keys plus llm_model_name; hop count is a string, switches are Chinese 是 or 否, language and domain use Chinese values, and prompt length is 1 to 4096 characters. Thinking maps inversely onto no_think_mode. No graph Top K is displayed or submitted; the outer knowledge-base topk remains supported. Tooltips use the recorded AIDP screenshots without renaming existing fields.
+ - rule_id: AKPR-RULE-015
+ description: The overview guide uses a rounded pale-blue gradient introduction area, a configuration-flow heading and three 156px-high step cards separated by 16px gaps; the knowledge-base cards keep a fixed Nexent icon, a full-width two-line description, and update time, document count and capacity on one aligned footer row, with stable card and right-aligned pagination geometry. Fonts and icons retain Nexent conventions.
+ - rule_id: AKPR-RULE-016
+ description: The full-page creation form follows the designed section order and spacing; page and section titles use 18px text; graph and VLM switches sit beside their labels, same-row fields align, and the chunking and retrieval controls retain slider layout and consistent value widths.
+ - rule_id: AKPR-RULE-017
+ description: The current breadcrumb title on the create and detail pages and the detail information section headings use 18px text while ancestor breadcrumb links remain visually secondary.
+ - rule_id: AKPR-RULE-018
+ description: Selecting or dropping files immediately sends one multipart batch through Nexent to AIDP. Each uploading row displays the batch transfer percentage on its icon and only its total file size below its name. Keep dashed borders until per-file acceptance; success is solid near-black. Only failed rows expose a local delete action. Confirm and all close controls stay disabled until requests return success or failure; Confirm only dismisses results. Preserve the 50-file cap and independent scrolling.
+ required_test_stages: [D1, D2, D3, D4, D5]
+ source:
+ requirement_ids: [AKPR-001]
+ spec_path: nexent-doc/nexent/knowledge-base-pages-refactor/proposal.md
diff --git "a/test/generated/Nexent_\346\265\213\350\257\225\345\237\272\347\272\277.xlsx" "b/test/generated/Nexent_\346\265\213\350\257\225\345\237\272\347\272\277.xlsx"
index 7e356c6091..8fea6f9c24 100644
Binary files "a/test/generated/Nexent_\346\265\213\350\257\225\345\237\272\347\272\277.xlsx" and "b/test/generated/Nexent_\346\265\213\350\257\225\345\237\272\347\272\277.xlsx" differ
diff --git a/test/manifests/aidp-create-live-contract.yaml b/test/manifests/aidp-create-live-contract.yaml
new file mode 100644
index 0000000000..a3b89be88a
--- /dev/null
+++ b/test/manifests/aidp-create-live-contract.yaml
@@ -0,0 +1,20 @@
+schema_version: '1.0'
+cases:
+- case_id: AKPR-D1-011
+ stage: D1
+ status: implemented
+ contract_hash: sha256:f2872957a3d2f7e49abdaf84e15a0bb4b0ac6bc46b8d5116ac7c58ced7988633
+ implementation_hash: sha256:0074537ecfe51f517f3d749371770d0d8b5e1f7a3b96bef2a13e16f63fc18cbd
+ implementation:
+ - framework: vitest
+ file: test/automation/d1/AidpCreateKbPage.test.tsx
+ selector: AKPR-D1-011
+ profiles:
+ - mock
+ - framework: vitest
+ file: test/automation/d1/AidpCreateKbSections.test.tsx
+ selector: AKPR-D1-011
+ profiles:
+ - mock
+ notes: Run from frontend with vitest.aidp.config.ts. Mock component coverage; live
+ AIDP verification is still required.
diff --git a/test/manifests/aidp-import-drawer.yaml b/test/manifests/aidp-import-drawer.yaml
new file mode 100644
index 0000000000..1b8ce309ef
--- /dev/null
+++ b/test/manifests/aidp-import-drawer.yaml
@@ -0,0 +1,20 @@
+schema_version: '1.0'
+cases:
+- case_id: AKPR-D1-015
+ stage: D1
+ status: implemented
+ contract_hash: sha256:6c4a5f0c2060b7e3780c73a01331faeae9528c1f10de5bb60e37c8fcdfb54355
+ implementation_hash: sha256:2bd0dcafdd0bd3669182569189845378bce21ae3c9ce22083b83b9fdca5ad631
+ implementation:
+ - framework: vitest
+ file: test/automation/d1/AidpImportDrawer.test.tsx
+ selector: AKPR-D1-015
+ profiles:
+ - mock
+ - framework: vitest
+ file: test/automation/d1/AidpUploadService.test.tsx
+ selector: AKPR-D1-015
+ profiles:
+ - mock
+ notes: 'Run from frontend: npx vitest run --config vitest.aidp.config.ts. This proves
+ mocked component and browser transport behavior, not live AIDP acceptance.'