feat(knowledge-fs): record and count every knowledge space query

Workflow KnowledgeFS retrieval nodes ran through `retrieval-tests`, which
never wrote an AnswerTrace or an overview event, so their queries were
missing from the retrieval history and from the space overview; the history
also only ever showed each member their own traces.

- AnswerTrace gains a `source` (retrieval_test | workflow | service_api |
  agent | mcp; migration 0051_answer_trace_source) derived from the
  Capability v2 caller kind. The retrieval-tests route records one trace per
  run (stages, evidence bundle, profile metadata), returns its id as
  `answerTraceId`, and the workflow node's failed-retrieval capture attaches
  to that trace instead of creating a second record.
- The quality trace list exposes and filters by `source`; traces from other
  caller kinds are visible to any current reader of the space, and counts and
  scores fall back to the evidence embedded in the trace when no bundle row
  exists.
- Overview accounting: retrieval-tests and Research tasks now emit
  `query.requested`, and the Research job state machine emits
  `query.completed` / `query.failed` on terminal stages (wired for both the
  in-process gateway and the durable runtime), so query volume, answer rate
  and outcomes include every caller. Activity details keep `source` and
  `taskKind`.
- Console and service trace routes accept a `source` filter; the retrieval
  test page shows a source badge and an all / retrieval tests / workflow
  filter, with translations for every locale.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zw5G5SX3HmVfnZof6YWAc
This commit is contained in:
Jyong 2026-09-04 05:42:43 -04:00
parent 397cc2d7cc
commit b79b8a879b
79 changed files with 1381 additions and 58 deletions

View File

@ -240,6 +240,7 @@ from services.knowledge_fs.product_dto import (
KnowledgeFSStreamCapabilityResponse,
KnowledgeFSTraceEntriesQuery,
KnowledgeFSTraceEntryListResponse,
KnowledgeFSTraceListQuery,
KnowledgeFSTraceListResponse,
KnowledgeFSUploadPartPresignPayload,
KnowledgeFSUploadSessionAbortPayload,
@ -3351,7 +3352,7 @@ class KnowledgeFSSpaceResearchTaskPartialsApi(Resource):
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/traces")
class KnowledgeFSSpaceTracesApi(Resource):
@console_ns.doc(params=query_params_from_model(KnowledgeFSCursorQuery))
@console_ns.doc(params=query_params_from_model(KnowledgeFSTraceListQuery))
@console_ns.response(
HTTPStatus.OK,
"KnowledgeFS traces",
@ -3363,12 +3364,13 @@ class KnowledgeFSSpaceTracesApi(Resource):
@_knowledge_fs_errors
def get(self, control_space_id: str):
actor_id, tenant_id = _actor()
query = KnowledgeFSCursorQuery.model_validate(request.args.to_dict())
query = KnowledgeFSTraceListQuery.model_validate(request.args.to_dict())
result = _console_services().facade.list_traces(
tenant_id=tenant_id,
account_id=actor_id,
control_space_id=control_space_id,
cursor=query.cursor,
source=query.source,
)
return dump_response(KnowledgeFSTraceListResponse, result)

View File

@ -84,6 +84,7 @@ from services.knowledge_fs.product_dto import (
KnowledgeFSSourceUpdatePayload,
KnowledgeFSTraceEntriesQuery,
KnowledgeFSTraceEntryListResponse,
KnowledgeFSTraceListQuery,
KnowledgeFSTraceListResponse,
)
from services.knowledge_fs.product_operations import product_operation_action
@ -955,7 +956,7 @@ class KnowledgeFSServiceResearchTaskPartialsApi(Resource):
@service_api_ns.route("/knowledge-fs/spaces/<string:control_space_id>/traces")
class KnowledgeFSServiceTracesApi(Resource):
@service_api_ns.doc(params=query_params_from_model(KnowledgeFSCursorQuery))
@service_api_ns.doc(params=query_params_from_model(KnowledgeFSTraceListQuery))
@service_api_ns.response(
HTTPStatus.OK,
"KnowledgeFS traces",
@ -965,11 +966,11 @@ class KnowledgeFSServiceTracesApi(Resource):
def get(self, control_space_id: str):
runtime = _runtime()
profile = _profile(runtime, operation_id="listTraces", control_space_id=control_space_id)
query = KnowledgeFSCursorQuery.model_validate(request.args.to_dict())
query = KnowledgeFSTraceListQuery.model_validate(request.args.to_dict())
raw = runtime.facade.execute_service(
profile=profile,
operation_id="listTraces",
query=(("cursor", query.cursor),) if query.cursor else (),
query=tuple((name, value) for name, value in (("cursor", query.cursor), ("source", query.source)) if value),
)
return dump_response(KnowledgeFSTraceListResponse, raw)

View File

@ -6,7 +6,7 @@ import logging
import math
import time
import uuid
from collections.abc import Iterator, Mapping, Sequence
from collections.abc import Generator, Mapping, Sequence
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from dataclasses import dataclass
@ -409,7 +409,7 @@ class KnowledgeRetrievalV2Node(Node[KnowledgeRetrievalV2NodeData]):
@staticmethod
@contextmanager
def _map_space_errors(control_space_id: str, *, action: str) -> Iterator[None]:
def _map_space_errors(control_space_id: str, *, action: str) -> Generator[None]:
try:
yield
except KnowledgeFSAppChannelDisabledError as exc:
@ -807,7 +807,9 @@ class KnowledgeRetrievalV2Node(Node[KnowledgeRetrievalV2NodeData]):
control_space_id=control_space_id,
query=query,
mode=response.mode,
retrieval_trace_id=response.trace_id,
# Prefer the recorded history trace so the capture attaches to it instead of
# creating a second record; older gateways only return the transport trace.
retrieval_trace_id=response.answer_trace_id or response.trace_id,
)
except Exception:
# The helper owns broker failures in production. Keep a second boundary here so a

View File

@ -1,7 +1,7 @@
{
"schemaVersion": 5,
"subtreeTree": "219d157af4b240de61ce77c7c3473bdca699addd",
"openapiSha256": "edeae0b8fd3bd6dc9512b396da508bb99862b12ad0faeebc130996c108496d85",
"subtreeTree": "dd565881a8b5a5c113292abeaef493579eccf97c",
"openapiSha256": "20e7f698679f6c9b51aba2cc386e49eee1fbc76d05b1c0c35609963c5fcc105a",
"capabilityV2AuthManifestSha256": "e322a2fa779d1f40b95c54c1021cffecaec77abbd7b34899573dcdf4ff353109",
"capabilityV2AuthTestVectorSha256": "ae0de37b1ff05c40f905cf17a7b410d8971acacf64db07d5ee3d6fecfa559ce3",
"productOperationManifestSha256": "e4611c61c69478f20c1e1e482c44d8c48e48adfc67f1e8941ce36225e10b32c0",

View File

@ -9053,6 +9053,7 @@ Claim a workspace-staged upload. Multipart file bodies remain accepted as a lega
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| cursor | query | | No | string |
| source | query | | No | string, <br>**Available values:** "agent", "mcp", "retrieval_test", "service_api", "workflow" |
| control_space_id | path | | Yes | string |
#### Responses
@ -21056,6 +21057,7 @@ Input field definition for snippet parameters.
| mode | string, <br>**Available values:** "auto", "deep", "fast", "research" | *Enum:* `"auto"`, `"deep"`, `"fast"`, `"research"` | Yes |
| query | string | | Yes |
| query_images | [ [KnowledgeFSQueryImageResponse](#knowledgefsqueryimageresponse) ] | | No |
| source | [KnowledgeFSTraceSource](#knowledgefstracesource) | | No |
| steps | [ [KnowledgeFSAnswerTraceStepResponse](#knowledgefsanswertracestepresponse) ] | | Yes |
#### KnowledgeFSAnswerTraceStepResponse
@ -21344,10 +21346,20 @@ Input field definition for snippet parameters.
| ---- | ---- | ----------- | -------- |
| KnowledgeFSControlSpaceVisibility | string | | |
#### KnowledgeFSCrawlImportPagePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| content | string | | Yes |
| description | string | | No |
| sourceUrl | string | | Yes |
| title | string | | No |
#### KnowledgeFSCrawlImportPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| pages | [ [KnowledgeFSCrawlImportPagePayload](#knowledgefscrawlimportpagepayload) ] | | No |
| sourceUrls | [ string ] | | Yes |
#### KnowledgeFSCrawlPreviewPageListQuery
@ -21918,6 +21930,7 @@ Input field definition for snippet parameters.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | No |
| page_id | string | | No |
| source_url | string | | Yes |
| title | string | | No |
@ -21956,6 +21969,7 @@ Input field definition for snippet parameters.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| canonical_url | string | | No |
| pageId | string | | No |
| source_url | string | | Yes |
| title | string | | No |
@ -21972,6 +21986,7 @@ Input field definition for snippet parameters.
| parameters | object | | No |
| pluginId | string | | No |
| previewConfigurationFingerprint | string | | No |
| previewJobId | string | | No |
| provider | string | | Yes |
| providerDisplayName | string | | No |
| root_url | string | | Yes |
@ -22390,9 +22405,17 @@ Input field definition for snippet parameters.
#### KnowledgeFSProductPermission
Product-level capability of a caller on one KnowledgeFS space.
These values are what the console returns as ``permission_keys`` so the web can gate
its UI. Enterprise RBAC is not evaluated in this vocabulary: KnowledgeFS spaces reuse
the legacy knowledge base (``dataset_*``) permission points, and each capability is
granted when the caller holds the dataset permission it maps to (see
:data:`RBAC_PERMISSION_BY_PRODUCT_PERMISSION`).
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| KnowledgeFSProductPermission | string | | |
| KnowledgeFSProductPermission | string | Product-level capability of a caller on one KnowledgeFS space. These values are what the console returns as ``permission_keys`` so the web can gate its UI. Enterprise RBAC is not evaluated in this vocabulary: KnowledgeFS spaces reuse the legacy knowledge base (``dataset_*``) permission points, and each capability is granted when the caller holds the dataset permission it maps to (see :data:`RBAC_PERMISSION_BY_PRODUCT_PERMISSION`). | |
#### KnowledgeFSProductRerankProfile
@ -23476,6 +23499,7 @@ the file name and a short-lived signed preview URL for files the caller still ow
| query_images | [ [KnowledgeFSQueryImageResponse](#knowledgefsqueryimageresponse) ] | | No |
| result_count | integer | | Yes |
| scores | [KnowledgeFSTraceScoresResponse](#knowledgefstracescoresresponse) | | Yes |
| source | [KnowledgeFSTraceSource](#knowledgefstracesource) | | No |
| stages | [ [KnowledgeFSTraceStageResponse](#knowledgefstracestageresponse) ] | | Yes |
#### KnowledgeFSTraceScoresResponse
@ -23486,6 +23510,12 @@ the file name and a short-lived signed preview URL for files the caller still ow
| rerank | number | | No |
| retrieval | number | | No |
#### KnowledgeFSTraceSource
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| KnowledgeFSTraceSource | string | | |
#### KnowledgeFSTraceStageResponse
| Name | Type | Description | Required |

View File

@ -663,6 +663,7 @@ Deprecated legacy alias for updating an existing document by providing text cont
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| cursor | query | | No | string |
| source | query | | No | string, <br>**Available values:** "agent", "mcp", "retrieval_test", "service_api", "workflow" |
| control_space_id | path | | Yes | string |
#### Responses
@ -4274,6 +4275,7 @@ Model class for i18n object.
| mode | string, <br>**Available values:** "auto", "deep", "fast", "research" | *Enum:* `"auto"`, `"deep"`, `"fast"`, `"research"` | Yes |
| query | string | | Yes |
| query_images | [ [KnowledgeFSQueryImageResponse](#knowledgefsqueryimageresponse) ] | | No |
| source | [KnowledgeFSTraceSource](#knowledgefstracesource) | | No |
| steps | [ [KnowledgeFSAnswerTraceStepResponse](#knowledgefsanswertracestepresponse) ] | | Yes |
#### KnowledgeFSAnswerTraceStepResponse
@ -5223,6 +5225,7 @@ the file name and a short-lived signed preview URL for files the caller still ow
| query_images | [ [KnowledgeFSQueryImageResponse](#knowledgefsqueryimageresponse) ] | | No |
| result_count | integer | | Yes |
| scores | [KnowledgeFSTraceScoresResponse](#knowledgefstracescoresresponse) | | Yes |
| source | [KnowledgeFSTraceSource](#knowledgefstracesource) | | No |
| stages | [ [KnowledgeFSTraceStageResponse](#knowledgefstracestageresponse) ] | | Yes |
#### KnowledgeFSTraceScoresResponse
@ -5233,6 +5236,12 @@ the file name and a short-lived signed preview URL for files the caller still ow
| rerank | number | | No |
| retrieval | number | | No |
#### KnowledgeFSTraceSource
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| KnowledgeFSTraceSource | string | | |
#### KnowledgeFSTraceStageResponse
| Name | Type | Description | Required |

View File

@ -2079,14 +2079,20 @@ class KnowledgeFSDataFacade:
return tasks
def list_traces(
self, *, tenant_id: str, account_id: str, control_space_id: str, cursor: str | None = None
self,
*,
tenant_id: str,
account_id: str,
control_space_id: str,
cursor: str | None = None,
source: str | None = None,
) -> KnowledgeFSTraceListResponse:
raw = self._interactive(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
operation_id="listTraces",
query=(("cursor", cursor),) if cursor else (),
query=_trace_list_query(cursor=cursor, source=source),
)
traces = KnowledgeFSTraceListResponse.model_validate(raw)
self._attach_query_image_previews(
@ -2738,3 +2744,12 @@ def _assert_sse_bff_ready(operation_id: str) -> None:
__all__ = ["KnowledgeFSDataFacade"]
def _trace_list_query(*, cursor: str | None, source: str | None) -> tuple[tuple[str, str], ...]:
query: list[tuple[str, str]] = []
if cursor:
query.append(("cursor", cursor))
if source:
query.append(("source", source))
return tuple(query)

View File

@ -498,6 +498,15 @@ class KnowledgeFSCursorQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
# Who produced a retrieval trace: the console retrieval test, a KnowledgeFS workflow node, the
# service API, an agent, or an MCP client. Mirrors KnowledgeFS `AnswerTraceSourceSchema`.
type KnowledgeFSTraceSource = Literal["retrieval_test", "workflow", "service_api", "agent", "mcp"]
class KnowledgeFSTraceListQuery(KnowledgeFSCursorQuery):
source: KnowledgeFSTraceSource | None = Field(default=None)
type KnowledgeFSConsistencyClass = Literal[
"path-consistent",
"snapshot-consistent",
@ -3133,6 +3142,9 @@ class KnowledgeFSRetrievalTestMetricsResponse(ResponseModel):
class KnowledgeFSRetrievalTestResponse(ResponseModel):
# AnswerTrace recorded for this run in the space's retrieval history; a failed-retrieval capture
# attaches to it so one record stays per retrieval.
answer_trace_id: str | None = Field(default=None, validation_alias=AliasChoices("answer_trace_id", "answerTraceId"))
items: list[KnowledgeFSRetrievalTestItemResponse] = Field(max_length=100)
metrics: KnowledgeFSRetrievalTestMetricsResponse
mode: Literal["deep", "fast", "research"]
@ -3334,6 +3346,7 @@ class KnowledgeFSAnswerTraceResponse(ResponseModel):
validation_alias=AliasChoices("query_images", "queryImages"),
exclude_if=lambda value: not value,
)
source: KnowledgeFSTraceSource = "retrieval_test"
steps: list[KnowledgeFSAnswerTraceStepResponse]
@ -3423,6 +3436,7 @@ class KnowledgeFSTraceResponse(ResponseModel):
)
result_count: int = Field(ge=0, validation_alias=AliasChoices("result_count", "resultCount"))
scores: KnowledgeFSTraceScoresResponse
source: KnowledgeFSTraceSource = "retrieval_test"
stages: list[KnowledgeFSTraceStageResponse]

View File

@ -1328,3 +1328,22 @@ def test_multi_space_calls_are_concurrent_but_never_exceed_the_bound() -> None:
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert service.max_active == 4
def test_quality_capture_prefers_the_recorded_history_trace_over_the_transport_trace(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Newer gateways return the AnswerTrace id so the capture attaches to the same history record."""
dispatched: list[dict[str, object]] = []
monkeypatch.setattr(
node_module,
"enqueue_workflow_failed_retrieval_capture",
lambda **kwargs: dispatched.append(kwargs),
)
recorded = _empty_response(mode="fast", space="a").model_copy(
update={"answer_trace_id": "018f0d60-7a49-7cc2-9c1b-5b36f18f2c60"}
)
result = _node(service=RecordingCapabilityService({"space-a": recorded}), spaces=["space-a"])._run()
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert [call["retrieval_trace_id"] for call in dispatched] == ["018f0d60-7a49-7cc2-9c1b-5b36f18f2c60"]

View File

@ -62,6 +62,7 @@ from services.knowledge_fs.product_dto import (
KnowledgeFSSpaceCreatePayload,
KnowledgeFSSpaceListItemResponse,
KnowledgeFSStatResponse,
KnowledgeFSTraceListQuery,
KnowledgeFSTraceResponse,
KnowledgeFSTreeResponse,
KnowledgeFSUploadPartPresignPayload,
@ -1641,3 +1642,38 @@ def test_overview_dtos_accept_the_knowledge_fs_wire_shape() -> None:
assert KnowledgeFSOverviewWindowQuery().window == "24h"
with pytest.raises(ValidationError):
KnowledgeFSOverviewWindowQuery.model_validate({"window": "1h"})
def test_trace_responses_carry_the_caller_source_and_default_to_retrieval_tests() -> None:
"""Workflow-node retrievals share the history with console retrieval tests, distinguished by source."""
base = {
"completed": True,
"created_at": "2026-09-04T10:00:00.000Z",
"id": "trace-1",
"mode": "fast",
"profile": {},
"query": "why",
"result_count": 2,
"scores": {},
"stages": [],
}
assert KnowledgeFSTraceResponse.model_validate(base).source == "retrieval_test"
assert KnowledgeFSTraceResponse.model_validate({**base, "source": "workflow"}).model_dump(mode="json")[
"source"
] == ("workflow")
assert KnowledgeFSTraceListQuery.model_validate({"source": "workflow"}).source == "workflow"
assert KnowledgeFSTraceListQuery.model_validate({}).source is None
with pytest.raises(ValidationError):
KnowledgeFSTraceListQuery.model_validate({"source": "console"})
def test_retrieval_test_response_exposes_the_recorded_history_trace_id() -> None:
base = {"items": [], "metrics": {"degradationFlags": [], "totalMs": 4}, "mode": "fast", "traceId": "trace-a"}
assert KnowledgeFSRetrievalTestResponse.model_validate(base).answer_trace_id is None
assert (
KnowledgeFSRetrievalTestResponse.model_validate(
{**base, "answerTraceId": "018f0d60-7a49-7cc2-9c1b-5b36f18f2c60"}
).answer_trace_id
== "018f0d60-7a49-7cc2-9c1b-5b36f18f2c60"
)

View File

@ -943,6 +943,9 @@ const researchTaskRuntime =
})}\n`,
);
},
...(databaseRepositories.gatewayOptions.knowledgeSpaceOverview
? { overview: databaseRepositories.gatewayOptions.knowledgeSpaceOverview }
: {}),
partials: databaseRepositories.researchTaskPartialResults,
progress: databaseRepositories.researchTaskProgressEvents,
...(researchProjectionSnapshotResolver

View File

@ -153,6 +153,8 @@ describe("runApiDatabaseMigrations", () => {
"insert",
"schema",
"insert",
"schema",
"insert",
]);
expect(migrationSql).toHaveLength(expectedPostgresMigrationIds.length);
expect(migrationSql[2]).toContain("-- Migration id: 0003_projection_set_publications\n");
@ -236,6 +238,7 @@ describe("runApiDatabaseMigrations", () => {
);
expect(migrationSql[50]).toContain("-- Migration id: 0051_failed_query_trace_outcome_index\n");
expect(migrationSql[51]).toContain("-- Migration id: 0052_namespace_source_previews\n");
expect(migrationSql[52]).toContain("-- Migration id: 0053_answer_trace_source\n");
expect(closed).toBe(true);
});

View File

@ -7,6 +7,7 @@ import {
type KnowledgeGatewayOptions,
type KnowledgeSpaceAccessService,
type KnowledgeSpaceManifestRepository,
type KnowledgeSpaceOverviewRepository,
type ModelInputModalityResolver,
type PublishedProjectionReadSnapshotResolver,
type QueryGenerator,
@ -46,6 +47,8 @@ export interface CreateApiResearchTaskRuntimeOptions {
readonly researchTaskJob?: { readonly id: string; readonly stage: string } | undefined;
}) => void)
| undefined;
/** Overview activity sink so durable Research tasks count as answered/failed queries. */
readonly overview?: Pick<KnowledgeSpaceOverviewRepository, "appendActivity"> | undefined;
readonly partials: ResearchTaskPartialResultRepository;
readonly progress: ResearchTaskProgressRepository;
readonly projectionSnapshotResolver?: PublishedProjectionReadSnapshotResolver | undefined;
@ -89,6 +92,7 @@ export function createApiResearchTaskRuntime({
metrics,
modelInputModalityResolver,
onError,
overview,
partials,
progress,
projectionSnapshotResolver,
@ -120,6 +124,7 @@ export function createApiResearchTaskRuntime({
...(metrics ? { metrics } : {}),
...(modelInputModalityResolver ? { modelInputModalityResolver } : {}),
...(onError ? { onError } : {}),
...(overview ? { overview } : {}),
repository,
});
const runtime = createResearchTaskRuntime({

View File

@ -60,6 +60,7 @@ const answerTraceQueryImagesMigrationId = "0049_answer_trace_query_images";
const deletionJobCapabilityProvenanceMigrationId = "0050_deletion_job_capability_provenance";
const failedQueryTraceOutcomeIndexMigrationId = "0051_failed_query_trace_outcome_index";
const namespaceSourcePreviewsMigrationId = "0052_namespace_source_previews";
const answerTraceSourceMigrationId = "0053_answer_trace_source";
const migrationsAfterDurableDeletion = [
versionedSpaceProfilesMigrationId,
profilePublicationBindingsMigrationId,
@ -96,6 +97,7 @@ const migrationsAfterDurableDeletion = [
deletionJobCapabilityProvenanceMigrationId,
failedQueryTraceOutcomeIndexMigrationId,
namespaceSourcePreviewsMigrationId,
answerTraceSourceMigrationId,
] as const;
const migrationsAfterTidbBaselineRepair = [
spaceAccessControlMigrationId,

View File

@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { type AnswerTrace, AnswerTraceSchema } from "@knowledge/core";
import { type AnswerTrace, AnswerTraceSchema, type AnswerTraceSource } from "@knowledge/core";
import type { QueryImageMetadata } from "./query-images";
import {
@ -23,6 +23,8 @@ interface RecordAnswerTraceBaseInput {
readonly mode: AnswerTrace["mode"];
readonly query: string;
readonly queryImages?: readonly QueryImageMetadata[] | undefined;
/** Caller that produced the trace; omitted means a console retrieval test. */
readonly source?: AnswerTraceSource | undefined;
readonly steps: readonly RecordAnswerTraceStepInput[];
readonly traceId?: string | undefined;
}

View File

@ -293,6 +293,7 @@ describe("AnswerTrace repositories", () => {
true,
trace.createdAt,
null,
null,
],
tableName: "answer_traces",
}),
@ -1219,3 +1220,58 @@ function hasWholeSpaceDeletionFence(
)
);
}
describe("AnswerTrace source column", () => {
it.each(["postgres", "tidb"] as const)("round-trips the trace source on %s", async (kind) => {
let storedTrace: DatabaseRow | undefined;
const inserts: DatabaseExecuteInput[] = [];
const executor = async (input: DatabaseExecuteInput): Promise<DatabaseExecuteResult> => {
if (input.tableName === "knowledge_spaces") {
return { rows: [{ id: input.params[0], tenant_id: "tenant-1" }], rowsAffected: 1 };
}
if (input.tableName === "answer_traces" && input.operation === "select") {
return { rows: storedTrace ? [storedTrace] : [], rowsAffected: storedTrace ? 1 : 0 };
}
if (input.tableName === "answer_traces" && input.operation === "insert") {
inserts.push(input);
storedTrace = {
access_channel: input.params[10],
capability_grant_id: input.params[3],
completed: input.params[11],
created_at: input.params[12],
evidence_bundle_id: input.params[4],
id: input.params[0],
knowledge_space_id: input.params[2],
mode: input.params[6],
permission_snapshot_id: input.params[8],
permission_snapshot_revision: input.params[9],
query: input.params[5],
query_images: input.params[13],
source: input.params[14],
subject_id: input.params[7],
tenant_id: input.params[1],
};
return { rows: [], rowsAffected: 1 };
}
return { rows: [], rowsAffected: input.operation === "insert" ? 1 : 0 };
};
const repository = createDatabaseAnswerTraceRepository({
database: createSchemaDatabaseAdapter({
executor,
kind,
transaction: async (callback) => callback({ execute: executor }),
}),
});
const trace = AnswerTraceSchema.parse({ ...databaseTrace(), source: "workflow" });
await repository.create(trace);
expect(inserts[0]?.sql).toContain(kind === "postgres" ? '"source"' : "`source`");
expect(inserts[0]?.params[14]).toBe("workflow");
expect((await repository.getById(trace.id))?.source).toBe("workflow");
// Rows written before the column existed read back without a source (= retrieval test).
storedTrace = { ...storedTrace, source: null };
expect((await repository.getById(trace.id))?.source).toBeUndefined();
});
});

View File

@ -240,6 +240,7 @@ export function createDatabaseAnswerTraceRepository({
"completed",
"created_at",
"query_images",
"source",
];
const traceParams = [
persistedTrace.id,
@ -256,6 +257,7 @@ export function createDatabaseAnswerTraceRepository({
answerTraceCompleted(persistedTrace),
persistedTrace.createdAt,
persistedTrace.queryImages?.length ? JSON.stringify(persistedTrace.queryImages) : null,
persistedTrace.source ?? null,
] satisfies readonly DatabaseQueryValue[];
const admissionSpaceParameter =
database.dialect === "postgres"
@ -605,6 +607,7 @@ function mapAnswerTraceRows(traceRow: DatabaseRow, stepRows: readonly DatabaseRo
);
const accessChannel = optionalStringColumn(traceRow, "access_channel");
const tenantId = optionalStringColumn(traceRow, "tenant_id");
const source = optionalStringColumn(traceRow, "source");
return parseAnswerTraceProvenance({
...(capabilityGrantId === undefined ? {} : { capabilityGrantId }),
@ -626,6 +629,7 @@ function mapAnswerTraceRows(traceRow: DatabaseRow, stepRows: readonly DatabaseRo
...(traceRow.query_images == null
? {}
: { queryImages: jsonArrayColumn(traceRow, "query_images") }),
...(source === undefined ? {} : { source }),
...(subjectId === undefined ? {} : { subjectId }),
steps: stepRows.map((row) => ({
endedAt: stringColumn(row, "ended_at"),

View File

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { answerTraceSourceForCaller } from "./answer-trace-source";
describe("answerTraceSourceForCaller", () => {
it("prefers the precise Capability v2 caller kind over the collapsed gateway kind", () => {
// The gateway maps workflow grants to the "agent" access channel; history must keep them apart.
expect(
answerTraceSourceForCaller({ callerKind: "agent", capabilityCallerKind: "workflow" }),
).toBe("workflow");
expect(answerTraceSourceForCaller({ capabilityCallerKind: "agent" })).toBe("agent");
expect(answerTraceSourceForCaller({ capabilityCallerKind: "mcp" })).toBe("mcp");
expect(answerTraceSourceForCaller({ capabilityCallerKind: "service" })).toBe("service_api");
expect(answerTraceSourceForCaller({ capabilityCallerKind: "internal_worker" })).toBe(
"service_api",
);
expect(answerTraceSourceForCaller({ capabilityCallerKind: "interactive" })).toBe(
"retrieval_test",
);
});
it("falls back to the gateway caller kind for legacy sessions and API keys", () => {
expect(answerTraceSourceForCaller({ callerKind: "api_key" })).toBe("service_api");
expect(answerTraceSourceForCaller({ callerKind: "service_api" })).toBe("service_api");
expect(answerTraceSourceForCaller({ callerKind: "agent" })).toBe("agent");
expect(answerTraceSourceForCaller({ callerKind: "mcp" })).toBe("mcp");
expect(answerTraceSourceForCaller({ callerKind: "interactive" })).toBe("retrieval_test");
expect(answerTraceSourceForCaller({})).toBe("retrieval_test");
});
});

View File

@ -0,0 +1,45 @@
import type { AnswerTraceSource } from "@knowledge/core";
import type { DifyCapabilityV2SanitizedGrant } from "./dify-capability-v2-grant";
import type { KnowledgeSpaceCallerKind } from "./knowledge-space-authorization";
/**
* Derive the AnswerTrace source from the authenticated caller.
*
* Capability v2 grants carry the precise caller kind (the gateway collapses `workflow` into
* `agent` for access-channel purposes, which is too coarse for history); legacy sessions and
* API keys only expose the gateway caller kind. Anything unknown is a console retrieval test.
*/
export function answerTraceSourceForCaller({
callerKind,
capabilityCallerKind,
}: {
readonly callerKind?: KnowledgeSpaceCallerKind | undefined;
readonly capabilityCallerKind?: DifyCapabilityV2SanitizedGrant["callerKind"] | undefined;
}): AnswerTraceSource {
switch (capabilityCallerKind) {
case "workflow":
return "workflow";
case "agent":
return "agent";
case "mcp":
return "mcp";
case "service":
case "internal_worker":
return "service_api";
case "interactive":
return "retrieval_test";
default:
break;
}
switch (callerKind) {
case "agent":
return "agent";
case "mcp":
return "mcp";
case "api_key":
case "service_api":
return "service_api";
default:
return "retrieval_test";
}
}

View File

@ -1,4 +1,5 @@
import type {
AnswerTraceSource,
AuthSubject,
EvidenceBundle,
KnowledgeSpaceEmbeddingProfile,
@ -36,6 +37,8 @@ import {
export type QueryGenerationMode = "deep" | "fast" | "research";
export interface QueryGenerationInput {
/** Caller that produced this query; recorded on the AnswerTrace for history filtering. */
readonly source?: AnswerTraceSource | undefined;
readonly capabilityGrantId?: string | undefined;
readonly embeddingProfile?: KnowledgeSpaceEmbeddingProfile | undefined;
readonly embeddingInputModalities?: readonly ModelInputModality[] | undefined;
@ -472,6 +475,7 @@ async function recordAnswerTrace({
mode: input.mode,
query: input.query,
...(input.queryImageMetadata?.length ? { queryImages: input.queryImageMetadata } : {}),
...(input.source ? { source: input.source } : {}),
steps: [
...stageSteps,
{

View File

@ -12872,7 +12872,7 @@ describe("createKnowledgeGateway", () => {
trace.permissionSnapshot?.accessChannel,
true,
trace.createdAt,
null,
...[null, trace.source ?? null],
],
tableName: "answer_traces",
}),

View File

@ -1221,6 +1221,7 @@ export function createKnowledgeGateway({
createResearchTaskJobStateMachine({
generateId: generateResearchTaskJobId,
jobs: adapter.jobs,
overview: overviewRepository,
progress: createResearchTaskProgressPublisher({
repository: researchTaskProgressEvents,
}),
@ -1984,6 +1985,7 @@ export function createKnowledgeGateway({
}
: {}),
now: () => Date.parse(now()),
overview: overviewRepository,
researchTaskJobs,
researchTaskPartialResults,
researchTaskProgressEvents,

View File

@ -503,7 +503,11 @@ const SAFE_ACTIVITY_DETAIL_KEYS = new Set([
"question",
"questionTruncated",
"reasonCode",
// Who issued a query (console retrieval test, workflow node, service API, agent, MCP).
"source",
"statusCode",
// Distinguishes Research tasks from streamed queries in the query events.
"taskKind",
]);
export function sanitizeKnowledgeSpaceActivityDetails(

View File

@ -3008,3 +3008,144 @@ function permissionFenceRow(
visibility: "all_members",
};
}
describe("answer trace history sources", () => {
const WORKFLOW_TRACE_ID = "018f0d60-7a49-7cc2-9c1b-5b36f18f2c70";
const evidenceBundle = {
createdAt: NOW,
id: "018f0d60-7a49-7cc2-9c1b-5b36f18f2c71",
items: [
{
citations: [
{ documentAssetId: "018f0d60-7a49-7cc2-9c1b-5b36f18f2c72", documentVersion: 1 },
],
conflicts: [],
freshness: { status: "unknown" },
metadata: {},
nodeId: "018f0d60-7a49-7cc2-9c1b-5b36f18f2c73",
score: 0.8,
scores: { final: 0.8, retrieval: 0.7 },
text: "workflow evidence",
},
],
missingEvidence: [],
query: "workflow question",
state: "answerable",
};
it.each(["postgres", "tidb"] as const)(
"lists workflow-sourced traces for any current reader and reads their embedded evidence on %s",
async (dialect) => {
const calls: DatabaseExecuteInput[] = [];
const database = testDatabase(dialect, async (input) => {
calls.push(input);
if (input.tableName === "answer_traces") {
return {
rows: [
{
completed: dialect === "tidb" ? "1" : true,
created_at: NOW,
evidence_bundle_id: null,
evidence_items: null,
evidence_state: null,
id: WORKFLOW_TRACE_ID,
mode: "fast",
query: "workflow question",
source: "workflow",
},
],
rowsAffected: 1,
};
}
if (input.tableName === "answer_trace_steps") {
const metadata = { evidenceBundle, resultCount: 1 };
return {
rows: [
{
ended_at: NOW,
id: "018f0d60-7a49-7cc2-9c1b-5b36f18f2c74",
metadata: dialect === "tidb" ? JSON.stringify(metadata) : metadata,
name: "retrieval.test",
started_at: NOW,
status: "ok",
trace_id: WORKFLOW_TRACE_ID,
},
],
rowsAffected: 1,
};
}
return { rows: [], rowsAffected: 0 };
});
const repository = createDatabaseQualityControlRepository({ database, maxListLimit: 100 });
const page = await repository.listTraces({
candidateGrants: ["tenant:tenant-1"],
capabilityRequester: { callerKind: "interactive", subjectId: "reader-1" },
knowledgeSpaceId: SPACE_ID,
limit: 10,
source: "workflow",
subjectId: "reader-1",
tenantId: "tenant-1",
});
expect(page.items[0]).toMatchObject({
evidenceState: "answerable",
id: WORKFLOW_TRACE_ID,
resultCount: 1,
scores: { final: 0.8, retrieval: 0.7 },
source: "workflow",
});
const call = calls.find((candidate) => candidate.tableName === "answer_traces");
// Own traces still join through the requester's grant; shared sources need only a current
// reader grant on the space, so the join is no longer an inner join.
expect(call?.sql).toContain("LEFT JOIN");
expect(call?.sql).toContain("'workflow', 'service_api', 'agent', 'mcp'");
expect(call?.sql).toContain("reader");
expect(call?.sql).not.toContain("knowledge_space_permission_snapshots");
expect(call?.params).toContain("workflow");
},
);
it.each(["postgres", "tidb"] as const)(
"treats rows without a stored source as retrieval tests when filtering on %s",
async (dialect) => {
const calls: DatabaseExecuteInput[] = [];
const database = testDatabase(dialect, async (input) => {
calls.push(input);
if (input.tableName === "answer_traces") {
return {
rows: [
{
completed: dialect === "tidb" ? "1" : true,
created_at: NOW,
evidence_bundle_id: null,
evidence_items: [],
evidence_state: null,
id: TRACE_ID,
mode: "fast",
query: "legacy question",
source: null,
},
],
rowsAffected: 1,
};
}
return { rows: [], rowsAffected: 0 };
});
const repository = createDatabaseQualityControlRepository({ database, maxListLimit: 100 });
const page = await repository.listTraces({
candidateGrants: ["tenant:tenant-1"],
knowledgeSpaceId: SPACE_ID,
limit: 10,
source: "retrieval_test",
subjectId: "editor-1",
tenantId: "tenant-1",
});
expect(page.items[0]?.source).toBe("retrieval_test");
const call = calls.find((candidate) => candidate.tableName === "answer_traces");
expect(call?.sql).toMatch(/IS NULL OR trace\.[`"]source[`"] = /u);
},
);
});

View File

@ -6,7 +6,12 @@ import type {
DatabaseQueryValue,
DatabaseRow,
} from "@knowledge/core";
import { QueryImageMetadataSchema } from "@knowledge/core";
import {
type AnswerTraceSource,
AnswerTraceSourceSchema,
EvidenceBundleSchema,
QueryImageMetadataSchema,
} from "@knowledge/core";
import { answerTraceKnowledgeSpaceVisibilitySql } from "./answer-trace-repository";
import { resolveCapabilityJobPublicationGrant } from "./capability-job-fence";
@ -1080,18 +1085,28 @@ async function listTraces(
const params: DatabaseQueryValue[] = [input.tenantId, input.knowledgeSpaceId, input.subjectId];
let authorizationFilter: string;
let authorizationJoin: string;
// A subject sees the traces it produced itself (joined through the grant or permission snapshot
// that authorized them) plus every trace another caller kind produced in the space (workflow
// nodes, agents, service API, MCP), provided the subject currently holds an authorization for
// the space whose content scope admits the caller's candidate grants. Console retrieval tests of
// other members stay private to their author, exactly as before.
const sharedSourcesSql = SHARED_ANSWER_TRACE_SOURCES.map((source) => `'${source}'`).join(", ");
if (input.capabilityRequester) {
params.push(input.capabilityRequester.callerKind, JSON.stringify(input.candidateGrants));
authorizationJoin = `INNER JOIN ${q(database, "capability_grants")} capability ON capability.${q(database, "tenant_id")} = ${p(database, 1)} AND capability.${q(database, "knowledge_space_id")} = trace.${q(database, "knowledge_space_id")} AND capability.${q(database, "grant_id")} = trace.${q(database, "capability_grant_id")} AND capability.${q(database, "subject_id")} = ${p(database, 3)} AND capability.${q(database, "caller_kind")} = ${p(database, 4)}`;
authorizationFilter = permissionScopeSql(
authorizationJoin = `LEFT JOIN ${q(database, "capability_grants")} capability ON capability.${q(database, "tenant_id")} = ${p(database, 1)} AND capability.${q(database, "knowledge_space_id")} = trace.${q(database, "knowledge_space_id")} AND capability.${q(database, "grant_id")} = trace.${q(database, "capability_grant_id")} AND capability.${q(database, "subject_id")} = ${p(database, 3)} AND capability.${q(database, "caller_kind")} = ${p(database, 4)}`;
const ownTrace = `capability.${q(database, "grant_id")} IS NOT NULL AND ${permissionScopeSql(
database,
`capability.${q(database, "content_scope_ids")}`,
p(database, 5),
);
)}`;
const sharedTrace = `trace.${q(database, "source")} IN (${sharedSourcesSql}) AND EXISTS (SELECT 1 FROM ${q(database, "capability_grants")} reader WHERE reader.${q(database, "tenant_id")} = ${p(database, 1)} AND reader.${q(database, "knowledge_space_id")} = trace.${q(database, "knowledge_space_id")} AND reader.${q(database, "subject_id")} = ${p(database, 3)} AND reader.${q(database, "caller_kind")} = ${p(database, 4)} AND reader.${q(database, "state")} = 'active' AND ${permissionScopeSql(database, `reader.${q(database, "content_scope_ids")}`, p(database, 5))})`;
authorizationFilter = `((${ownTrace}) OR (${sharedTrace}))`;
} else {
params.push(JSON.stringify(input.candidateGrants));
authorizationJoin = `INNER JOIN ${q(database, "knowledge_space_permission_snapshots")} permission ON permission.${q(database, "tenant_id")} = ${p(database, 1)} AND permission.${q(database, "knowledge_space_id")} = trace.${q(database, "knowledge_space_id")} AND permission.${q(database, "id")} = trace.${q(database, "permission_snapshot_id")} AND permission.${q(database, "subject_id")} = trace.${q(database, "subject_id")}`;
authorizationFilter = `trace.${q(database, "subject_id")} = ${p(database, 3)} AND ${permissionScopeSql(database, `permission.${q(database, "permission_scopes")}`, p(database, 4))}`;
authorizationJoin = `LEFT JOIN ${q(database, "knowledge_space_permission_snapshots")} permission ON permission.${q(database, "tenant_id")} = ${p(database, 1)} AND permission.${q(database, "knowledge_space_id")} = trace.${q(database, "knowledge_space_id")} AND permission.${q(database, "id")} = trace.${q(database, "permission_snapshot_id")} AND permission.${q(database, "subject_id")} = trace.${q(database, "subject_id")}`;
const ownTrace = `trace.${q(database, "subject_id")} = ${p(database, 3)} AND permission.${q(database, "id")} IS NOT NULL AND ${permissionScopeSql(database, `permission.${q(database, "permission_scopes")}`, p(database, 4))}`;
const sharedTrace = `trace.${q(database, "source")} IN (${sharedSourcesSql}) AND EXISTS (SELECT 1 FROM ${q(database, "knowledge_space_permission_snapshots")} reader WHERE reader.${q(database, "tenant_id")} = ${p(database, 1)} AND reader.${q(database, "knowledge_space_id")} = trace.${q(database, "knowledge_space_id")} AND reader.${q(database, "subject_id")} = ${p(database, 3)} AND ${permissionScopeSql(database, `reader.${q(database, "permission_scopes")}`, p(database, 4))})`;
authorizationFilter = `((${ownTrace}) OR (${sharedTrace}))`;
}
const traceTenantFilter = input.capabilityRequester
? `trace.${q(database, "tenant_id")} = ${p(database, 1)} AND `
@ -1115,6 +1130,14 @@ async function listTraces(
params.push(input.mode);
filters.push(`trace.${q(database, "mode")} = ${p(database, params.length)}`);
}
if (input.source) {
params.push(input.source);
filters.push(
input.source === "retrieval_test"
? `(trace.${q(database, "source")} IS NULL OR trace.${q(database, "source")} = ${p(database, params.length)})`
: `trace.${q(database, "source")} = ${p(database, params.length)}`,
);
}
if (input.status) {
filters.push(
input.status === "completed"
@ -1176,7 +1199,13 @@ function mapTraceSummary(
row: DatabaseRow,
steps: readonly DatabaseRow[],
): QualityAnswerTraceSummary {
const evidenceItems = row.evidence_items == null ? [] : jsonArrayColumn(row, "evidence_items");
// Retrieval tests recorded by workflow nodes carry their evidence inside the summary step instead
// of a persisted bundle row; read counts and scores from there when no bundle is joined.
const embeddedBundle = row.evidence_items == null ? embeddedEvidenceBundle(steps) : undefined;
const evidenceItems =
row.evidence_items == null
? (embeddedBundle?.items ?? [])
: jsonArrayColumn(row, "evidence_items");
const queryImages =
row.query_images == null
? []
@ -1218,12 +1247,13 @@ function mapTraceSummary(
...(optionalStringColumn(row, "evidence_bundle_id")
? { evidenceBundleId: optionalStringColumn(row, "evidence_bundle_id") }
: {}),
...(optionalStringColumn(row, "evidence_state")
? { evidenceState: optionalStringColumn(row, "evidence_state") }
...((optionalStringColumn(row, "evidence_state") ?? embeddedBundle?.state)
? { evidenceState: optionalStringColumn(row, "evidence_state") ?? embeddedBundle?.state }
: {}),
...(score("final") !== undefined ? { finalScore: score("final") } : {}),
id: stringColumn(row, "id"),
mode: stringColumn(row, "mode") as QualityAnswerTraceSummary["mode"],
source: traceSourceColumn(row),
...(optionalStringColumn(row, "open_bad_case_id")
? { openBadCaseId: optionalStringColumn(row, "open_bad_case_id") }
: {}),
@ -2226,3 +2256,22 @@ function p(database: DatabaseAdapter, position: number) {
function jsonP(database: DatabaseAdapter, position: number) {
return jsonInsertPlaceholder(database, position, undefined);
}
/** Every source except console retrieval tests; visible to any current reader of the space. */
const SHARED_ANSWER_TRACE_SOURCES: readonly AnswerTraceSource[] =
AnswerTraceSourceSchema.options.filter((source) => source !== "retrieval_test");
function traceSourceColumn(row: DatabaseRow): AnswerTraceSource {
const parsed = AnswerTraceSourceSchema.safeParse(optionalStringColumn(row, "source"));
return parsed.success ? parsed.data : "retrieval_test";
}
function embeddedEvidenceBundle(steps: readonly DatabaseRow[]) {
for (const step of steps) {
const candidate = EvidenceBundleSchema.safeParse(
jsonObjectColumn(step, "metadata").evidenceBundle,
);
if (candidate.success) return candidate.data;
}
return undefined;
}

View File

@ -108,6 +108,7 @@ export function registerQualityControlHandlers({
limit: query.limit,
...(query.mode ? { mode: query.mode } : {}),
...(query.query ? { query: query.query } : {}),
...(query.source ? { source: query.source } : {}),
...(query.status ? { status: query.status } : {}),
subjectId: scope.subject.subjectId,
tenantId: scope.subject.tenantId,

View File

@ -1,5 +1,5 @@
import { createRoute, z } from "@hono/zod-openapi";
import { QueryImageMetadataSchema } from "@knowledge/core";
import { AnswerTraceSourceSchema, QueryImageMetadataSchema } from "@knowledge/core";
import { ForbiddenResponse, UnauthorizedResponse } from "./gateway-openapi-contracts";
import { ErrorResponseSchema } from "./gateway-route-schemas";
@ -28,6 +28,7 @@ export const QualityTraceHistoryQuerySchema = z
limit: z.coerce.number().int().min(1).max(100).default(50),
mode: z.enum(["auto", "deep", "fast", "research"]).optional(),
query: z.string().trim().min(1).max(500).optional(),
source: AnswerTraceSourceSchema.optional(),
status: z.enum(["completed", "failed"]).optional(),
to: DateTime.optional(),
})
@ -58,6 +59,7 @@ export const QualityTraceSummarySchema = z
queryImages: z.array(QueryImageMetadataSchema).max(4).optional(),
resultCount: z.number().int().nonnegative(),
scores: z.object({ final: ScoreSchema, rerank: ScoreSchema, retrieval: ScoreSchema }),
source: AnswerTraceSourceSchema,
stages: z.array(
z.object({
candidateCount: z.number().int().nonnegative().optional(),

View File

@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import type {
AnswerTraceSource,
AuthSubject,
KnowledgeSpaceEmbeddingProfile,
KnowledgeSpaceRetrievalProfile,
@ -55,6 +56,8 @@ export interface QualityAnswerTraceSummary {
readonly finalScore?: number | undefined;
readonly id: string;
readonly mode: "auto" | "deep" | "fast" | "research";
/** Caller that produced the trace; legacy rows without a stored value are retrieval tests. */
readonly source: AnswerTraceSource;
/** The unresolved (open or replaying) bad case already filed for this trace, if any. */
readonly openBadCaseId?: string | undefined;
readonly profile: {
@ -95,6 +98,7 @@ export interface QualityAnswerTraceHistoryInput {
readonly knowledgeSpaceId: string;
readonly limit: number;
readonly mode?: "auto" | "deep" | "fast" | "research" | undefined;
readonly source?: AnswerTraceSource | undefined;
readonly query?: string | undefined;
readonly status?: "completed" | "failed" | undefined;
readonly subjectId: string;

View File

@ -2,6 +2,7 @@ import type { OpenAPIHono } from "@hono/zod-openapi";
import { validateKnowledgeSpaceRetrievalProfileForMode } from "@knowledge/core";
import type { AnswerTraceRecorder } from "./answer-trace-recorder";
import { answerTraceSourceForCaller } from "./answer-trace-source";
import {
type AutoRetrievalModeResolver,
resolveRetrievalModeRequest,
@ -500,6 +501,10 @@ export function registerQueryHandlers({
input: {
...(capabilityGrant ? { capabilityGrantId: capabilityGrant.grantId } : {}),
knowledgeSpaceId: space.id,
source: answerTraceSourceForCaller({
callerKind: context.get("callerKind"),
capabilityCallerKind: capabilityGrant?.callerKind,
}),
...(runtimeSnapshot?.embeddingProfile
? { embeddingProfile: runtimeSnapshot.embeddingProfile }
: {}),

View File

@ -15,6 +15,7 @@ import {
encodeResearchTaskListCursor,
researchTaskRuntimeSnapshotFromMetadata,
} from "./index";
import { createInMemoryKnowledgeSpaceOverviewRepository } from "./knowledge-space-overview";
describe("research task handlers", () => {
it("round-trips list cursors and rejects every malformed cursor boundary", () => {
@ -84,9 +85,14 @@ describe("research task handlers", () => {
repository: createInMemoryResearchTaskJobRepository({ maxJobs: 10 }),
});
let deletionActive = false;
const overview = createInMemoryKnowledgeSpaceOverviewRepository({
maxEvents: 100,
maxListLimit: 50,
});
const app = createKnowledgeGateway({
adapter,
allowLegacyResearchTaskProfileFallback: true,
knowledgeSpaceOverview: overview,
auth: createStaticAuthVerifier({
subjectsByToken: {
"editor-token": {
@ -129,6 +135,27 @@ describe("research task handlers", () => {
});
expect(response.status).toBe(201);
// Research tasks count as knowledge-space queries in the overview, keyed by the task id.
expect(
(
await overview.listActivity({
candidateGrants: ["tenant:tenant-1"],
knowledgeSpaceId,
limit: 10,
tenantId: "tenant-1",
})
).items,
).toEqual([
expect.objectContaining({
action: "query.requested",
details: expect.objectContaining({
question: "Research semantic retrieval regressions",
source: "retrieval_test",
taskKind: "research",
}),
resource: { id: "research-task-job-1", type: "query" },
}),
]);
const responseBody = (await response.json()) as Record<string, unknown>;
expect(responseBody).toMatchObject({
id: "research-task-job-1",

View File

@ -4,6 +4,7 @@ import {
validateKnowledgeSpaceRetrievalProfileForMode,
} from "@knowledge/core";
import { answerTraceSourceForCaller } from "./answer-trace-source";
import { isAuthenticatedApiKeyBoundToKnowledgeSpace } from "./auth";
import {
AUTO_RETRIEVAL_MODE_DECISION_METADATA_KEY,
@ -31,6 +32,10 @@ import {
type KnowledgeSpaceAuthorizationGuard,
type KnowledgeSpaceCallerKind,
} from "./knowledge-space-authorization";
import {
type KnowledgeSpaceOverviewRepository,
deterministicKnowledgeSpaceActivityId,
} from "./knowledge-space-overview";
import type { KnowledgeSpaceRepository } from "./knowledge-space-repository";
import { type LooseOpenApiContext, openApiHandler } from "./openapi-handler-utils";
import type { PublishedKnowledgeSpaceRuntimeSnapshotResolver } from "./published-knowledge-space-runtime-snapshot";
@ -88,6 +93,8 @@ export interface RegisterResearchTaskHandlersOptions {
readonly assets: Pick<DocumentAssetRepository, "get" | "getManyByIds">;
readonly dryRunResearchPlanner: ResearchTaskDryRunPlanner;
readonly deletionVisibility?: ResearchTaskDeletionVisibility | undefined;
/** Counts each task as a knowledge-space query in the overview, like the query stream does. */
readonly overview?: Pick<KnowledgeSpaceOverviewRepository, "appendActivity"> | undefined;
readonly researchTaskJobs: ResearchTaskJobStateMachine;
readonly researchTaskPartialResults: ResearchTaskPartialResultRepository;
readonly researchTaskProgressEvents: ResearchTaskProgressRepository;
@ -109,6 +116,7 @@ export function registerResearchTaskHandlers({
dryRunResearchPlanner,
deletionVisibility,
directStream,
overview,
researchTaskJobs,
researchTaskPartialResults,
researchTaskProgressEvents,
@ -390,6 +398,17 @@ export function registerResearchTaskHandlers({
tenantId: subject.tenantId,
topK: plan.retrievalPlan.topK,
});
await appendResearchTaskQueryRequested({
job,
occurredAt: new Date(now()).toISOString(),
overview,
query: body.query,
source: answerTraceSourceForCaller({
callerKind,
capabilityCallerKind: capabilityGrant?.callerKind,
}),
subject,
});
return context.json(toPublicResearchTaskJob(job), 201);
} catch (error) {
@ -927,3 +946,58 @@ async function authorizeJobAccess(input: {
throw error;
}
}
/**
* Count the Research task as a knowledge-space query. The overview derives query volume from
* `query.requested` events and marks the task answered when the job state machine emits
* `query.completed` for the same id. Best effort: task creation must not fail because the
* activity feed is unavailable.
*/
async function appendResearchTaskQueryRequested({
job,
occurredAt,
overview,
query,
source,
subject,
}: {
readonly job: {
readonly id: string;
readonly knowledgeSpaceId: string;
readonly mode?: string | undefined;
readonly tenantId: string;
};
readonly occurredAt: string;
readonly overview: Pick<KnowledgeSpaceOverviewRepository, "appendActivity"> | undefined;
readonly query: string | undefined;
readonly source: string;
readonly subject: KnowledgeGatewayEnv["Variables"]["subject"];
}): Promise<void> {
if (!overview) return;
try {
await overview.appendActivity({
action: "query.requested",
actor: { id: subject.subjectId, type: "member" },
details: {
mode: job.mode ?? "research",
...(query ? { question: query } : {}),
source,
taskKind: "research",
},
id: deterministicKnowledgeSpaceActivityId(
"query.requested",
job.tenantId,
job.knowledgeSpaceId,
job.id,
),
knowledgeSpaceId: job.knowledgeSpaceId,
occurredAt,
requiredPermissionScope: [],
resource: { id: job.id, type: "query" },
result: "success",
tenantId: job.tenantId,
});
} catch {
// Best effort: see the function comment.
}
}

View File

@ -1009,3 +1009,74 @@ function evidenceBundle(id: string, text: string) {
state: "partial" as const,
};
}
describe("research task overview outcomes", () => {
it("reports completion and failure to the space overview as query outcomes", async () => {
const appendActivity = vi.fn(async (input: unknown) => input as never);
const machine = createResearchTaskJobStateMachine({
generateId: (() => {
let next = 0;
return () => `research-task-job-${++next}`;
})(),
jobs: new FakeJobQueue(),
now: () => 5_000,
overview: { appendActivity },
repository: createInMemoryResearchTaskJobRepository({ maxJobs: 10 }),
});
const completed = await machine.start(baseStartInput());
for (const stage of [
"planning",
"retrieving",
"analyzing",
"generating",
"completed",
] as const) {
await machine.advance(completed.id, stage);
}
const failed = await machine.start(baseStartInput());
await machine.fail(failed.id, "model unavailable");
const canceled = await machine.start(baseStartInput());
await machine.cancel(canceled.id, "operator");
expect(appendActivity.mock.calls.map((call) => call[0])).toEqual([
expect.objectContaining({
action: "query.completed",
actor: { type: "system" },
knowledgeSpaceId: "space-1",
resource: { id: completed.id, type: "query" },
result: "success",
tenantId: "tenant-1",
}),
expect.objectContaining({
action: "query.failed",
details: expect.objectContaining({ reasonCode: "failed", taskKind: "research" }),
resource: { id: failed.id, type: "query" },
result: "failure",
}),
expect.objectContaining({
action: "query.failed",
details: expect.objectContaining({ reasonCode: "canceled", taskKind: "research" }),
resource: { id: canceled.id, type: "query" },
result: "canceled",
}),
]);
});
it("never lets an overview write failure break a terminal transition", async () => {
const machine = createResearchTaskJobStateMachine({
generateId: () => "research-task-job-1",
jobs: new FakeJobQueue(),
now: () => 5_000,
overview: {
appendActivity: async () => {
throw new Error("activity feed down");
},
},
repository: createInMemoryResearchTaskJobRepository({ maxJobs: 10 }),
});
const job = await machine.start(baseStartInput());
await expect(machine.fail(job.id, "boom")).resolves.toMatchObject({ stage: "failed" });
});
});

View File

@ -4,6 +4,10 @@ import {
type JobPayload,
type JobQueueAdapter,
} from "@knowledge/core";
import {
type KnowledgeSpaceOverviewRepository,
deterministicKnowledgeSpaceActivityId,
} from "./knowledge-space-overview";
import type {
CapabilityCallerKind,
@ -196,6 +200,8 @@ export interface ResearchTaskJobStateMachineOptions {
readonly maxQueryBytes?: number;
readonly metrics?: DurableTaskOperationalMetrics | undefined;
readonly now?: () => number;
/** Reports terminal outcomes to the space overview so Research tasks count as answered/failed queries. */
readonly overview?: Pick<KnowledgeSpaceOverviewRepository, "appendActivity"> | undefined;
readonly progress?: ResearchTaskProgressPublisher | undefined;
readonly repository: ResearchTaskJobRepository;
}
@ -291,6 +297,7 @@ export function createResearchTaskJobStateMachine({
maxQueryBytes = defaultMaxQueryBytes,
metrics,
now = Date.now,
overview,
progress,
repository,
}: ResearchTaskJobStateMachineOptions): ResearchTaskJobStateMachine {
@ -316,6 +323,7 @@ export function createResearchTaskJobStateMachine({
outcome: "completed",
taskKind: "research",
});
await appendResearchTaskOutcome(overview, updated, "query.completed", "success", {});
}
await progress?.publish(updated, "research_task.stage_changed", {
previousStage: job.stage,
@ -341,6 +349,9 @@ export function createResearchTaskJobStateMachine({
outcome: "canceled",
taskKind: "research",
});
await appendResearchTaskOutcome(overview, updated, "query.failed", "canceled", {
reasonCode: "canceled",
});
await progress?.publish(updated, "research_task.canceled", reason ? { reason } : {});
return cloneResearchTaskJob(updated);
},
@ -363,6 +374,9 @@ export function createResearchTaskJobStateMachine({
outcome: "failed",
taskKind: "research",
});
await appendResearchTaskOutcome(overview, updated, "query.failed", "failure", {
reasonCode: "failed",
});
await progress?.publish(updated, "research_task.failed", { error });
return cloneResearchTaskJob(updated);
},
@ -1135,3 +1149,34 @@ function clonePartialResult(result: ResearchTaskPartialResult): ResearchTaskPart
function cloneEvidenceBundle(bundle: EvidenceBundle): EvidenceBundle {
return JSON.parse(JSON.stringify(bundle)) as EvidenceBundle;
}
/**
* Mirror a terminal Research outcome into the space overview. The task was counted as a query
* when it was created; `query.completed` marks it answered and `query.failed` keeps it in the
* unanswered bucket. Best effort: the state transition has already been persisted.
*/
async function appendResearchTaskOutcome(
overview: Pick<KnowledgeSpaceOverviewRepository, "appendActivity"> | undefined,
job: ResearchTaskJob,
action: "query.completed" | "query.failed",
result: "canceled" | "failure" | "success",
details: Readonly<Record<string, unknown>>,
): Promise<void> {
if (!overview) return;
try {
await overview.appendActivity({
action,
actor: { type: "system" },
details: { ...details, taskKind: "research" },
id: deterministicKnowledgeSpaceActivityId(action, job.tenantId, job.knowledgeSpaceId, job.id),
knowledgeSpaceId: job.knowledgeSpaceId,
occurredAt: new Date(job.completedAt ?? job.updatedAt).toISOString(),
requiredPermissionScope: [],
resource: { id: job.id, type: "query" },
result,
tenantId: job.tenantId,
});
} catch {
// Best effort: see the function comment.
}
}

View File

@ -90,21 +90,28 @@ describe("retrieval test route", () => {
});
expect(response.status).toBe(200);
// The node forwards this id when it captures an empty retrieval, so one record stays per run.
expect((await response.json()) as Record<string, unknown>).toMatchObject({
answerTraceId: expectedTraceId,
});
expect(overview.appendActivity).toHaveBeenCalledWith(
expect.objectContaining({
action: "query.requested",
details: expect.objectContaining({ mode: "fast" }),
details: expect.objectContaining({ mode: "fast", source: "workflow" }),
resource: { id: expectedTraceId, type: "query" },
}),
);
expect(answerTraceRecorder.record).toHaveBeenCalledWith(
expect.objectContaining({
capabilityGrantId: "10000000-0000-4000-8000-000000000011",
source: "workflow",
traceId: expectedTraceId,
steps: [
expect.objectContaining({
metadata: expect.objectContaining({
queryOutcome: expectedOutcome,
resultCount: result.items.length,
stages: result.stages,
workflowQueryId: WORKFLOW_QUERY_ID,
}),
name: "query.generate",

View File

@ -2,10 +2,14 @@ import type { OpenAPIHono } from "@hono/zod-openapi";
import { validateKnowledgeSpaceRetrievalProfileForMode } from "@knowledge/core";
import type { AnswerTraceRecorder } from "./answer-trace-recorder";
import { answerTraceSourceForCaller } from "./answer-trace-source";
import { currentCandidateGrants } from "./candidate-content-authorization";
import { classifyQueryOutcome } from "./failed-query-recorder";
import type { KnowledgeGatewayEnv } from "./gateway-openapi-contracts";
import { queryRetrievalProfileMetadata } from "./gateway-sse-responses";
import {
queryProjectionSnapshotMetadata,
queryRetrievalProfileMetadata,
} from "./gateway-sse-responses";
import {
type KnowledgeSpaceOverviewRepository,
deterministicKnowledgeSpaceActivityId,
@ -70,6 +74,10 @@ export function registerRetrievalTestHandlers({
const body = context.req.valid("json");
const capabilityGrant = context.get("capabilityV2Grant");
const workflowQueryId = capabilityGrant?.callerKind === "workflow" ? body.queryId : undefined;
const source = answerTraceSourceForCaller({
callerKind: context.get("callerKind"),
capabilityCallerKind: capabilityGrant?.callerKind,
});
const space = await spaces.get({ id: knowledgeSpaceId, tenantId: subject.tenantId });
if (!space) {
return context.json({ error: "Knowledge space not found" }, 404);
@ -233,6 +241,7 @@ export function registerRetrievalTestHandlers({
details: {
mode,
...(body.query ? { question: body.query } : {}),
source,
},
id: deterministicKnowledgeSpaceActivityId(
"query.requested",
@ -274,6 +283,9 @@ export function registerRetrievalTestHandlers({
traceId,
});
await executionLease.assertActive();
const recordsTrace = Boolean(
workflowAnswerTraceId && capabilityGrant && answerTraceRecorder && overview,
);
if (workflowAnswerTraceId && capabilityGrant && answerTraceRecorder && overview) {
const finishReason =
result.items.length > 0 ? "retrieval-evidence" : "no-retrieval-evidence";
@ -297,6 +309,9 @@ export function registerRetrievalTestHandlers({
}
: {}),
});
// One `query.generate` step carries the outcome classification the overview reads plus
// everything the retrieval history needs to show the run like a console retrieval test:
// the evidence bundle (counts, scores, evidence view), stages, plan, and publication.
await answerTraceRecorder.record({
capabilityGrantId: capabilityGrant.grantId,
knowledgeSpaceId,
@ -305,12 +320,20 @@ export function registerRetrievalTestHandlers({
...(resolvedQueryImages.length > 0
? { queryImages: resolvedQueryImages.map(queryImageMetadata) }
: {}),
source,
steps: [
{
metadata: {
...outcomeMetadata,
...(result.evidenceBundle ? { evidenceBundle: result.evidenceBundle } : {}),
plan: result.plan,
projectionSnapshot: queryProjectionSnapshotMetadata(
runtimeSnapshot.projectionSnapshot,
),
queryOutcome: classification.outcome,
...(classification.trigger ? { failedQueryTrigger: classification.trigger } : {}),
resultCount: result.items.length,
stages: result.stages,
},
name: "query.generate",
status: "ok",
@ -327,6 +350,10 @@ export function registerRetrievalTestHandlers({
: "disabled";
const response = RetrievalTestResponseSchema.parse({
// Lets the caller attach a later failed-retrieval capture to this history record.
...(recordsTrace && workflowAnswerTraceId
? { answerTraceId: workflowAnswerTraceId }
: {}),
capabilityStatus: {
embedding: embeddingCapabilityStatus,
reasoning: "verified" as const,

View File

@ -204,6 +204,8 @@ export const RetrievalTestMetricsSchema = z
export const RetrievalTestResponseSchema = z
.object({
/** AnswerTrace recorded for this run in the space's retrieval history, when one was written. */
answerTraceId: z.string().uuid().optional(),
capabilityStatus: z
.object({
embedding: z.enum(["not-required", "verified"]),

View File

@ -231,6 +231,9 @@ describe("createRetrievalTestExecutor", () => {
sources: ["dense", "fts"],
},
]);
// This fixture's node/document ids are not UUIDs, so no history bundle can be assembled; the
// diagnostics are unaffected. See the dedicated evidence bundle test for the real shape.
expect(result.evidenceBundle).toBeUndefined();
expect(JSON.stringify(result)).not.toContain("secret candidate text");
expect(stageStatuses(result)).toMatchObject({
dense: "executed",
@ -734,3 +737,78 @@ function stageStatuses(
) {
return Object.fromEntries(result.stages.map((stage) => [stage.name, stage.status]));
}
describe("retrieval test history evidence", () => {
it("assembles the same EvidenceBundle a query would produce from the raw retrieval", async () => {
const nodeId = "018f0d60-7a49-7cc2-9c1b-5b36f18f2c61";
const documentAssetId = "018f0d60-7a49-7cc2-9c1b-5b36f18f2c62";
const embeddings: EmbeddingProvider = {
embed: async () => ({
dense: [[0.1, 0.2, 0.3]],
metadata: { dimension: 3, model: embeddingSelection.model, provider: "dify-model-runtime" },
model: embeddingSelection.model,
}),
kind: "dify-model-runtime",
models: async () => [],
};
const planner = createRetrievalPlanner({ maxTopK: 100 });
const retriever: BasicHybridRetriever = {
retrieve: async (input) => ({
items: [
{
citation: {
artifactHash: "a".repeat(64),
documentAssetId,
documentVersion: 1,
sectionPath: ["Sensor"],
},
metadata: { text: "camera sensor evidence text" },
nodeId,
permissionScope: ["tenant:tenant-1"],
projectionIds: ["projection-1"],
score: 0.8,
sources: ["dense", "fts"],
},
],
metrics: ordinaryMetrics({ rerank: true }),
plan: planner.plan({
hasQueryImages: false,
mode: "fast",
query: input.query,
topK: input.topK,
}),
}),
};
const executor = createRetrievalTestExecutor({
embeddingModel: embeddingSelection.model,
embeddings,
retriever,
});
const result = await executor.execute({
embeddingProfile,
// Text is withheld from the diagnostics, but the history bundle still carries evidence.
includeText: false,
knowledgeSpaceId: SPACE_ID,
mode: "fast",
permissionScope: ["tenant:tenant-1", "subject:owner-1"],
projectionSnapshot,
query: "camera sensor evidence",
retrievalProfile,
subject,
traceId: "trace-history",
});
expect(result.items[0]?.text).toBeUndefined();
expect(result.evidenceBundle).toMatchObject({
query: "camera sensor evidence",
state: expect.any(String),
});
expect(result.evidenceBundle?.items).toHaveLength(1);
expect(result.evidenceBundle?.items[0]).toMatchObject({
citations: [expect.objectContaining({ documentAssetId })],
nodeId,
text: "camera sensor evidence text",
});
});
});

View File

@ -1,5 +1,6 @@
import {
type AuthSubject,
type EvidenceBundle,
type KnowledgeSpaceEmbeddingProfile,
type KnowledgeSpaceModelSelection,
type KnowledgeSpaceRetrievalProfile,
@ -9,6 +10,7 @@ import type { EmbeddingProvider } from "@knowledge/embeddings";
import { runWithAbortSignal } from "./bounded-concurrency";
import { candidatePermissionScopeAllows } from "./candidate-content-authorization";
import { createEvidenceBundleAssembler } from "./evidence-bundle-assembler";
import {
type KnowledgeSpaceEmbeddingResolver,
assertEmbeddingModelMatchesProfile,
@ -30,6 +32,7 @@ import {
QUERY_IMAGE_MAX_COUNT,
QueryImageDegradationReasonSchema,
type ResolvedQueryImage,
queryImageMetadata,
} from "./query-images";
import type { RetrievalMetadataFilters } from "./retrieval-candidates";
import type { RetrievalSource } from "./retrieval-candidates";
@ -69,6 +72,12 @@ export interface RetrievalTestStage {
}
export interface RetrievalTestResult {
/**
* The same EvidenceBundle a query would have produced from this retrieval, so the run can be
* recorded as an AnswerTrace with evidence for the retrieval history. Absent when the bundle
* could not be assembled; the diagnostics above are unaffected.
*/
readonly evidenceBundle?: EvidenceBundle | undefined;
readonly items: readonly {
readonly citation: {
readonly artifactHash: string;
@ -294,7 +303,13 @@ export function createRetrievalTestExecutor({
permissionScope: input.permissionScope,
profile: input.retrievalProfile,
});
const evidenceBundle = assembleRetrievalTestEvidenceBundle({
query: preparedQuery.query || input.query,
queryImages: preparedQuery.queryImages,
retrieval,
});
return {
...(evidenceBundle ? { evidenceBundle } : {}),
items: retrieval.items.map((item) =>
safeRetrievalTestItem(item, input.includeText === true),
),
@ -391,6 +406,37 @@ async function resolveRetrievalTestEmbedding({
return [...vector];
}
const retrievalTestEvidenceBundleAssembler = createEvidenceBundleAssembler();
/**
* Build the history EvidenceBundle for a retrieval test. Assembly validates every evidence item,
* so a retrieval whose items cannot be projected (for example text-less legacy nodes) yields no
* bundle rather than failing the diagnostics the caller asked for.
*/
function assembleRetrievalTestEvidenceBundle({
query,
queryImages,
retrieval,
}: {
readonly query: string;
readonly queryImages: readonly ResolvedQueryImage[];
readonly retrieval: Parameters<EvidenceBundleAssemblerAssemble>[0]["retrieval"];
}): EvidenceBundle | undefined {
const images = queryImages.map(queryImageMetadata);
if (!query.trim() && images.length === 0) return undefined;
try {
return retrievalTestEvidenceBundleAssembler.assemble({
query,
...(images.length > 0 ? { queryImages: images } : {}),
retrieval,
});
} catch {
return undefined;
}
}
type EvidenceBundleAssemblerAssemble = ReturnType<typeof createEvidenceBundleAssembler>["assemble"];
async function prepareRetrievalTestQuery({
input,
provider,

View File

@ -9,6 +9,7 @@ const SPACE_ID = "10000000-0000-4000-8000-000000000001";
const EVENT_ID = "10000000-0000-4000-8000-000000000002";
const FIRST_GRANT_ID = "10000000-0000-4000-8000-000000000003";
const RETRY_GRANT_ID = "10000000-0000-4000-8000-000000000004";
const RETRIEVAL_TRACE_ID = "10000000-0000-4000-8000-000000000005";
function baseInput(capabilityGrantId = FIRST_GRANT_ID) {
return {
@ -101,3 +102,49 @@ describe("workflow failed-retrieval capture", () => {
},
);
});
describe("workflow failed-retrieval history linkage", () => {
it("attaches the capture to the retrieval-test trace the node reports instead of a second record", async () => {
const { answerTraces, createBadCase, failedQueries, service } = setup("retrieval-miss");
const retrievalTrace = await createAnswerTraceRecorder({
now: () => "2026-08-12T00:00:00.000Z",
repository: answerTraces,
}).record({
capabilityGrantId: FIRST_GRANT_ID,
knowledgeSpaceId: SPACE_ID,
mode: "deep",
query: "发票号码在哪里?",
source: "workflow",
steps: [{ metadata: { resultCount: 0 }, name: "retrieval.test", status: "ok" }],
tenantId: "tenant-1",
traceId: RETRIEVAL_TRACE_ID,
});
const result = await service.capture({ ...baseInput(), retrievalTraceId: retrievalTrace.id });
expect(result.verdict).toBe("retrieval-miss");
expect(await answerTraces.getById(EVENT_ID)).toBeNull();
const failedQuery = await failedQueries.get({
candidateGrants: ["tenant:tenant-1"],
id: EVENT_ID,
knowledgeSpaceId: SPACE_ID,
subjectId: "dify-app:workflow-app",
tenantId: "tenant-1",
});
expect(failedQuery?.answerTraceId).toBe(RETRIEVAL_TRACE_ID);
expect(createBadCase).toHaveBeenCalledWith(
expect.objectContaining({ id: EVENT_ID, traceId: RETRIEVAL_TRACE_ID }),
);
});
it("falls back to a workflow-sourced trace keyed by the event when only a transport id is known", async () => {
const { answerTraces, service } = setup("coverage-gap");
await service.capture(baseInput());
expect(await answerTraces.getById(EVENT_ID)).toMatchObject({
id: EVENT_ID,
source: "workflow",
});
});
});

View File

@ -77,17 +77,16 @@ export function createWorkflowFailedRetrievalCaptureService({
};
let failedQuery = await failedQueries.get(lookup);
let traceCapabilityGrantId = input.capabilityGrantId;
let answerTraceId = failedQuery?.answerTraceId ?? input.eventId;
if (!failedQuery) {
traceCapabilityGrantId = await ensureWorkflowAnswerTrace(
answerTraces,
answerTraceRecorder,
input,
);
const trace = await ensureWorkflowAnswerTrace(answerTraces, answerTraceRecorder, input);
traceCapabilityGrantId = trace.capabilityGrantId;
answerTraceId = trace.id;
}
failedQuery = await failedQueries.captureWorkflowFailedRetrieval({
actorSubjectId: input.actorSubjectId,
answerTraceId: input.eventId,
answerTraceId,
candidateGrants: input.candidateGrants,
capabilityGrantId: input.capabilityGrantId,
id: input.eventId,
@ -141,18 +140,36 @@ export function createWorkflowFailedRetrievalCaptureService({
"Workflow retrieval returned no evidence even though the knowledge base appears to contain relevant answer material.",
tags: ["workflow", "auto-captured", "retrieval-miss"],
tenantId: input.tenantId,
traceId: input.eventId,
traceId: answerTraceId,
});
return { badCaseId: badCase.id, failedQueryId: failedQuery.id, verdict };
},
};
}
/**
* Resolve the AnswerTrace a failed workflow retrieval belongs to.
*
* The retrieval-tests route already records one trace per workflow run and returns its id as
* `answerTraceId`, which the node forwards as `retrievalTraceId`; attaching the failed query to
* that trace keeps one history record per retrieval and lets the overview classify the query as
* "no evidence". Older nodes send an opaque transport trace id instead, in which case a minimal
* trace keyed by the capture event id is created (or replayed) as before.
*/
async function ensureWorkflowAnswerTrace(
answerTraces: Pick<AnswerTraceRepository, "get">,
recorder: AnswerTraceRecorder,
input: CaptureWorkflowFailedRetrievalInput,
): Promise<string> {
): Promise<{ readonly capabilityGrantId: string; readonly id: string }> {
if (UUID_PATTERN.test(input.retrievalTraceId) && input.retrievalTraceId !== input.eventId) {
const retrievalTrace = await answerTraces.get({
id: input.retrievalTraceId,
knowledgeSpaceId: input.knowledgeSpaceId,
});
if (retrievalTrace?.capabilityGrantId && retrievalTrace.query === input.query) {
return { capabilityGrantId: retrievalTrace.capabilityGrantId, id: retrievalTrace.id };
}
}
const existing = await answerTraces.get({
id: input.eventId,
knowledgeSpaceId: input.knowledgeSpaceId,
@ -162,13 +179,14 @@ async function ensureWorkflowAnswerTrace(
if (!existing.capabilityGrantId) {
throw new WorkflowFailedRetrievalReplayConflictError(input.eventId);
}
return existing.capabilityGrantId;
return { capabilityGrantId: existing.capabilityGrantId, id: existing.id };
}
const recorded = await recorder.record({
capabilityGrantId: input.capabilityGrantId,
knowledgeSpaceId: input.knowledgeSpaceId,
mode: input.mode,
query: input.query,
source: "workflow",
steps: [
{
metadata: {
@ -188,9 +206,11 @@ async function ensureWorkflowAnswerTrace(
if (!recorded.capabilityGrantId) {
throw new Error("Workflow failed-retrieval AnswerTrace lost Capability provenance");
}
return recorded.capabilityGrantId;
return { capabilityGrantId: recorded.capabilityGrantId, id: recorded.id };
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
function assertWorkflowAnswerTraceReplay(
existing: AnswerTrace,
input: CaptureWorkflowFailedRetrievalInput,

View File

@ -1533,6 +1533,25 @@ export const AnswerTraceStepSchema = z.object({
});
export type AnswerTraceStep = z.infer<typeof AnswerTraceStepSchema>;
/**
* Which caller produced an AnswerTrace. Console retrieval tests, KnowledgeFS workflow nodes,
* service API clients, agents, and MCP clients all record traces into the same history, and the
* console distinguishes them by this value. Rows written before the column existed are
* `retrieval_test`.
*/
export const AnswerTraceSourceSchema = z.enum([
"retrieval_test",
"workflow",
"service_api",
"agent",
"mcp",
]);
export type AnswerTraceSource = z.infer<typeof AnswerTraceSourceSchema>;
export function answerTraceSourceOf(trace: { readonly source?: AnswerTraceSource | undefined }) {
return trace.source ?? "retrieval_test";
}
export const AnswerTraceSchema = z.object({
/** Durable Capability v2 provenance; mutually exclusive with legacy member snapshots. */
capabilityGrantId: UuidSchema.optional(),
@ -1550,6 +1569,8 @@ export const AnswerTraceSchema = z.object({
.optional(),
query: z.string(),
queryImages: z.array(QueryImageMetadataSchema).max(4).optional(),
/** Absent on legacy rows; read it through `answerTraceSourceOf`. */
source: AnswerTraceSourceSchema.optional(),
/** Authenticated creator. Legacy traces without it are intentionally unreadable via the API. */
subjectId: z.string().min(1).max(255).optional(),
steps: z.array(AnswerTraceStepSchema),

View File

@ -0,0 +1,11 @@
-- Knowledge Platform schema migration
-- Migration id: 0053_answer_trace_source
-- Dialect: postgres
-- Answer traces record which caller produced them (console retrieval test, workflow node, service
-- API, agent, MCP) so retrieval history can distinguish and filter them. NULL means retrieval_test.
ALTER TABLE "answer_traces"
ADD COLUMN IF NOT EXISTS "source" TEXT;
CREATE INDEX IF NOT EXISTS "answer_traces_space_source_created_idx"
ON "answer_traces" ("knowledge_space_id", "source", "created_at", "id");

View File

@ -0,0 +1,11 @@
-- Knowledge Platform schema migration
-- Migration id: 0053_answer_trace_source
-- Dialect: tidb
-- Answer traces record which caller produced them (console retrieval test, workflow node, service
-- API, agent, MCP) so retrieval history can distinguish and filter them. NULL means retrieval_test.
ALTER TABLE `answer_traces`
ADD COLUMN IF NOT EXISTS `source` VARCHAR(32) NULL;
CREATE INDEX IF NOT EXISTS `answer_traces_space_source_created_idx`
ON `answer_traces` (`knowledge_space_id`, `source`, `created_at`, `id`);

View File

@ -107,4 +107,6 @@ export const migrationArtifacts = [
{ content: "-- Knowledge Platform schema migration\n-- Migration id: 0051_failed_query_trace_outcome_index\n-- Dialect: tidb\n-- Supports Overview outcome joins by exact trace and trigger without scanning space history.\n\nCREATE INDEX IF NOT EXISTS `failed_queries_trace_trigger_idx`\n ON `failed_queries` (\n `tenant_id`, `knowledge_space_id`, `answer_trace_id`\n );\n", path: "packages/database/migrations/0051_failed_query_trace_outcome_index.tidb.sql" },
{ content: "-- Knowledge Platform schema migration\n-- Migration id: 0052_namespace_source_previews\n-- Dialect: postgres\n\nCREATE TABLE IF NOT EXISTS \"namespace_source_preview_jobs\" (\n \"id\" uuid PRIMARY KEY,\n \"tenant_id\" varchar(255) NOT NULL,\n \"account_id\" varchar(255) NOT NULL,\n \"status\" varchar(32) NOT NULL,\n \"provider_config\" jsonb NOT NULL,\n \"configuration_fingerprint\" varchar(128) NOT NULL,\n \"expires_at\" timestamptz NOT NULL,\n \"consumed_at\" timestamptz NULL,\n \"content_cleaned_at\" timestamptz NULL,\n \"import_workflow_id\" uuid NULL,\n \"error_code\" varchar(128) NULL,\n \"created_at\" timestamptz NOT NULL,\n \"updated_at\" timestamptz NOT NULL\n);\nCREATE INDEX IF NOT EXISTS \"namespace_source_preview_jobs_claim_idx\"\n ON \"namespace_source_preview_jobs\" (\"status\", \"expires_at\", \"created_at\");\nCREATE INDEX IF NOT EXISTS \"namespace_source_preview_jobs_owner_idx\"\n ON \"namespace_source_preview_jobs\" (\"tenant_id\", \"account_id\", \"created_at\");\nCREATE INDEX IF NOT EXISTS \"namespace_source_preview_jobs_cleanup_idx\"\n ON \"namespace_source_preview_jobs\" (\"status\", \"content_cleaned_at\", \"updated_at\");\n\nCREATE TABLE IF NOT EXISTS \"namespace_source_preview_pages\" (\n \"job_id\" uuid NOT NULL REFERENCES \"namespace_source_preview_jobs\" (\"id\") ON DELETE CASCADE,\n \"page_id\" varchar(128) NOT NULL,\n \"source_url\" varchar(4096) NOT NULL,\n \"title\" varchar(500) NULL,\n \"description\" text NULL,\n \"content_hash\" varchar(64) NOT NULL,\n \"content_object_key\" varchar(2048) NOT NULL,\n \"created_at\" timestamptz NOT NULL,\n PRIMARY KEY (\"job_id\", \"page_id\")\n);\n", path: "packages/database/migrations/0052_namespace_source_previews.postgres.sql" },
{ content: "-- Knowledge Platform schema migration\n-- Migration id: 0052_namespace_source_previews\n-- Dialect: tidb\n\nCREATE TABLE IF NOT EXISTS `namespace_source_preview_jobs` (\n `id` char(36) PRIMARY KEY,\n `tenant_id` varchar(255) NOT NULL,\n `account_id` varchar(255) NOT NULL,\n `status` varchar(32) NOT NULL,\n `provider_config` json NOT NULL,\n `configuration_fingerprint` varchar(128) NOT NULL,\n `expires_at` datetime(6) NOT NULL,\n `consumed_at` datetime(6) NULL,\n `content_cleaned_at` datetime(6) NULL,\n `import_workflow_id` char(36) NULL,\n `error_code` varchar(128) NULL,\n `created_at` datetime(6) NOT NULL,\n `updated_at` datetime(6) NOT NULL,\n INDEX `namespace_source_preview_jobs_claim_idx` (`status`, `expires_at`, `created_at`),\n INDEX `namespace_source_preview_jobs_owner_idx` (`tenant_id`, `account_id`, `created_at`),\n INDEX `namespace_source_preview_jobs_cleanup_idx` (`status`, `content_cleaned_at`, `updated_at`)\n);\n\nCREATE TABLE IF NOT EXISTS `namespace_source_preview_pages` (\n `job_id` char(36) NOT NULL,\n `page_id` varchar(128) NOT NULL,\n `source_url` varchar(4096) NOT NULL,\n `title` varchar(500) NULL,\n `description` text NULL,\n `content_hash` varchar(64) NOT NULL,\n `content_object_key` varchar(2048) NOT NULL,\n `created_at` datetime(6) NOT NULL,\n PRIMARY KEY (`job_id`, `page_id`),\n CONSTRAINT `namespace_source_preview_pages_job_fk` FOREIGN KEY (`job_id`) REFERENCES `namespace_source_preview_jobs` (`id`) ON DELETE CASCADE\n);\n", path: "packages/database/migrations/0052_namespace_source_previews.tidb.sql" },
{ content: "-- Knowledge Platform schema migration\n-- Migration id: 0053_answer_trace_source\n-- Dialect: postgres\n-- Answer traces record which caller produced them (console retrieval test, workflow node, service\n-- API, agent, MCP) so retrieval history can distinguish and filter them. NULL means retrieval_test.\n\nALTER TABLE \"answer_traces\"\n ADD COLUMN IF NOT EXISTS \"source\" TEXT;\n\nCREATE INDEX IF NOT EXISTS \"answer_traces_space_source_created_idx\"\n ON \"answer_traces\" (\"knowledge_space_id\", \"source\", \"created_at\", \"id\");\n", path: "packages/database/migrations/0053_answer_trace_source.postgres.sql" },
{ content: "-- Knowledge Platform schema migration\n-- Migration id: 0053_answer_trace_source\n-- Dialect: tidb\n-- Answer traces record which caller produced them (console retrieval test, workflow node, service\n-- API, agent, MCP) so retrieval history can distinguish and filter them. NULL means retrieval_test.\n\nALTER TABLE `answer_traces`\n ADD COLUMN IF NOT EXISTS `source` VARCHAR(32) NULL;\n\nCREATE INDEX IF NOT EXISTS `answer_traces_space_source_created_idx`\n ON `answer_traces` (`knowledge_space_id`, `source`, `created_at`, `id`);\n", path: "packages/database/migrations/0053_answer_trace_source.tidb.sql" },
] as const satisfies readonly MigrationArtifact[];

View File

@ -164,6 +164,8 @@ describe("migration file rendering", () => {
"packages/database/migrations/0051_failed_query_trace_outcome_index.tidb.sql",
"packages/database/migrations/0052_namespace_source_previews.postgres.sql",
"packages/database/migrations/0052_namespace_source_previews.tidb.sql",
"packages/database/migrations/0053_answer_trace_source.postgres.sql",
"packages/database/migrations/0053_answer_trace_source.tidb.sql",
]);
const workflowCapturePostgres = artifacts.find(
(artifact) =>
@ -946,6 +948,7 @@ describe("migration file rendering", () => {
"packages/database/migrations/0050_deletion_job_capability_provenance.postgres.sql",
"packages/database/migrations/0051_failed_query_trace_outcome_index.postgres.sql",
"packages/database/migrations/0052_namespace_source_previews.postgres.sql",
"packages/database/migrations/0053_answer_trace_source.postgres.sql",
]);
expect(
getPendingMigrationArtifacts({
@ -1002,6 +1005,7 @@ describe("migration file rendering", () => {
"0050_deletion_job_capability_provenance",
"0051_failed_query_trace_outcome_index",
"0052_namespace_source_previews",
"0053_answer_trace_source",
],
dialect: "postgres",
}),

View File

@ -1197,6 +1197,7 @@ export type KnowledgeFsAnswerTraceResponse = {
mode: 'auto' | 'deep' | 'fast' | 'research'
query: string
query_images?: Array<KnowledgeFsQueryImageResponse>
source?: KnowledgeFsTraceSource
steps: Array<KnowledgeFsAnswerTraceStepResponse>
}
@ -2164,9 +2165,12 @@ export type KnowledgeFsTraceResponse = {
query_images?: Array<KnowledgeFsQueryImageResponse>
result_count: number
scores: KnowledgeFsTraceScoresResponse
source?: KnowledgeFsTraceSource
stages: Array<KnowledgeFsTraceStageResponse>
}
export type KnowledgeFsTraceSource = 'agent' | 'mcp' | 'retrieval_test' | 'service_api' | 'workflow'
export type KnowledgeFsAnswerTraceStepResponse = {
ended_at?: string | null
metadata: {
@ -4404,6 +4408,7 @@ export type GetKnowledgeFsSpacesByControlSpaceIdTracesData = {
}
query?: {
cursor?: string
source?: 'agent' | 'mcp' | 'retrieval_test' | 'service_api' | 'workflow'
}
url: '/knowledge-fs/spaces/{control_space_id}/traces'
}

View File

@ -2159,6 +2159,14 @@ export const zKnowledgeFsSpaceListResponse = z.object({
page: z.int(),
})
export const zKnowledgeFsTraceSource = z.enum([
'agent',
'mcp',
'retrieval_test',
'service_api',
'workflow',
])
/**
* KnowledgeFSAnswerTraceStepResponse
*/
@ -2181,6 +2189,7 @@ export const zKnowledgeFsAnswerTraceResponse = z.object({
mode: z.enum(['auto', 'deep', 'fast', 'research']),
query: z.string(),
query_images: z.array(zKnowledgeFsQueryImageResponse).optional(),
source: zKnowledgeFsTraceSource.optional().default('retrieval_test'),
steps: z.array(zKnowledgeFsAnswerTraceStepResponse),
})
@ -3060,6 +3069,7 @@ export const zKnowledgeFsTraceResponse = z.object({
query_images: z.array(zKnowledgeFsQueryImageResponse).optional(),
result_count: z.int().gte(0),
scores: zKnowledgeFsTraceScoresResponse,
source: zKnowledgeFsTraceSource.optional().default('retrieval_test'),
stages: z.array(zKnowledgeFsTraceStageResponse),
})
@ -4651,6 +4661,7 @@ export const zGetKnowledgeFsSpacesByControlSpaceIdTracesPath = z.object({
export const zGetKnowledgeFsSpacesByControlSpaceIdTracesQuery = z.object({
cursor: z.string().min(1).max(1000).optional(),
source: z.enum(['agent', 'mcp', 'retrieval_test', 'service_api', 'workflow']).optional(),
})
/**

View File

@ -1269,6 +1269,7 @@ export type KnowledgeFsAnswerTraceResponse = {
mode: 'auto' | 'deep' | 'fast' | 'research'
query: string
query_images?: Array<KnowledgeFsQueryImageResponse>
source?: KnowledgeFsTraceSource
steps: Array<KnowledgeFsAnswerTraceStepResponse>
}
@ -2487,6 +2488,7 @@ export type KnowledgeFsTraceResponse = {
query_images?: Array<KnowledgeFsQueryImageResponse>
result_count: number
scores: KnowledgeFsTraceScoresResponse
source?: KnowledgeFsTraceSource
stages: Array<KnowledgeFsTraceStageResponse>
}
@ -2496,6 +2498,8 @@ export type KnowledgeFsTraceScoresResponse = {
retrieval?: number | null
}
export type KnowledgeFsTraceSource = 'agent' | 'mcp' | 'retrieval_test' | 'service_api' | 'workflow'
export type KnowledgeFsTraceStageResponse = {
candidate_count?: number | null
name: string
@ -6048,6 +6052,7 @@ export type GetKnowledgeFsSpacesByControlSpaceIdTracesData = {
}
query?: {
cursor?: string
source?: 'agent' | 'mcp' | 'retrieval_test' | 'service_api' | 'workflow'
}
url: '/knowledge-fs/spaces/{control_space_id}/traces'
}

View File

@ -2047,20 +2047,6 @@ export const zKnowledgeFsQueryImageResponse = z.object({
upload_file_id: z.string(),
})
/**
* KnowledgeFSAnswerTraceResponse
*/
export const zKnowledgeFsAnswerTraceResponse = z.object({
created_at: z.iso.datetime(),
evidence_bundle_id: z.string().nullish(),
id: z.string(),
knowledge_space_id: z.string(),
mode: z.enum(['auto', 'deep', 'fast', 'research']),
query: z.string(),
query_images: z.array(zKnowledgeFsQueryImageResponse).optional(),
steps: z.array(zKnowledgeFsAnswerTraceStepResponse),
})
/**
* KnowledgeFSReadinessCapabilities
*/
@ -2905,6 +2891,29 @@ export const zKnowledgeFsTraceScoresResponse = z.object({
retrieval: z.number().gte(0).lte(1).nullish(),
})
export const zKnowledgeFsTraceSource = z.enum([
'agent',
'mcp',
'retrieval_test',
'service_api',
'workflow',
])
/**
* KnowledgeFSAnswerTraceResponse
*/
export const zKnowledgeFsAnswerTraceResponse = z.object({
created_at: z.iso.datetime(),
evidence_bundle_id: z.string().nullish(),
id: z.string(),
knowledge_space_id: z.string(),
mode: z.enum(['auto', 'deep', 'fast', 'research']),
query: z.string(),
query_images: z.array(zKnowledgeFsQueryImageResponse).optional(),
source: zKnowledgeFsTraceSource.optional().default('retrieval_test'),
steps: z.array(zKnowledgeFsAnswerTraceStepResponse),
})
/**
* KnowledgeFSTraceStageResponse
*/
@ -2932,6 +2941,7 @@ export const zKnowledgeFsTraceResponse = z.object({
query_images: z.array(zKnowledgeFsQueryImageResponse).optional(),
result_count: z.int().gte(0),
scores: zKnowledgeFsTraceScoresResponse,
source: zKnowledgeFsTraceSource.optional().default('retrieval_test'),
stages: z.array(zKnowledgeFsTraceStageResponse),
})
@ -5958,6 +5968,7 @@ export const zGetKnowledgeFsSpacesByControlSpaceIdTracesPath = z.object({
export const zGetKnowledgeFsSpacesByControlSpaceIdTracesQuery = z.object({
cursor: z.string().min(1).max(1000).optional(),
source: z.enum(['agent', 'mcp', 'retrieval_test', 'service_api', 'workflow']).optional(),
})
/**

View File

@ -264,3 +264,35 @@ describe('retrieval test model', () => {
).toBe(false)
})
})
describe('retrieval record sources', () => {
it('keeps the trace source and defaults legacy traces to retrieval tests', () => {
const [workflow, legacy] = retrievalTestRecords(
[
{
completed: true,
created_at: '2026-09-04T10:00:00.000Z',
id: 'trace-workflow',
mode: 'fast',
query: 'From a workflow node',
result_count: 3,
source: 'workflow',
} as KnowledgeFsTraceResponse,
{
completed: true,
created_at: '2026-09-04T09:00:00.000Z',
id: 'trace-legacy',
mode: 'fast',
query: 'From the console',
result_count: 1,
} as KnowledgeFsTraceResponse,
],
[],
)
expect(workflow).toEqual(expect.objectContaining({ id: 'trace-workflow', source: 'workflow' }))
expect(legacy).toEqual(
expect.objectContaining({ id: 'trace-legacy', source: 'retrieval_test' }),
)
})
})

View File

@ -1,6 +1,8 @@
'use client'
import type { RetrievalHistorySourceFilter } from './state/scoped'
import { Button } from '@langgenius/dify-ui/button'
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { RecordButton } from './history'
@ -9,20 +11,40 @@ import {
retrievalHistoryFactsAtom,
selectRetrievalRecordAtom,
} from './state/graph'
import { retrievalHistorySourceFilterAtom } from './state/scoped'
const sourceFilters = ['all', 'retrieval_test', 'workflow'] as const
export function RetrievalHistoryPanel() {
const { t } = useTranslation('knowledgeSpace')
const { activeRecordKey, displayRecords, hasNextPage, isFetchingNextPage } =
const { activeRecordKey, displayRecords, hasNextPage, isFetchingNextPage, sourceFilter } =
useAtomValue(retrievalHistoryFactsAtom)
const selectRecord = useSetAtom(selectRetrievalRecordAtom)
const loadMore = useSetAtom(loadMoreRetrievalHistoryAtom)
const setSourceFilter = useSetAtom(retrievalHistorySourceFilterAtom)
return (
<div className="mt-3 flex min-h-0 flex-1 flex-col pt-6">
<div className="flex shrink-0 items-center pb-2 pl-3">
<div className="flex shrink-0 items-center justify-between gap-2 pr-3 pb-2 pl-3">
<h2 className="system-xs-medium text-text-tertiary">
{t(($) => $['retrievalTest.records'])}
</h2>
<SegmentedControl<RetrievalHistorySourceFilter>
aria-label={t(($) => $['retrievalTest.sourceFilter.label'])}
className="shrink-0"
value={sourceFilter}
onValueChange={setSourceFilter}
>
{sourceFilters.map((filter) => (
<SegmentedControlItem<RetrievalHistorySourceFilter>
key={filter}
value={filter}
className="px-1.5 py-0.5 system-2xs-medium"
>
{t(($) => $[`retrievalTest.sourceFilter.${filter}`])}
</SegmentedControlItem>
))}
</SegmentedControl>
</div>
<div className="min-h-0 flex-1 scrollbar-none overflow-y-auto">
{displayRecords.length > 0 ? (

View File

@ -575,6 +575,14 @@ export function RecordButton({
{queryImageCount}
</span>
)}
{record.kind === 'trace' && record.source !== 'retrieval_test' && (
<span
className="inline-flex shrink-0 items-center rounded-[5px] border border-divider-deep px-1 system-2xs-medium-uppercase text-text-tertiary"
data-testid="retrieval-record-source"
>
{t(($) => $[`retrievalTest.source.${record.source}`])}
</span>
)}
</span>
<span className="mt-1.5 flex items-center gap-1 system-xs-regular text-text-tertiary">
<span className={cn('min-w-0 flex-1 truncate', failed && 'text-text-destructive')}>

View File

@ -2,8 +2,16 @@ import type {
KnowledgeFsQueryImageResponse,
KnowledgeFsResearchTaskResponse,
KnowledgeFsTraceResponse,
KnowledgeFsTraceSource,
} from '@dify/contracts/api/console/knowledge-fs/types.gen'
/** Who produced a persisted trace: the console retrieval test or another caller. */
export type RetrievalTestSource = KnowledgeFsTraceSource
export function retrievalTestSource(source?: KnowledgeFsTraceSource | null): RetrievalTestSource {
return source ?? 'retrieval_test'
}
export type RetrievalTestMode = 'deep' | 'fast' | 'research'
export function normalizedRetrievalTestMode(mode?: string): RetrievalTestMode {
@ -74,6 +82,8 @@ export type RetrievalTestRecord =
query: string
queryImages?: RetrievalQueryImage[]
resultCount?: number
/** Console retrieval test, workflow node, service API, agent, or MCP. */
source: RetrievalTestSource
status: 'completed' | 'failed'
}
| {
@ -398,6 +408,7 @@ export function retrievalTestRecords(
...(trace.result_count !== null && trace.result_count !== undefined
? { resultCount: trace.result_count }
: {}),
source: retrievalTestSource(trace.source),
status: trace.completed ? 'completed' : 'failed',
})),
...researchTasks.map((task): RetrievalTestRecord => ({

View File

@ -24,6 +24,7 @@ import {
retrievalAdmittedResearchTasksAtom,
retrievalComposerDraftAtom,
retrievalComposerImagesAtom,
retrievalHistorySourceFilterAtom,
retrievalLocalRunAtom,
retrievalLocalSelectedAtom,
retrievalRecordImagesAtom,
@ -38,10 +39,17 @@ const tracesQueryAtom = atomWithInfiniteQuery((get) =>
consoleQuery.knowledgeFs.spaces.byControlSpaceId.traces.get.infiniteOptions({
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
initialPageParam: null as string | null,
input: (pageParam) => ({
params: { control_space_id: get(retrievalKnowledgeSpaceIdAtom) },
...(typeof pageParam === 'string' ? { query: { cursor: pageParam } } : {}),
}),
input: (pageParam) => {
const sourceFilter = get(retrievalHistorySourceFilterAtom)
const query = {
...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
...(sourceFilter === 'all' ? {} : { source: sourceFilter }),
}
return {
params: { control_space_id: get(retrievalKnowledgeSpaceIdAtom) },
...(Object.keys(query).length > 0 ? { query } : {}),
}
},
refetchInterval: get(retrievalLocalRunAtom)?.status === 'running' ? 1000 : false,
}),
)
@ -119,7 +127,14 @@ const retrievalDisplayRecordsAtom = atom((get) => {
localRun?.traceId &&
records.some((record) => record.kind === 'trace' && record.id === localRun.traceId),
)
return localRecord && !traceAlreadyListed ? [localRecord, ...records] : records
const displayRecords = localRecord && !traceAlreadyListed ? [localRecord, ...records] : records
// The server already filters persisted traces by source; local runs and research tasks are
// console retrieval tests, so they only belong to the "all" and "retrieval_test" views.
const sourceFilter = get(retrievalHistorySourceFilterAtom)
if (sourceFilter === 'all') return displayRecords
return displayRecords.filter((record) =>
record.kind === 'trace' ? record.source === sourceFilter : sourceFilter === 'retrieval_test',
)
})
const requestedSelectionAtom = atom((get) => {
@ -440,6 +455,7 @@ export const retrievalHistoryFactsAtom = atom((get) => {
displayRecords,
hasNextPage: Boolean(tracesQuery.hasNextPage || researchTasksQuery.hasNextPage),
isFetchingNextPage: tracesQuery.isFetchingNextPage || researchTasksQuery.isFetchingNextPage,
sourceFilter: get(retrievalHistorySourceFilterAtom),
}
})

View File

@ -62,6 +62,9 @@ export const retrievalAdmittedResearchTasksAtom = atom<
Record<string, KnowledgeFsResearchTaskResponse>
>({})
export const retrievalResearchRetryPendingAtom = atom(false)
/** History filter: every record, only console retrieval tests, or only workflow-node queries. */
export type RetrievalHistorySourceFilter = 'all' | 'retrieval_test' | 'workflow'
export const retrievalHistorySourceFilterAtom = atom<RetrievalHistorySourceFilter>('all')
export const retrievalScopedAtoms = [
retrievalComposerDraftAtom,
@ -73,5 +76,6 @@ export const retrievalScopedAtoms = [
retrievalResearchEventsAtom,
retrievalAdmittedResearchTasksAtom,
retrievalResearchRetryPendingAtom,
retrievalHistorySourceFilterAtom,
retrievalRuntimeBridgeAtom,
] as const

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "تم الحفظ كسؤال مرجعي",
"retrievalTest.score": "الدرجة",
"retrievalTest.showAllChunks": "عرض جميع المقاطع وعددها {{count}}",
"retrievalTest.source.agent": "الوكيل",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "اختبار الاسترجاع",
"retrievalTest.source.service_api": "Service API",
"retrievalTest.source.workflow": "سير العمل",
"retrievalTest.sourceFilter.all": "الكل",
"retrievalTest.sourceFilter.label": "مصدر السجل",
"retrievalTest.sourceFilter.retrieval_test": "اختبارات الاسترجاع",
"retrievalTest.sourceFilter.workflow": "سير العمل",
"retrievalTest.startResearch": "بدء البحث",
"retrievalTest.title": "اختبار الاسترجاع",
"retrievalTest.viewInQuality": "عرض في الجودة",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "Als Referenzfrage gespeichert",
"retrievalTest.score": "Punktzahl",
"retrievalTest.showAllChunks": "Alle {{count}} Abschnitte anzeigen",
"retrievalTest.source.agent": "Agent",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "Abruftest",
"retrievalTest.source.service_api": "Service-API",
"retrievalTest.source.workflow": "Workflow",
"retrievalTest.sourceFilter.all": "Alle",
"retrievalTest.sourceFilter.label": "Quelle des Eintrags",
"retrievalTest.sourceFilter.retrieval_test": "Abruftests",
"retrievalTest.sourceFilter.workflow": "Workflow",
"retrievalTest.startResearch": "Recherche starten",
"retrievalTest.title": "Abruf testen",
"retrievalTest.viewInQuality": "In „Qualität“ anzeigen",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "Saved as golden question",
"retrievalTest.score": "Score",
"retrievalTest.showAllChunks": "Show all {{count}} chunks",
"retrievalTest.source.agent": "Agent",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "Retrieval test",
"retrievalTest.source.service_api": "Service API",
"retrievalTest.source.workflow": "Workflow",
"retrievalTest.sourceFilter.all": "All",
"retrievalTest.sourceFilter.label": "Record source",
"retrievalTest.sourceFilter.retrieval_test": "Retrieval tests",
"retrievalTest.sourceFilter.workflow": "Workflow",
"retrievalTest.startResearch": "Start research",
"retrievalTest.title": "Retrieval Test",
"retrievalTest.viewInQuality": "View in Quality",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "Guardado como pregunta de referencia",
"retrievalTest.score": "Puntuación",
"retrievalTest.showAllChunks": "Mostrar los {{count}} fragmentos",
"retrievalTest.source.agent": "Agente",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "Prueba de recuperación",
"retrievalTest.source.service_api": "API de servicio",
"retrievalTest.source.workflow": "Flujo de trabajo",
"retrievalTest.sourceFilter.all": "Todos",
"retrievalTest.sourceFilter.label": "Origen del registro",
"retrievalTest.sourceFilter.retrieval_test": "Pruebas de recuperación",
"retrievalTest.sourceFilter.workflow": "Flujo de trabajo",
"retrievalTest.startResearch": "Iniciar investigación",
"retrievalTest.title": "Prueba de recuperación",
"retrievalTest.viewInQuality": "Ver en Calidad",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "به‌عنوان پرسش مرجع ذخیره شد",
"retrievalTest.score": "امتیاز",
"retrievalTest.showAllChunks": "نمایش همه {{count}} قطعه",
"retrievalTest.source.agent": "عامل",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "آزمون بازیابی",
"retrievalTest.source.service_api": "Service API",
"retrievalTest.source.workflow": "گردش کار",
"retrievalTest.sourceFilter.all": "همه",
"retrievalTest.sourceFilter.label": "منبع رکورد",
"retrievalTest.sourceFilter.retrieval_test": "آزمون‌های بازیابی",
"retrievalTest.sourceFilter.workflow": "گردش کار",
"retrievalTest.startResearch": "شروع پژوهش",
"retrievalTest.title": "آزمون بازیابی",
"retrievalTest.viewInQuality": "نمایش در بخش کیفیت",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "Enregistré comme question de référence",
"retrievalTest.score": "Score",
"retrievalTest.showAllChunks": "Afficher les {{count}} segments",
"retrievalTest.source.agent": "Agent",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "Test de récupération",
"retrievalTest.source.service_api": "API de service",
"retrievalTest.source.workflow": "Workflow",
"retrievalTest.sourceFilter.all": "Tout",
"retrievalTest.sourceFilter.label": "Origine de l'enregistrement",
"retrievalTest.sourceFilter.retrieval_test": "Tests de récupération",
"retrievalTest.sourceFilter.workflow": "Workflow",
"retrievalTest.startResearch": "Démarrer la recherche",
"retrievalTest.title": "Test de récupération",
"retrievalTest.viewInQuality": "Afficher dans Qualité",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "संदर्भ प्रश्न के रूप में सहेजा गया",
"retrievalTest.score": "स्कोर",
"retrievalTest.showAllChunks": "सभी {{count}} खंड दिखाएँ",
"retrievalTest.source.agent": "एजेंट",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "पुनर्प्राप्ति परीक्षण",
"retrievalTest.source.service_api": "Service API",
"retrievalTest.source.workflow": "वर्कफ़्लो",
"retrievalTest.sourceFilter.all": "सभी",
"retrievalTest.sourceFilter.label": "रिकॉर्ड का स्रोत",
"retrievalTest.sourceFilter.retrieval_test": "पुनर्प्राप्ति परीक्षण",
"retrievalTest.sourceFilter.workflow": "वर्कफ़्लो",
"retrievalTest.startResearch": "रिसर्च शुरू करें",
"retrievalTest.title": "रिट्रीवल परीक्षण",
"retrievalTest.viewInQuality": "गुणवत्ता में देखें",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "Disimpan sebagai pertanyaan referensi",
"retrievalTest.score": "Skor",
"retrievalTest.showAllChunks": "Tampilkan semua {{count}} potongan",
"retrievalTest.source.agent": "Agen",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "Uji pengambilan",
"retrievalTest.source.service_api": "Service API",
"retrievalTest.source.workflow": "Alur kerja",
"retrievalTest.sourceFilter.all": "Semua",
"retrievalTest.sourceFilter.label": "Sumber catatan",
"retrievalTest.sourceFilter.retrieval_test": "Uji pengambilan",
"retrievalTest.sourceFilter.workflow": "Alur kerja",
"retrievalTest.startResearch": "Mulai riset",
"retrievalTest.title": "Uji Pengambilan",
"retrievalTest.viewInQuality": "Lihat di Kualitas",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "Salvato come domanda di riferimento",
"retrievalTest.score": "Punteggio",
"retrievalTest.showAllChunks": "Mostra tutti i {{count}} segmenti",
"retrievalTest.source.agent": "Agente",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "Test di recupero",
"retrievalTest.source.service_api": "API di servizio",
"retrievalTest.source.workflow": "Workflow",
"retrievalTest.sourceFilter.all": "Tutti",
"retrievalTest.sourceFilter.label": "Origine del record",
"retrievalTest.sourceFilter.retrieval_test": "Test di recupero",
"retrievalTest.sourceFilter.workflow": "Workflow",
"retrievalTest.startResearch": "Avvia ricerca",
"retrievalTest.title": "Test di recupero",
"retrievalTest.viewInQuality": "Visualizza in Qualità",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "参考質問として保存しました",
"retrievalTest.score": "スコア",
"retrievalTest.showAllChunks": "全 {{count}} チャンクを表示",
"retrievalTest.source.agent": "エージェント",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "検索テスト",
"retrievalTest.source.service_api": "Service API",
"retrievalTest.source.workflow": "ワークフロー",
"retrievalTest.sourceFilter.all": "すべて",
"retrievalTest.sourceFilter.label": "記録の発生元",
"retrievalTest.sourceFilter.retrieval_test": "検索テスト",
"retrievalTest.sourceFilter.workflow": "ワークフロー",
"retrievalTest.startResearch": "調査を開始",
"retrievalTest.title": "検索テスト",
"retrievalTest.viewInQuality": "品質で表示",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "참고 질문으로 저장됨",
"retrievalTest.score": "점수",
"retrievalTest.showAllChunks": "청크 {{count}}개 모두 표시",
"retrievalTest.source.agent": "에이전트",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "검색 테스트",
"retrievalTest.source.service_api": "Service API",
"retrievalTest.source.workflow": "워크플로",
"retrievalTest.sourceFilter.all": "전체",
"retrievalTest.sourceFilter.label": "기록 출처",
"retrievalTest.sourceFilter.retrieval_test": "검색 테스트",
"retrievalTest.sourceFilter.workflow": "워크플로",
"retrievalTest.startResearch": "리서치 시작",
"retrievalTest.title": "검색 테스트",
"retrievalTest.viewInQuality": "품질에서 보기",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "ບັນທຶກເປັນຄຳຖາມມາດຕະຖານແລ້ວ",
"retrievalTest.score": "ຄະແນນ",
"retrievalTest.showAllChunks": "ສະແດງທັງໝົດ {{count}} ສ່ວນ",
"retrievalTest.source.agent": "ຕົວແທນ",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "ທົດສອບການດຶງຂໍ້ມູນ",
"retrievalTest.source.service_api": "Service API",
"retrievalTest.source.workflow": "ຂັ້ນຕອນການເຮັດວຽກ",
"retrievalTest.sourceFilter.all": "ທັງໝົດ",
"retrievalTest.sourceFilter.label": "ແຫຼ່ງທີ່ມາຂອງບັນທຶກ",
"retrievalTest.sourceFilter.retrieval_test": "ທົດສອບການດຶງຂໍ້ມູນ",
"retrievalTest.sourceFilter.workflow": "ຂັ້ນຕອນການເຮັດວຽກ",
"retrievalTest.startResearch": "ເລີ່ມການຄົ້ນຄວ້າ",
"retrievalTest.title": "ທົດສອບການຄົ້ນຄືນ",
"retrievalTest.viewInQuality": "ເບິ່ງໃນໜ້າຄຸນະພາບ",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "Opgeslagen als referentievraag",
"retrievalTest.score": "Score",
"retrievalTest.showAllChunks": "Alle {{count}} fragmenten tonen",
"retrievalTest.source.agent": "Agent",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "Ophaaltest",
"retrievalTest.source.service_api": "Service-API",
"retrievalTest.source.workflow": "Workflow",
"retrievalTest.sourceFilter.all": "Alle",
"retrievalTest.sourceFilter.label": "Bron van record",
"retrievalTest.sourceFilter.retrieval_test": "Ophaaltests",
"retrievalTest.sourceFilter.workflow": "Workflow",
"retrievalTest.startResearch": "Onderzoek starten",
"retrievalTest.title": "Ophalen testen",
"retrievalTest.viewInQuality": "Bekijken in Kwaliteit",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "Zapisano jako pytanie referencyjne",
"retrievalTest.score": "Wynik",
"retrievalTest.showAllChunks": "Pokaż wszystkie fragmenty ({{count}})",
"retrievalTest.source.agent": "Agent",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "Test wyszukiwania",
"retrievalTest.source.service_api": "API usługi",
"retrievalTest.source.workflow": "Przepływ pracy",
"retrievalTest.sourceFilter.all": "Wszystkie",
"retrievalTest.sourceFilter.label": "Źródło rekordu",
"retrievalTest.sourceFilter.retrieval_test": "Testy wyszukiwania",
"retrievalTest.sourceFilter.workflow": "Przepływ pracy",
"retrievalTest.startResearch": "Rozpocznij badanie",
"retrievalTest.title": "Test wyszukiwania",
"retrievalTest.viewInQuality": "Zobacz w sekcji Jakość",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "Salvo como pergunta de referência",
"retrievalTest.score": "Pontuação",
"retrievalTest.showAllChunks": "Mostrar todos os {{count}} trechos",
"retrievalTest.source.agent": "Agente",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "Teste de recuperação",
"retrievalTest.source.service_api": "API de serviço",
"retrievalTest.source.workflow": "Fluxo de trabalho",
"retrievalTest.sourceFilter.all": "Todos",
"retrievalTest.sourceFilter.label": "Origem do registro",
"retrievalTest.sourceFilter.retrieval_test": "Testes de recuperação",
"retrievalTest.sourceFilter.workflow": "Fluxo de trabalho",
"retrievalTest.startResearch": "Iniciar pesquisa",
"retrievalTest.title": "Teste de recuperação",
"retrievalTest.viewInQuality": "Ver em Qualidade",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "Salvat ca întrebare de referință",
"retrievalTest.score": "Scor",
"retrievalTest.showAllChunks": "Afișează toate cele {{count}} fragmente",
"retrievalTest.source.agent": "Agent",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "Test de regăsire",
"retrievalTest.source.service_api": "API de serviciu",
"retrievalTest.source.workflow": "Flux de lucru",
"retrievalTest.sourceFilter.all": "Toate",
"retrievalTest.sourceFilter.label": "Sursa înregistrării",
"retrievalTest.sourceFilter.retrieval_test": "Teste de regăsire",
"retrievalTest.sourceFilter.workflow": "Flux de lucru",
"retrievalTest.startResearch": "Începe cercetarea",
"retrievalTest.title": "Test de recuperare",
"retrievalTest.viewInQuality": "Vezi în Calitate",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "Сохранено как эталонный вопрос",
"retrievalTest.score": "Оценка",
"retrievalTest.showAllChunks": "Показать все фрагменты ({{count}})",
"retrievalTest.source.agent": "Агент",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "Тест поиска",
"retrievalTest.source.service_api": "Service API",
"retrievalTest.source.workflow": "Рабочий процесс",
"retrievalTest.sourceFilter.all": "Все",
"retrievalTest.sourceFilter.label": "Источник записи",
"retrievalTest.sourceFilter.retrieval_test": "Тесты поиска",
"retrievalTest.sourceFilter.workflow": "Рабочий процесс",
"retrievalTest.startResearch": "Начать исследование",
"retrievalTest.title": "Проверка поиска",
"retrievalTest.viewInQuality": "Открыть в разделе «Качество»",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "Shranjeno kot referenčno vprašanje",
"retrievalTest.score": "Rezultat",
"retrievalTest.showAllChunks": "Prikaži vseh {{count}} odsekov",
"retrievalTest.source.agent": "Agent",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "Preizkus pridobivanja",
"retrievalTest.source.service_api": "Storitveni API",
"retrievalTest.source.workflow": "Potek dela",
"retrievalTest.sourceFilter.all": "Vse",
"retrievalTest.sourceFilter.label": "Vir zapisa",
"retrievalTest.sourceFilter.retrieval_test": "Preizkusi pridobivanja",
"retrievalTest.sourceFilter.workflow": "Potek dela",
"retrievalTest.startResearch": "Začni raziskavo",
"retrievalTest.title": "Preskus pridobivanja",
"retrievalTest.viewInQuality": "Prikaži v razdelku Kakovost",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "บันทึกเป็นคำถามอ้างอิงแล้ว",
"retrievalTest.score": "คะแนน",
"retrievalTest.showAllChunks": "แสดงส่วนเนื้อหาทั้งหมด {{count}} รายการ",
"retrievalTest.source.agent": "เอเจนต์",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "ทดสอบการค้นคืน",
"retrievalTest.source.service_api": "Service API",
"retrievalTest.source.workflow": "เวิร์กโฟลว์",
"retrievalTest.sourceFilter.all": "ทั้งหมด",
"retrievalTest.sourceFilter.label": "ที่มาของบันทึก",
"retrievalTest.sourceFilter.retrieval_test": "ทดสอบการค้นคืน",
"retrievalTest.sourceFilter.workflow": "เวิร์กโฟลว์",
"retrievalTest.startResearch": "เริ่มการวิจัย",
"retrievalTest.title": "ทดสอบการค้นคืน",
"retrievalTest.viewInQuality": "ดูในคุณภาพ",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "Referans sorusu olarak kaydedildi",
"retrievalTest.score": "Puan",
"retrievalTest.showAllChunks": "{{count}} parçanın tümünü göster",
"retrievalTest.source.agent": "Ajan",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "Getirme testi",
"retrievalTest.source.service_api": "Servis API'si",
"retrievalTest.source.workflow": "İş akışı",
"retrievalTest.sourceFilter.all": "Tümü",
"retrievalTest.sourceFilter.label": "Kayıt kaynağı",
"retrievalTest.sourceFilter.retrieval_test": "Getirme testleri",
"retrievalTest.sourceFilter.workflow": "İş akışı",
"retrievalTest.startResearch": "Araştırmayı başlat",
"retrievalTest.title": "Getirme Testi",
"retrievalTest.viewInQuality": "Kalite bölümünde görüntüle",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "Збережено як еталонне запитання",
"retrievalTest.score": "Оцінка",
"retrievalTest.showAllChunks": "Показати всі фрагменти ({{count}})",
"retrievalTest.source.agent": "Агент",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "Тест пошуку",
"retrievalTest.source.service_api": "Service API",
"retrievalTest.source.workflow": "Робочий процес",
"retrievalTest.sourceFilter.all": "Усі",
"retrievalTest.sourceFilter.label": "Джерело запису",
"retrievalTest.sourceFilter.retrieval_test": "Тести пошуку",
"retrievalTest.sourceFilter.workflow": "Робочий процес",
"retrievalTest.startResearch": "Почати дослідження",
"retrievalTest.title": "Перевірка пошуку",
"retrievalTest.viewInQuality": "Відкрити в розділі «Якість»",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "Đã lưu làm câu hỏi tham chiếu",
"retrievalTest.score": "Điểm",
"retrievalTest.showAllChunks": "Hiển thị tất cả {{count}} đoạn",
"retrievalTest.source.agent": "Agent",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "Kiểm tra truy xuất",
"retrievalTest.source.service_api": "Service API",
"retrievalTest.source.workflow": "Quy trình",
"retrievalTest.sourceFilter.all": "Tất cả",
"retrievalTest.sourceFilter.label": "Nguồn bản ghi",
"retrievalTest.sourceFilter.retrieval_test": "Kiểm tra truy xuất",
"retrievalTest.sourceFilter.workflow": "Quy trình",
"retrievalTest.startResearch": "Bắt đầu nghiên cứu",
"retrievalTest.title": "Kiểm thử truy xuất",
"retrievalTest.viewInQuality": "Xem trong Chất lượng",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "已保存为参考问题",
"retrievalTest.score": "得分",
"retrievalTest.showAllChunks": "显示全部 {{count}} 个分段",
"retrievalTest.source.agent": "Agent",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "检索测试",
"retrievalTest.source.service_api": "Service API",
"retrievalTest.source.workflow": "工作流",
"retrievalTest.sourceFilter.all": "全部",
"retrievalTest.sourceFilter.label": "记录来源",
"retrievalTest.sourceFilter.retrieval_test": "检索测试",
"retrievalTest.sourceFilter.workflow": "工作流",
"retrievalTest.startResearch": "开始研究",
"retrievalTest.title": "检索测试",
"retrievalTest.viewInQuality": "前往“质量”页查看",

View File

@ -506,6 +506,15 @@
"retrievalTest.savedGoldenQuestion": "已儲存為參考問題",
"retrievalTest.score": "分數",
"retrievalTest.showAllChunks": "顯示全部 {{count}} 個片段",
"retrievalTest.source.agent": "Agent",
"retrievalTest.source.mcp": "MCP",
"retrievalTest.source.retrieval_test": "檢索測試",
"retrievalTest.source.service_api": "Service API",
"retrievalTest.source.workflow": "工作流",
"retrievalTest.sourceFilter.all": "全部",
"retrievalTest.sourceFilter.label": "記錄來源",
"retrievalTest.sourceFilter.retrieval_test": "檢索測試",
"retrievalTest.sourceFilter.workflow": "工作流",
"retrievalTest.startResearch": "開始研究",
"retrievalTest.title": "檢索測試",
"retrievalTest.viewInQuality": "在品質中查看",