fix(knowledge): simplify query activity feed

This commit is contained in:
Jyong 2026-08-19 10:22:39 -04:00
parent 0391141936
commit 0aa7a2ac84
13 changed files with 185 additions and 134 deletions

View File

@ -1,6 +1,6 @@
{
"schemaVersion": 5,
"subtreeTree": "05e402ad162663945d482ebfa7c5221e73347364",
"subtreeTree": "b480632eddd1d3946a18d45472c0856bee471b58",
"openapiSha256": "2cf348c68bbe65dd51bbde9a0a4f91398beeebd79e89e9288c9386b26ae09796",
"capabilityV2AuthManifestSha256": "fc0a47e23cce12544882f0298522b4933002e892b84ce1815df7e81d36a7a0c7",
"capabilityV2AuthTestVectorSha256": "ae0de37b1ff05c40f905cf17a7b410d8971acacf64db07d5ee3d6fecfa559ce3",

View File

@ -0,0 +1,28 @@
# Query Activity Consolidation
## Summary
- Records one `query.requested` activity when a user starts a query, including the resolved
retrieval mode and a bounded copy of the question.
- Stops appending `query.completed`, `query.failed`, and `profile.published` activities.
- Excludes historical terminal-query and profile-publication rows from activity-feed reads while
retaining their storage decoding compatibility.
- Shows the question and retrieval mode directly in recent activity and the complete activity
drawer.
## Safety and Compatibility
- Query questions are limited to 4,000 characters in activity details; longer values are marked
as truncated.
- Credentials, tokens, object keys, and the legacy arbitrary `query` detail key remain excluded by
the activity-detail allow-list.
- Overview answer-rate and outcome calculations continue to use durable AnswerTrace and
FailedQuery facts rather than the removed terminal activity writes.
## Verification
- `pnpm --filter @knowledge/api test`
- `pnpm --filter @knowledge/api typecheck`
- `pnpm exec vitest run features/new-rag/__tests__/knowledge-overview-page.spec.tsx` (from `web/`)
- `pnpm type-check` (from `web/`)
- `git diff --check`

View File

@ -123,16 +123,12 @@ describe.each(["postgres", "tidb"] as const)(
JSON.stringify(["team:camera"]),
]);
const sql = select?.sql ?? "";
expect(sql).toContain("UNION ALL");
expect(sql).toContain("answer_traces");
expect(sql).not.toContain("UNION ALL");
expect(sql).not.toContain("answer_traces");
expect(sql).toContain("NOT IN");
expect(sql).toContain("query.completed");
expect(sql).toContain("query.failed");
expect(sql).toMatch(
/stored_request\.[`"]actor_subject_id[`"] = stored_trace\.[`"]subject_id[`"]/u,
);
expect(sql).toMatch(
/stored_trace\.[`"]created_at[`"] >= stored_request\.[`"]occurred_at[`"]/u,
);
expect(sql).toContain("profile.published");
expect(sql.indexOf("tenant_id")).toBeLessThan(sql.indexOf("ORDER BY"));
expect(sql.indexOf("knowledge_space_id")).toBeLessThan(sql.indexOf("ORDER BY"));
expect(sql).toContain(dialect === "postgres" ? "::jsonb @>" : "JSON_CONTAINS");

View File

@ -23,6 +23,7 @@ import {
import { lockKnowledgeSpaceForDeletionAdmission } from "./knowledge-space-deletion-admission";
import {
type AppendKnowledgeSpaceActivityInput,
HiddenKnowledgeSpaceActivityActions,
type KnowledgeSpaceActivityAction,
KnowledgeSpaceActivityActions,
type KnowledgeSpaceActivityEvent,
@ -1318,9 +1319,11 @@ function activityReadModelSql(database: DatabaseAdapter): string {
const selectColumns = (alias: string) =>
columns.map((column) => `${alias}.${q(database, column)}`).join(", ");
const activity = q(database, "knowledge_space_activity_events");
const traces = q(database, "answer_traces");
const hiddenActions = HiddenKnowledgeSpaceActivityActions.map((action) => `'${action}'`).join(
", ",
);
return `SELECT ${selectColumns("stored_event")} FROM ${activity} stored_event WHERE NOT (stored_event.${q(database, "action")} IN ('query.completed', 'query.failed') AND EXISTS (SELECT 1 FROM ${traces} stored_trace WHERE stored_trace.${q(database, "knowledge_space_id")} = stored_event.${q(database, "knowledge_space_id")} AND ${textIdSql(database, "stored_trace", "id")} = stored_event.${q(database, "resource_id")} AND EXISTS (SELECT 1 FROM ${activity} stored_request WHERE stored_request.${q(database, "tenant_id")} = stored_event.${q(database, "tenant_id")} AND stored_request.${q(database, "knowledge_space_id")} = stored_event.${q(database, "knowledge_space_id")} AND stored_request.${q(database, "resource_id")} = stored_event.${q(database, "resource_id")} AND stored_request.${q(database, "actor_subject_id")} = stored_trace.${q(database, "subject_id")} AND stored_request.${q(database, "action")} = 'query.requested' AND stored_trace.${q(database, "created_at")} >= stored_request.${q(database, "occurred_at")}))) UNION ALL SELECT terminal_trace.${q(database, "id")} AS ${q(database, "id")}, requested.${q(database, "tenant_id")}, requested.${q(database, "knowledge_space_id")}, requested.${q(database, "actor_type")}, requested.${q(database, "actor_subject_id")}, CASE WHEN terminal_trace.${q(database, "completed")} = TRUE THEN 'query.completed' ELSE 'query.failed' END AS ${q(database, "action")}, requested.${q(database, "resource_type")}, requested.${q(database, "resource_id")}, CASE WHEN terminal_trace.${q(database, "completed")} = TRUE THEN 'success' ELSE 'failure' END AS ${q(database, "result")}, requested.${q(database, "required_permission_scope")}, requested.${q(database, "details")}, terminal_trace.${q(database, "created_at")} AS ${q(database, "occurred_at")} FROM ${traces} terminal_trace INNER JOIN ${activity} requested ON requested.${q(database, "knowledge_space_id")} = terminal_trace.${q(database, "knowledge_space_id")} AND requested.${q(database, "resource_id")} = ${textIdSql(database, "terminal_trace", "id")} AND requested.${q(database, "actor_subject_id")} = terminal_trace.${q(database, "subject_id")} AND requested.${q(database, "action")} = 'query.requested' AND requested.${q(database, "resource_type")} = 'query' AND terminal_trace.${q(database, "created_at")} >= requested.${q(database, "occurred_at")}`;
return `SELECT ${selectColumns("stored_event")} FROM ${activity} stored_event WHERE stored_event.${q(database, "action")} NOT IN (${hiddenActions})`;
}
function textIdSql(database: DatabaseAdapter, alias: string, column: string): string {

View File

@ -5,6 +5,7 @@ import {
decodeKnowledgeSpaceActivityCursor,
deterministicKnowledgeSpaceActivityId,
encodeKnowledgeSpaceActivityCursor,
sanitizeKnowledgeSpaceActivityDetails,
} from "./knowledge-space-overview";
const TENANT_ID = "tenant-overview";
@ -23,7 +24,12 @@ describe("in-memory knowledge-space Overview repository", () => {
const input = {
action: "query.requested" as const,
actor: { id: "member-1", type: "member" as const },
details: { apiKey: "must-not-leak", mode: "fast", query: "must-not-leak" },
details: {
apiKey: "must-not-leak",
mode: "fast",
query: "legacy-query-key-must-not-leak",
question: "What is the permission model?",
},
id,
knowledgeSpaceId: SPACE_ID,
occurredAt: NOW,
@ -40,7 +46,7 @@ describe("in-memory knowledge-space Overview repository", () => {
});
expect(replay).toEqual(first);
expect(first.details).toEqual({ mode: "fast" });
expect(first.details).toEqual({ mode: "fast", question: "What is the permission model?" });
await expect(repository.appendActivity({ ...input, result: "failure" })).rejects.toThrow(
"idempotency key",
);
@ -49,6 +55,59 @@ describe("in-memory knowledge-space Overview repository", () => {
);
});
it("keeps legacy terminal and profile events out of the product activity feed", async () => {
const repository = createInMemoryKnowledgeSpaceOverviewRepository({
maxEvents: 3,
maxListLimit: 10,
});
const common = {
actor: { id: "member-1", type: "member" as const },
knowledgeSpaceId: SPACE_ID,
occurredAt: NOW,
requiredPermissionScope: ["team:camera"],
tenantId: TENANT_ID,
};
await repository.appendActivity({
...common,
action: "query.requested",
id: "00000000-0000-4000-8000-000000000021",
resource: { id: QUERY_ID, type: "query" },
result: "success",
});
await repository.appendActivity({
...common,
action: "query.completed",
id: "00000000-0000-4000-8000-000000000022",
resource: { id: QUERY_ID, type: "query" },
result: "success",
});
await repository.appendActivity({
...common,
action: "profile.published",
id: "00000000-0000-4000-8000-000000000023",
resource: { id: "profile-1", type: "profile" },
result: "success",
});
const activity = await repository.listActivity({
candidateGrants: ["team:camera"],
knowledgeSpaceId: SPACE_ID,
limit: 10,
tenantId: TENANT_ID,
});
expect(activity.items.map((event) => event.action)).toEqual(["query.requested"]);
});
it("bounds long questions without losing the truncation marker on database re-sanitization", () => {
const details = sanitizeKnowledgeSpaceActivityDetails({ question: "q".repeat(4_001) });
expect(details.question).toHaveLength(4_000);
expect(details.question).toMatch(/$/u);
expect(details.questionTruncated).toBe(true);
expect(sanitizeKnowledgeSpaceActivityDetails(details)).toEqual(details);
});
it("counts distinct requested query identities and only their later successful completion", async () => {
const repository = createInMemoryKnowledgeSpaceOverviewRepository({
maxEvents: 20,

View File

@ -19,6 +19,16 @@ export const KnowledgeSpaceActivityActions = [
] as const;
export type KnowledgeSpaceActivityAction = (typeof KnowledgeSpaceActivityActions)[number];
/** Retained for decoding historical rows, but excluded from the product activity feed. */
export const HiddenKnowledgeSpaceActivityActions = [
"query.completed",
"query.failed",
"profile.published",
] as const satisfies readonly KnowledgeSpaceActivityAction[];
const HiddenKnowledgeSpaceActivityActionSet: ReadonlySet<KnowledgeSpaceActivityAction> = new Set(
HiddenKnowledgeSpaceActivityActions,
);
export const KnowledgeSpaceActivityResourceTypes = [
"knowledge-space",
"query",
@ -41,7 +51,7 @@ export interface KnowledgeSpaceActivityEvent {
readonly id?: string | undefined;
readonly type: "member" | "system";
};
/** A deliberately small allow-list; query text, credentials, tokens and object keys are absent. */
/** A deliberately small allow-list; credentials, tokens and object keys are absent. */
readonly details: Readonly<Record<string, boolean | number | string>>;
readonly id: string;
readonly knowledgeSpaceId: string;
@ -399,6 +409,7 @@ export function createInMemoryKnowledgeSpaceOverviewRepository(options: {
event.knowledgeSpaceId === input.knowledgeSpaceId &&
candidatePermissionScopeAllows(event.requiredPermissionScope, input.candidateGrants),
)
.filter((event) => !HiddenKnowledgeSpaceActivityActionSet.has(event.action))
.filter((event) => !input.action || event.action === input.action)
.filter((event) => !input.actorType || event.actor.type === input.actorType)
.filter((event) => !input.actorId || event.actor.id === input.actorId)
@ -489,6 +500,8 @@ const SAFE_ACTIVITY_DETAIL_KEYS = new Set([
"durationMs",
"mode",
"providerId",
"question",
"questionTruncated",
"reasonCode",
"statusCode",
]);
@ -502,7 +515,13 @@ export function sanitizeKnowledgeSpaceActivityDetails(
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
continue;
}
if (typeof value === "string" && value.length > 160) continue;
if (typeof value === "string" && key === "question" && value.length > 4_000) {
safe.question = `${value.slice(0, 3_999)}`;
safe.questionTruncated = true;
continue;
}
if (typeof value === "string" && value.length > 160 && key !== "question") continue;
if (key === "questionTruncated" && value !== true) continue;
if (typeof value === "number" && !Number.isFinite(value)) continue;
safe[key] = value;
}

View File

@ -324,7 +324,7 @@ describe("knowledge-space profile repository", () => {
(call) =>
call.tableName === "knowledge_space_activity_events" && call.operation === "insert",
),
).toBe(true);
).toBe(false);
},
);

View File

@ -34,8 +34,6 @@ import {
assertDatabaseKnowledgeSpacePermissionFence,
} from "./knowledge-space-access-control";
import { lockKnowledgeSpaceForDeletionAdmission } from "./knowledge-space-deletion-admission";
import { deterministicKnowledgeSpaceActivityId } from "./knowledge-space-overview";
import { appendKnowledgeSpaceActivityWithExecutor } from "./knowledge-space-overview-database-repository";
export const KnowledgeSpaceProfileKinds = ["embedding", "retrieval"] as const;
export type KnowledgeSpaceProfileKind = (typeof KnowledgeSpaceProfileKinds)[number];
@ -492,28 +490,6 @@ export function createDatabaseKnowledgeSpaceProfileRepository({
if (!head) {
throw new Error("Activated knowledge-space profile head could not be reloaded");
}
await appendKnowledgeSpaceActivityWithExecutor({
database,
executor: transaction,
input: {
action: "profile.published",
actor: { id: candidate.createdBySubjectId, type: "member" },
details: { providerId: candidate.provider },
id: deterministicKnowledgeSpaceActivityId(
"profile.published",
input.tenantId,
input.knowledgeSpaceId,
input.kind,
candidate.id,
),
knowledgeSpaceId: input.knowledgeSpaceId,
occurredAt: input.now,
requiredPermissionScope: [],
resource: { id: candidate.id, type: "profile" },
result: "success",
tenantId: input.tenantId,
},
});
return head;
});
},
@ -1588,28 +1564,6 @@ async function activateUnpublishedProfileRevision({
const head = await getProfileHead(database, transaction, input, false);
if (!head) throw new Error("Activated unpublished profile head could not be reloaded");
await appendKnowledgeSpaceActivityWithExecutor({
database,
executor: transaction,
input: {
action: "profile.published",
actor: { id: candidate.createdBySubjectId, type: "member" },
details: { providerId: candidate.provider },
id: deterministicKnowledgeSpaceActivityId(
"profile.published",
input.tenantId,
input.knowledgeSpaceId,
input.kind,
candidate.id,
),
knowledgeSpaceId: input.knowledgeSpaceId,
occurredAt: input.now,
requiredPermissionScope: [],
resource: { id: candidate.id, type: "profile" },
result: "success",
tenantId: input.tenantId,
},
});
return { head, replayed: false };
}

View File

@ -221,7 +221,7 @@ describe("query handler branch coverage", () => {
await response.body?.cancel();
});
it("records requested and failed terminal activity and releases after session failure", async () => {
it("records only the query request and releases after session failure", async () => {
const release = vi.fn(async () => undefined);
const appendActivity = vi.fn(async () => ({}));
const failure = new Error("session failed");
@ -231,19 +231,26 @@ describe("query handler branch coverage", () => {
sessionError: failure,
});
await expect(fixture.invoke()).rejects.toBe(failure);
expect(appendActivity).toHaveBeenCalledTimes(2);
expect(appendActivity).toHaveBeenCalledOnce();
expect(appendActivity).toHaveBeenCalledWith(
expect.objectContaining({
action: "query.requested",
details: { mode: "fast", question: "question" },
result: "success",
}),
);
expect(release).toHaveBeenCalledOnce();
});
it("records successful terminal activity when the stream completes", async () => {
it("does not append a terminal activity when the stream completes", async () => {
const appendActivity = vi.fn(async () => ({}));
const fixture = queryFixture({ overview: { appendActivity } });
const response = await fixture.invoke();
expect(response.status).toBe(200);
await response.text();
expect(appendActivity).toHaveBeenCalledTimes(2);
expect(appendActivity).toHaveBeenLastCalledWith(
expect.objectContaining({ action: "query.completed", result: "success" }),
expect(appendActivity).toHaveBeenCalledOnce();
expect(appendActivity).toHaveBeenCalledWith(
expect.objectContaining({ action: "query.requested", result: "success" }),
);
});
});

View File

@ -388,35 +388,13 @@ export function registerQueryHandlers({
throw error;
}
const appendTerminalActivity = async (status: "canceled" | "failed" | "succeeded") => {
if (!overview) return;
const occurredAt = new Date(now()).toISOString();
await overview.appendActivity({
action: status === "succeeded" ? "query.completed" : "query.failed",
actor: { id: subject.subjectId, type: "member" },
details: { mode: resolvedMode },
id: deterministicKnowledgeSpaceActivityId(
`query.${status}`,
subject.tenantId,
space.id,
queryRunId,
),
knowledgeSpaceId: space.id,
occurredAt,
requiredPermissionScope: [],
resource: { id: queryRunId, type: "query" },
result: status === "succeeded" ? "success" : status === "canceled" ? "canceled" : "failure",
tenantId: subject.tenantId,
});
};
let requestedActivityPersisted = false;
try {
if (overview) {
const occurredAt = new Date(now()).toISOString();
await overview.appendActivity({
action: "query.requested",
actor: { id: subject.subjectId, type: "member" },
details: { mode: resolvedMode },
details: { mode: resolvedMode, ...(query ? { question: query } : {}) },
id: deterministicKnowledgeSpaceActivityId(
"query.requested",
subject.tenantId,
@ -427,10 +405,9 @@ export function registerQueryHandlers({
occurredAt,
requiredPermissionScope: [],
resource: { id: queryRunId, type: "query" },
result: "pending",
result: "success",
tenantId: subject.tenantId,
});
requestedActivityPersisted = true;
}
const session = await sessionRepository.recordQuery({
activeDocumentIds: body.activeDocumentIds,
@ -500,18 +477,10 @@ export function registerQueryHandlers({
subject,
traceId: queryRunId,
},
...(overview
? {
onTerminal: appendTerminalActivity,
}
: {}),
sessionId: session.context.sessionId,
traceId: queryRunId,
});
} catch (error) {
if (requestedActivityPersisted) {
await appendTerminalActivity("failed").catch(() => undefined);
}
await executionLease?.release().catch(() => undefined);
throw error;
}

View File

@ -22,7 +22,7 @@ const SUBJECT = {
};
describe("query Overview durability", () => {
it("persists query.requested before admitting generation and records terminal activity", async () => {
it("persists one query activity with its question and resolved mode before generation", async () => {
const spaces = createInMemoryKnowledgeSpaceRepository({
generateId: () => "018f0d60-7a49-7cc2-9c1b-5b36f18f9a02",
maxListLimit: 10,
@ -118,32 +118,20 @@ describe("query Overview durability", () => {
limit: 10,
tenantId: SUBJECT.tenantId,
});
expect(activity.items.map((event) => event.action).sort()).toEqual([
"query.completed",
"query.requested",
expect(activity.items).toEqual([
expect.objectContaining({
action: "query.requested",
details: { mode: "fast", question: "Is the request durable?" },
id: deterministicKnowledgeSpaceActivityId(
"query.requested",
SUBJECT.tenantId,
space.id,
QUERY_RUN_ID,
),
resource: { id: QUERY_RUN_ID, type: "query" },
result: "success",
}),
]);
expect(activity.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: deterministicKnowledgeSpaceActivityId(
"query.requested",
SUBJECT.tenantId,
space.id,
QUERY_RUN_ID,
),
resource: { id: QUERY_RUN_ID, type: "query" },
}),
expect.objectContaining({
id: deterministicKnowledgeSpaceActivityId(
"query.succeeded",
SUBJECT.tenantId,
space.id,
QUERY_RUN_ID,
),
resource: { id: QUERY_RUN_ID, type: "query" },
}),
]),
);
});
});

View File

@ -327,6 +327,7 @@ describe('KnowledgeOverviewPage', () => {
queryData.attention.data = []
queryData.activity.data[0]!.action = 'source.synced'
queryData.activity.data[0]!.actor = { id: 'dify-account:member-1', type: 'member' }
queryData.activity.data[0]!.details = { count: 1 }
queryData.activity.data[0]!.occurred_at = '2026-07-29T08:05:00Z'
queryData.activity.data[0]!.result = 'success'
queryData.stats.source_count = 3
@ -730,6 +731,29 @@ describe('KnowledgeOverviewPage', () => {
expect(within(dialog).getByText(/2h ago|2 hr\. ago|2 hours ago/)).toBeInTheDocument()
})
it('shows one initiated query activity with its question and retrieval mode', async () => {
const user = userEvent.setup()
queryData.activity.data[0]!.action = 'query.requested'
queryData.activity.data[0]!.details = {
mode: 'research',
question: 'How do permissions work?',
}
queryData.activity.data[0]!.result = 'success'
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
const label = 'dataset.newKnowledge.qualityPage.question: How do permissions work? — research'
expect(within(screen.getByRole('table')).getByText(label)).toBeInTheDocument()
await user.click(
screen.getByRole('button', { name: 'dataset.newKnowledge.overview.allActivity' }),
)
expect(within(screen.getByRole('dialog')).getByText(label)).toBeInTheDocument()
expect(
screen.queryByText('dataset.newKnowledge.overview.activityQueued'),
).not.toBeInTheDocument()
})
it('refreshes overview snapshots until they catch up with a completed background task', () => {
queryData.tasks[0]!.state = 'running'
queryData.tasks[0]!.updated_at = '2026-07-29T10:00:00Z'

View File

@ -689,8 +689,6 @@ function activityOperationLabel(
if (activity.action.startsWith('query.'))
return t(($) => $['newKnowledge.overview.queryOutcomes'])
if (activity.action === 'permission.updated') return t(($) => $['newKnowledge.permission'])
if (activity.action === 'profile.published')
return t(($) => $['newKnowledge.retrievalTest.title'])
if (activity.action === 'settings.updated')
return t(($) => $['newKnowledge.overview.updateEvidence'])
return t(($) => $['newKnowledge.backgroundTasks'])
@ -700,6 +698,16 @@ function activityLabel(
activity: KnowledgeFsOverviewActivityResponse,
t: ReturnType<typeof useTranslation<'dataset'>>['t'],
) {
if (activity.action === 'query.requested') {
const question = activity.details.question
const mode = activity.details.mode
const label =
typeof question === 'string' && question.trim()
? `${t(($) => $['newKnowledge.qualityPage.question'])}: ${question}`
: activityOperationLabel(activity, t)
return typeof mode === 'string' && mode.trim() ? `${label}${mode}` : label
}
const operation = activityOperationLabel(activity, t)
let label: string
if (activity.result === 'success')
@ -708,11 +716,7 @@ function activityLabel(
label = t(($) => $['newKnowledge.overview.activityFailed'], { operation })
else if (activity.result === 'canceled')
label = t(($) => $['newKnowledge.overview.activityCanceled'], { operation })
else
label =
activity.action === 'query.requested'
? t(($) => $['newKnowledge.overview.activityQueued'], { operation })
: t(($) => $['newKnowledge.overview.activityRunning'], { operation })
else label = t(($) => $['newKnowledge.overview.activityRunning'], { operation })
const detail = [
activity.details.reasonCode,