diff --git a/api/controllers/console/knowledge_fs/resources.py b/api/controllers/console/knowledge_fs/resources.py index 83362e3a39f..67a3b022dc3 100644 --- a/api/controllers/console/knowledge_fs/resources.py +++ b/api/controllers/console/knowledge_fs/resources.py @@ -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//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) diff --git a/api/controllers/service_api/knowledge_fs/resources.py b/api/controllers/service_api/knowledge_fs/resources.py index a7ea7bd76c4..b4351ab8d89 100644 --- a/api/controllers/service_api/knowledge_fs/resources.py +++ b/api/controllers/service_api/knowledge_fs/resources.py @@ -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//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) diff --git a/api/core/workflow/nodes/knowledge_retrieval_v2/knowledge_retrieval_v2_node.py b/api/core/workflow/nodes/knowledge_retrieval_v2/knowledge_retrieval_v2_node.py index a81c4dd65ea..6cd5b9e8aa1 100644 --- a/api/core/workflow/nodes/knowledge_retrieval_v2/knowledge_retrieval_v2_node.py +++ b/api/core/workflow/nodes/knowledge_retrieval_v2/knowledge_retrieval_v2_node.py @@ -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 diff --git a/api/knowledge-fs-contract.lock.json b/api/knowledge-fs-contract.lock.json index 86e8b139c14..6b1aa89559e 100644 --- a/api/knowledge-fs-contract.lock.json +++ b/api/knowledge-fs-contract.lock.json @@ -1,7 +1,7 @@ { "schemaVersion": 5, - "subtreeTree": "219d157af4b240de61ce77c7c3473bdca699addd", - "openapiSha256": "edeae0b8fd3bd6dc9512b396da508bb99862b12ad0faeebc130996c108496d85", + "subtreeTree": "dd565881a8b5a5c113292abeaef493579eccf97c", + "openapiSha256": "20e7f698679f6c9b51aba2cc386e49eee1fbc76d05b1c0c35609963c5fcc105a", "capabilityV2AuthManifestSha256": "e322a2fa779d1f40b95c54c1021cffecaec77abbd7b34899573dcdf4ff353109", "capabilityV2AuthTestVectorSha256": "ae0de37b1ff05c40f905cf17a7b410d8971acacf64db07d5ee3d6fecfa559ce3", "productOperationManifestSha256": "e4611c61c69478f20c1e1e482c44d8c48e48adfc67f1e8941ce36225e10b32c0", diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index a08380d1e26..60315c22924 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -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,
**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,
**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 | diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index 56703a961bc..1697ff31d91 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -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,
**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,
**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 | diff --git a/api/services/knowledge_fs/data_facade.py b/api/services/knowledge_fs/data_facade.py index a043287e6da..e0ab9adb217 100644 --- a/api/services/knowledge_fs/data_facade.py +++ b/api/services/knowledge_fs/data_facade.py @@ -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) diff --git a/api/services/knowledge_fs/product_dto.py b/api/services/knowledge_fs/product_dto.py index a2fc86dc74d..b18e6912799 100644 --- a/api/services/knowledge_fs/product_dto.py +++ b/api/services/knowledge_fs/product_dto.py @@ -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] diff --git a/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval_v2/test_knowledge_retrieval_v2_node.py b/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval_v2/test_knowledge_retrieval_v2_node.py index a4d8e5f9d70..1c9b15feec0 100644 --- a/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval_v2/test_knowledge_retrieval_v2_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval_v2/test_knowledge_retrieval_v2_node.py @@ -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"] diff --git a/api/tests/unit_tests/services/test_knowledge_fs_product_dto.py b/api/tests/unit_tests/services/test_knowledge_fs_product_dto.py index d870aa9628a..cd56d6e31a3 100644 --- a/api/tests/unit_tests/services/test_knowledge_fs_product_dto.py +++ b/api/tests/unit_tests/services/test_knowledge_fs_product_dto.py @@ -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" + ) diff --git a/knowledge-fs/apps/api/src/index.ts b/knowledge-fs/apps/api/src/index.ts index 05e910953fd..9e05906e7d8 100644 --- a/knowledge-fs/apps/api/src/index.ts +++ b/knowledge-fs/apps/api/src/index.ts @@ -943,6 +943,9 @@ const researchTaskRuntime = })}\n`, ); }, + ...(databaseRepositories.gatewayOptions.knowledgeSpaceOverview + ? { overview: databaseRepositories.gatewayOptions.knowledgeSpaceOverview } + : {}), partials: databaseRepositories.researchTaskPartialResults, progress: databaseRepositories.researchTaskProgressEvents, ...(researchProjectionSnapshotResolver diff --git a/knowledge-fs/apps/api/src/migrate.test.ts b/knowledge-fs/apps/api/src/migrate.test.ts index 6ddc9610f0a..c36b6cbc5be 100644 --- a/knowledge-fs/apps/api/src/migrate.test.ts +++ b/knowledge-fs/apps/api/src/migrate.test.ts @@ -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); }); diff --git a/knowledge-fs/apps/api/src/research-task-runtime-options.ts b/knowledge-fs/apps/api/src/research-task-runtime-options.ts index ddb92e439d8..6b1cbb05d4b 100644 --- a/knowledge-fs/apps/api/src/research-task-runtime-options.ts +++ b/knowledge-fs/apps/api/src/research-task-runtime-options.ts @@ -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 | 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({ diff --git a/knowledge-fs/packages/adapters/src/migration-runner.test.ts b/knowledge-fs/packages/adapters/src/migration-runner.test.ts index f389123db29..e5ff6c2db1a 100644 --- a/knowledge-fs/packages/adapters/src/migration-runner.test.ts +++ b/knowledge-fs/packages/adapters/src/migration-runner.test.ts @@ -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, diff --git a/knowledge-fs/packages/api/src/answer-trace-recorder.ts b/knowledge-fs/packages/api/src/answer-trace-recorder.ts index 3bcc4a7af27..a34b5b84682 100644 --- a/knowledge-fs/packages/api/src/answer-trace-recorder.ts +++ b/knowledge-fs/packages/api/src/answer-trace-recorder.ts @@ -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; } diff --git a/knowledge-fs/packages/api/src/answer-trace-repository.test.ts b/knowledge-fs/packages/api/src/answer-trace-repository.test.ts index f7fda991c51..0dd83950012 100644 --- a/knowledge-fs/packages/api/src/answer-trace-repository.test.ts +++ b/knowledge-fs/packages/api/src/answer-trace-repository.test.ts @@ -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 => { + 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(); + }); +}); diff --git a/knowledge-fs/packages/api/src/answer-trace-repository.ts b/knowledge-fs/packages/api/src/answer-trace-repository.ts index 0e31fb26c37..976319ab457 100644 --- a/knowledge-fs/packages/api/src/answer-trace-repository.ts +++ b/knowledge-fs/packages/api/src/answer-trace-repository.ts @@ -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"), diff --git a/knowledge-fs/packages/api/src/answer-trace-source.test.ts b/knowledge-fs/packages/api/src/answer-trace-source.test.ts new file mode 100644 index 00000000000..62c7b3e2abf --- /dev/null +++ b/knowledge-fs/packages/api/src/answer-trace-source.test.ts @@ -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"); + }); +}); diff --git a/knowledge-fs/packages/api/src/answer-trace-source.ts b/knowledge-fs/packages/api/src/answer-trace-source.ts new file mode 100644 index 00000000000..c29d43f668d --- /dev/null +++ b/knowledge-fs/packages/api/src/answer-trace-source.ts @@ -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"; + } +} diff --git a/knowledge-fs/packages/api/src/gateway-sse-responses.ts b/knowledge-fs/packages/api/src/gateway-sse-responses.ts index 35d1ec15c04..e90ae87cdbf 100644 --- a/knowledge-fs/packages/api/src/gateway-sse-responses.ts +++ b/knowledge-fs/packages/api/src/gateway-sse-responses.ts @@ -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, { diff --git a/knowledge-fs/packages/api/src/gateway.test.ts b/knowledge-fs/packages/api/src/gateway.test.ts index 9c15e4f9206..6da18a0337d 100644 --- a/knowledge-fs/packages/api/src/gateway.test.ts +++ b/knowledge-fs/packages/api/src/gateway.test.ts @@ -12872,7 +12872,7 @@ describe("createKnowledgeGateway", () => { trace.permissionSnapshot?.accessChannel, true, trace.createdAt, - null, + ...[null, trace.source ?? null], ], tableName: "answer_traces", }), diff --git a/knowledge-fs/packages/api/src/index.ts b/knowledge-fs/packages/api/src/index.ts index 10c4b70b4de..6530b2e37c4 100644 --- a/knowledge-fs/packages/api/src/index.ts +++ b/knowledge-fs/packages/api/src/index.ts @@ -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, diff --git a/knowledge-fs/packages/api/src/knowledge-space-overview.ts b/knowledge-fs/packages/api/src/knowledge-space-overview.ts index 1a945044d18..6c3cccf8c3a 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-overview.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-overview.ts @@ -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( diff --git a/knowledge-fs/packages/api/src/quality-control-database-repository.test.ts b/knowledge-fs/packages/api/src/quality-control-database-repository.test.ts index 68fd648dedc..61bd00c184d 100644 --- a/knowledge-fs/packages/api/src/quality-control-database-repository.test.ts +++ b/knowledge-fs/packages/api/src/quality-control-database-repository.test.ts @@ -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); + }, + ); +}); diff --git a/knowledge-fs/packages/api/src/quality-control-database-repository.ts b/knowledge-fs/packages/api/src/quality-control-database-repository.ts index 834d2c7c4bb..b29805a8193 100644 --- a/knowledge-fs/packages/api/src/quality-control-database-repository.ts +++ b/knowledge-fs/packages/api/src/quality-control-database-repository.ts @@ -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; +} diff --git a/knowledge-fs/packages/api/src/quality-control-handlers.ts b/knowledge-fs/packages/api/src/quality-control-handlers.ts index 22a54745684..4cfb97c32d0 100644 --- a/knowledge-fs/packages/api/src/quality-control-handlers.ts +++ b/knowledge-fs/packages/api/src/quality-control-handlers.ts @@ -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, diff --git a/knowledge-fs/packages/api/src/quality-control-routes.ts b/knowledge-fs/packages/api/src/quality-control-routes.ts index 425d4617759..3b1b16f689a 100644 --- a/knowledge-fs/packages/api/src/quality-control-routes.ts +++ b/knowledge-fs/packages/api/src/quality-control-routes.ts @@ -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(), diff --git a/knowledge-fs/packages/api/src/quality-control.ts b/knowledge-fs/packages/api/src/quality-control.ts index 8914fe3355c..c4322b84dcc 100644 --- a/knowledge-fs/packages/api/src/quality-control.ts +++ b/knowledge-fs/packages/api/src/quality-control.ts @@ -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; diff --git a/knowledge-fs/packages/api/src/query-handlers.ts b/knowledge-fs/packages/api/src/query-handlers.ts index f3a8a13336e..87b1a7f3a5c 100644 --- a/knowledge-fs/packages/api/src/query-handlers.ts +++ b/knowledge-fs/packages/api/src/query-handlers.ts @@ -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 } : {}), diff --git a/knowledge-fs/packages/api/src/research-task-handlers.test.ts b/knowledge-fs/packages/api/src/research-task-handlers.test.ts index 5db0c7725f1..5273fa839c1 100644 --- a/knowledge-fs/packages/api/src/research-task-handlers.test.ts +++ b/knowledge-fs/packages/api/src/research-task-handlers.test.ts @@ -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; expect(responseBody).toMatchObject({ id: "research-task-job-1", diff --git a/knowledge-fs/packages/api/src/research-task-handlers.ts b/knowledge-fs/packages/api/src/research-task-handlers.ts index dd729ce6f5f..e52eab5dd83 100644 --- a/knowledge-fs/packages/api/src/research-task-handlers.ts +++ b/knowledge-fs/packages/api/src/research-task-handlers.ts @@ -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; 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 | 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 | undefined; + readonly query: string | undefined; + readonly source: string; + readonly subject: KnowledgeGatewayEnv["Variables"]["subject"]; +}): Promise { + 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. + } +} diff --git a/knowledge-fs/packages/api/src/research-task-job.test.ts b/knowledge-fs/packages/api/src/research-task-job.test.ts index 6d15395702d..f55b6066d0f 100644 --- a/knowledge-fs/packages/api/src/research-task-job.test.ts +++ b/knowledge-fs/packages/api/src/research-task-job.test.ts @@ -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" }); + }); +}); diff --git a/knowledge-fs/packages/api/src/research-task-job.ts b/knowledge-fs/packages/api/src/research-task-job.ts index 3390384c9c4..6d8453f419e 100644 --- a/knowledge-fs/packages/api/src/research-task-job.ts +++ b/knowledge-fs/packages/api/src/research-task-job.ts @@ -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 | 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 | undefined, + job: ResearchTaskJob, + action: "query.completed" | "query.failed", + result: "canceled" | "failure" | "success", + details: Readonly>, +): Promise { + 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. + } +} diff --git a/knowledge-fs/packages/api/src/retrieval-test-handlers.test.ts b/knowledge-fs/packages/api/src/retrieval-test-handlers.test.ts index bcc9b421fc9..b47eea4c07d 100644 --- a/knowledge-fs/packages/api/src/retrieval-test-handlers.test.ts +++ b/knowledge-fs/packages/api/src/retrieval-test-handlers.test.ts @@ -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).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", diff --git a/knowledge-fs/packages/api/src/retrieval-test-handlers.ts b/knowledge-fs/packages/api/src/retrieval-test-handlers.ts index 91d2808e244..0a261708ff4 100644 --- a/knowledge-fs/packages/api/src/retrieval-test-handlers.ts +++ b/knowledge-fs/packages/api/src/retrieval-test-handlers.ts @@ -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, diff --git a/knowledge-fs/packages/api/src/retrieval-test-routes.ts b/knowledge-fs/packages/api/src/retrieval-test-routes.ts index 76c4e3fabd7..87a7b23a809 100644 --- a/knowledge-fs/packages/api/src/retrieval-test-routes.ts +++ b/knowledge-fs/packages/api/src/retrieval-test-routes.ts @@ -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"]), diff --git a/knowledge-fs/packages/api/src/retrieval-test.test.ts b/knowledge-fs/packages/api/src/retrieval-test.test.ts index 33350d8e942..0a79c545973 100644 --- a/knowledge-fs/packages/api/src/retrieval-test.test.ts +++ b/knowledge-fs/packages/api/src/retrieval-test.test.ts @@ -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", + }); + }); +}); diff --git a/knowledge-fs/packages/api/src/retrieval-test.ts b/knowledge-fs/packages/api/src/retrieval-test.ts index 2fb6c662823..d9dc10391a8 100644 --- a/knowledge-fs/packages/api/src/retrieval-test.ts +++ b/knowledge-fs/packages/api/src/retrieval-test.ts @@ -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[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["assemble"]; + async function prepareRetrievalTestQuery({ input, provider, diff --git a/knowledge-fs/packages/api/src/workflow-failed-retrieval.test.ts b/knowledge-fs/packages/api/src/workflow-failed-retrieval.test.ts index 0992e87ce32..a2c5c790379 100644 --- a/knowledge-fs/packages/api/src/workflow-failed-retrieval.test.ts +++ b/knowledge-fs/packages/api/src/workflow-failed-retrieval.test.ts @@ -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", + }); + }); +}); diff --git a/knowledge-fs/packages/api/src/workflow-failed-retrieval.ts b/knowledge-fs/packages/api/src/workflow-failed-retrieval.ts index e8e6d29c229..75eda5cd0ee 100644 --- a/knowledge-fs/packages/api/src/workflow-failed-retrieval.ts +++ b/knowledge-fs/packages/api/src/workflow-failed-retrieval.ts @@ -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, recorder: AnswerTraceRecorder, input: CaptureWorkflowFailedRetrievalInput, -): Promise { +): 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, diff --git a/knowledge-fs/packages/core/src/models.ts b/knowledge-fs/packages/core/src/models.ts index 692adfadc31..dca0dfff470 100644 --- a/knowledge-fs/packages/core/src/models.ts +++ b/knowledge-fs/packages/core/src/models.ts @@ -1533,6 +1533,25 @@ export const AnswerTraceStepSchema = z.object({ }); export type AnswerTraceStep = z.infer; +/** + * 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; + +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), diff --git a/knowledge-fs/packages/database/migrations/0053_answer_trace_source.postgres.sql b/knowledge-fs/packages/database/migrations/0053_answer_trace_source.postgres.sql new file mode 100644 index 00000000000..2dbe9c035ef --- /dev/null +++ b/knowledge-fs/packages/database/migrations/0053_answer_trace_source.postgres.sql @@ -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"); diff --git a/knowledge-fs/packages/database/migrations/0053_answer_trace_source.tidb.sql b/knowledge-fs/packages/database/migrations/0053_answer_trace_source.tidb.sql new file mode 100644 index 00000000000..62c7dda6889 --- /dev/null +++ b/knowledge-fs/packages/database/migrations/0053_answer_trace_source.tidb.sql @@ -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`); diff --git a/knowledge-fs/packages/database/src/migration-artifacts.generated.ts b/knowledge-fs/packages/database/src/migration-artifacts.generated.ts index 1a22941538f..98171cc0bf8 100644 --- a/knowledge-fs/packages/database/src/migration-artifacts.generated.ts +++ b/knowledge-fs/packages/database/src/migration-artifacts.generated.ts @@ -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[]; diff --git a/knowledge-fs/packages/database/src/migration-file.test.ts b/knowledge-fs/packages/database/src/migration-file.test.ts index 787d153e2b2..fe81966824f 100644 --- a/knowledge-fs/packages/database/src/migration-file.test.ts +++ b/knowledge-fs/packages/database/src/migration-file.test.ts @@ -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", }), diff --git a/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts b/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts index bf56808be57..eb304054823 100644 --- a/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts +++ b/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts @@ -1197,6 +1197,7 @@ export type KnowledgeFsAnswerTraceResponse = { mode: 'auto' | 'deep' | 'fast' | 'research' query: string query_images?: Array + source?: KnowledgeFsTraceSource steps: Array } @@ -2164,9 +2165,12 @@ export type KnowledgeFsTraceResponse = { query_images?: Array result_count: number scores: KnowledgeFsTraceScoresResponse + source?: KnowledgeFsTraceSource stages: Array } +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' } diff --git a/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts b/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts index 4c233ce3215..abf0eb381e1 100644 --- a/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts +++ b/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts @@ -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(), }) /** diff --git a/packages/contracts/generated/api/service/types.gen.ts b/packages/contracts/generated/api/service/types.gen.ts index 336a9fd1839..0b9cf1a129e 100644 --- a/packages/contracts/generated/api/service/types.gen.ts +++ b/packages/contracts/generated/api/service/types.gen.ts @@ -1269,6 +1269,7 @@ export type KnowledgeFsAnswerTraceResponse = { mode: 'auto' | 'deep' | 'fast' | 'research' query: string query_images?: Array + source?: KnowledgeFsTraceSource steps: Array } @@ -2487,6 +2488,7 @@ export type KnowledgeFsTraceResponse = { query_images?: Array result_count: number scores: KnowledgeFsTraceScoresResponse + source?: KnowledgeFsTraceSource stages: Array } @@ -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' } diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index 3a22750bd40..36a74ca8560 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -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(), }) /** diff --git a/web/features/new-rag/retrieval/__tests__/model.spec.ts b/web/features/new-rag/retrieval/__tests__/model.spec.ts index 2732d808901..e8d2b117661 100644 --- a/web/features/new-rag/retrieval/__tests__/model.spec.ts +++ b/web/features/new-rag/retrieval/__tests__/model.spec.ts @@ -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' }), + ) + }) +}) diff --git a/web/features/new-rag/retrieval/history-panel.tsx b/web/features/new-rag/retrieval/history-panel.tsx index b0f983833c0..b02cca63efe 100644 --- a/web/features/new-rag/retrieval/history-panel.tsx +++ b/web/features/new-rag/retrieval/history-panel.tsx @@ -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 (
-
+

{t(($) => $['retrievalTest.records'])}

+ + aria-label={t(($) => $['retrievalTest.sourceFilter.label'])} + className="shrink-0" + value={sourceFilter} + onValueChange={setSourceFilter} + > + {sourceFilters.map((filter) => ( + + key={filter} + value={filter} + className="px-1.5 py-0.5 system-2xs-medium" + > + {t(($) => $[`retrievalTest.sourceFilter.${filter}`])} + + ))} +
{displayRecords.length > 0 ? ( diff --git a/web/features/new-rag/retrieval/history.tsx b/web/features/new-rag/retrieval/history.tsx index cc06160ae10..e8accf5fa55 100644 --- a/web/features/new-rag/retrieval/history.tsx +++ b/web/features/new-rag/retrieval/history.tsx @@ -575,6 +575,14 @@ export function RecordButton({ {queryImageCount} )} + {record.kind === 'trace' && record.source !== 'retrieval_test' && ( + + {t(($) => $[`retrievalTest.source.${record.source}`])} + + )} diff --git a/web/features/new-rag/retrieval/model.ts b/web/features/new-rag/retrieval/model.ts index 26146f2deed..33d920e33b9 100644 --- a/web/features/new-rag/retrieval/model.ts +++ b/web/features/new-rag/retrieval/model.ts @@ -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 => ({ diff --git a/web/features/new-rag/retrieval/state/graph.ts b/web/features/new-rag/retrieval/state/graph.ts index 733f2761738..36a4fadc47c 100644 --- a/web/features/new-rag/retrieval/state/graph.ts +++ b/web/features/new-rag/retrieval/state/graph.ts @@ -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), } }) diff --git a/web/features/new-rag/retrieval/state/scoped.ts b/web/features/new-rag/retrieval/state/scoped.ts index 63c07663d91..e8e6b2fd35f 100644 --- a/web/features/new-rag/retrieval/state/scoped.ts +++ b/web/features/new-rag/retrieval/state/scoped.ts @@ -62,6 +62,9 @@ export const retrievalAdmittedResearchTasksAtom = atom< Record >({}) 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('all') export const retrievalScopedAtoms = [ retrievalComposerDraftAtom, @@ -73,5 +76,6 @@ export const retrievalScopedAtoms = [ retrievalResearchEventsAtom, retrievalAdmittedResearchTasksAtom, retrievalResearchRetryPendingAtom, + retrievalHistorySourceFilterAtom, retrievalRuntimeBridgeAtom, ] as const diff --git a/web/i18n/ar-TN/knowledge-space.json b/web/i18n/ar-TN/knowledge-space.json index 4958c5733b0..2d7a3ee8aa3 100644 --- a/web/i18n/ar-TN/knowledge-space.json +++ b/web/i18n/ar-TN/knowledge-space.json @@ -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": "عرض في الجودة", diff --git a/web/i18n/de-DE/knowledge-space.json b/web/i18n/de-DE/knowledge-space.json index 8dee3cd5547..c4201f3cd31 100644 --- a/web/i18n/de-DE/knowledge-space.json +++ b/web/i18n/de-DE/knowledge-space.json @@ -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", diff --git a/web/i18n/en-US/knowledge-space.json b/web/i18n/en-US/knowledge-space.json index c1b5036d812..09c12568478 100644 --- a/web/i18n/en-US/knowledge-space.json +++ b/web/i18n/en-US/knowledge-space.json @@ -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", diff --git a/web/i18n/es-ES/knowledge-space.json b/web/i18n/es-ES/knowledge-space.json index 91ac0f5187e..4ab1d24eae8 100644 --- a/web/i18n/es-ES/knowledge-space.json +++ b/web/i18n/es-ES/knowledge-space.json @@ -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", diff --git a/web/i18n/fa-IR/knowledge-space.json b/web/i18n/fa-IR/knowledge-space.json index 2850da8b262..eae4302f6a3 100644 --- a/web/i18n/fa-IR/knowledge-space.json +++ b/web/i18n/fa-IR/knowledge-space.json @@ -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": "نمایش در بخش کیفیت", diff --git a/web/i18n/fr-FR/knowledge-space.json b/web/i18n/fr-FR/knowledge-space.json index 03d9536e304..5152ffc461a 100644 --- a/web/i18n/fr-FR/knowledge-space.json +++ b/web/i18n/fr-FR/knowledge-space.json @@ -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é", diff --git a/web/i18n/hi-IN/knowledge-space.json b/web/i18n/hi-IN/knowledge-space.json index 2735870c699..14eb99c9e32 100644 --- a/web/i18n/hi-IN/knowledge-space.json +++ b/web/i18n/hi-IN/knowledge-space.json @@ -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": "गुणवत्ता में देखें", diff --git a/web/i18n/id-ID/knowledge-space.json b/web/i18n/id-ID/knowledge-space.json index 02d9537eddd..117a015d7cf 100644 --- a/web/i18n/id-ID/knowledge-space.json +++ b/web/i18n/id-ID/knowledge-space.json @@ -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", diff --git a/web/i18n/it-IT/knowledge-space.json b/web/i18n/it-IT/knowledge-space.json index a3792bfcf3f..614c896c69e 100644 --- a/web/i18n/it-IT/knowledge-space.json +++ b/web/i18n/it-IT/knowledge-space.json @@ -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à", diff --git a/web/i18n/ja-JP/knowledge-space.json b/web/i18n/ja-JP/knowledge-space.json index 4d1ede010d8..6775833d83b 100644 --- a/web/i18n/ja-JP/knowledge-space.json +++ b/web/i18n/ja-JP/knowledge-space.json @@ -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": "品質で表示", diff --git a/web/i18n/ko-KR/knowledge-space.json b/web/i18n/ko-KR/knowledge-space.json index 8d8e120e7d6..a27c498d48e 100644 --- a/web/i18n/ko-KR/knowledge-space.json +++ b/web/i18n/ko-KR/knowledge-space.json @@ -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": "품질에서 보기", diff --git a/web/i18n/lo-LA/knowledge-space.json b/web/i18n/lo-LA/knowledge-space.json index 6dd81071f7a..5a1f939df92 100644 --- a/web/i18n/lo-LA/knowledge-space.json +++ b/web/i18n/lo-LA/knowledge-space.json @@ -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": "ເບິ່ງໃນໜ້າຄຸນະພາບ", diff --git a/web/i18n/nl-NL/knowledge-space.json b/web/i18n/nl-NL/knowledge-space.json index 3908f9483ab..370a3cbedc0 100644 --- a/web/i18n/nl-NL/knowledge-space.json +++ b/web/i18n/nl-NL/knowledge-space.json @@ -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", diff --git a/web/i18n/pl-PL/knowledge-space.json b/web/i18n/pl-PL/knowledge-space.json index 57934851ab3..3d0ec58e2f5 100644 --- a/web/i18n/pl-PL/knowledge-space.json +++ b/web/i18n/pl-PL/knowledge-space.json @@ -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ść", diff --git a/web/i18n/pt-BR/knowledge-space.json b/web/i18n/pt-BR/knowledge-space.json index f136464634e..6f6dd0e1b34 100644 --- a/web/i18n/pt-BR/knowledge-space.json +++ b/web/i18n/pt-BR/knowledge-space.json @@ -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", diff --git a/web/i18n/ro-RO/knowledge-space.json b/web/i18n/ro-RO/knowledge-space.json index 993e96e1f6a..a825a43b452 100644 --- a/web/i18n/ro-RO/knowledge-space.json +++ b/web/i18n/ro-RO/knowledge-space.json @@ -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", diff --git a/web/i18n/ru-RU/knowledge-space.json b/web/i18n/ru-RU/knowledge-space.json index 63354725f37..5c2b4c46539 100644 --- a/web/i18n/ru-RU/knowledge-space.json +++ b/web/i18n/ru-RU/knowledge-space.json @@ -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": "Открыть в разделе «Качество»", diff --git a/web/i18n/sl-SI/knowledge-space.json b/web/i18n/sl-SI/knowledge-space.json index f124a739cd8..028de78b0a4 100644 --- a/web/i18n/sl-SI/knowledge-space.json +++ b/web/i18n/sl-SI/knowledge-space.json @@ -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", diff --git a/web/i18n/th-TH/knowledge-space.json b/web/i18n/th-TH/knowledge-space.json index 63ad7b28be0..7866ad2e498 100644 --- a/web/i18n/th-TH/knowledge-space.json +++ b/web/i18n/th-TH/knowledge-space.json @@ -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": "ดูในคุณภาพ", diff --git a/web/i18n/tr-TR/knowledge-space.json b/web/i18n/tr-TR/knowledge-space.json index 6f6f0fd66a0..5336e7da5b5 100644 --- a/web/i18n/tr-TR/knowledge-space.json +++ b/web/i18n/tr-TR/knowledge-space.json @@ -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", diff --git a/web/i18n/uk-UA/knowledge-space.json b/web/i18n/uk-UA/knowledge-space.json index 197632c904c..75dcdcac529 100644 --- a/web/i18n/uk-UA/knowledge-space.json +++ b/web/i18n/uk-UA/knowledge-space.json @@ -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": "Відкрити в розділі «Якість»", diff --git a/web/i18n/vi-VN/knowledge-space.json b/web/i18n/vi-VN/knowledge-space.json index cecb29a7fc2..4015f7b5978 100644 --- a/web/i18n/vi-VN/knowledge-space.json +++ b/web/i18n/vi-VN/knowledge-space.json @@ -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", diff --git a/web/i18n/zh-Hans/knowledge-space.json b/web/i18n/zh-Hans/knowledge-space.json index 53d9a048587..ab15b9a251f 100644 --- a/web/i18n/zh-Hans/knowledge-space.json +++ b/web/i18n/zh-Hans/knowledge-space.json @@ -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": "前往“质量”页查看", diff --git a/web/i18n/zh-Hant/knowledge-space.json b/web/i18n/zh-Hant/knowledge-space.json index 55fc18d14e4..4d048fb3571 100644 --- a/web/i18n/zh-Hant/knowledge-space.json +++ b/web/i18n/zh-Hant/knowledge-space.json @@ -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": "在品質中查看",