mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 02:43:49 +08:00
feat(knowledge-fs): record and count every knowledge space query
Workflow KnowledgeFS retrieval nodes ran through `retrieval-tests`, which never wrote an AnswerTrace or an overview event, so their queries were missing from the retrieval history and from the space overview; the history also only ever showed each member their own traces. - AnswerTrace gains a `source` (retrieval_test | workflow | service_api | agent | mcp; migration 0051_answer_trace_source) derived from the Capability v2 caller kind. The retrieval-tests route records one trace per run (stages, evidence bundle, profile metadata), returns its id as `answerTraceId`, and the workflow node's failed-retrieval capture attaches to that trace instead of creating a second record. - The quality trace list exposes and filters by `source`; traces from other caller kinds are visible to any current reader of the space, and counts and scores fall back to the evidence embedded in the trace when no bundle row exists. - Overview accounting: retrieval-tests and Research tasks now emit `query.requested`, and the Research job state machine emits `query.completed` / `query.failed` on terminal stages (wired for both the in-process gateway and the durable runtime), so query volume, answer rate and outcomes include every caller. Activity details keep `source` and `taskKind`. - Console and service trace routes accept a `source` filter; the retrieval test page shows a source badge and an all / retrieval tests / workflow filter, with translations for every locale. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015zw5G5SX3HmVfnZof6YWAc
This commit is contained in:
parent
397cc2d7cc
commit
b79b8a879b
@ -240,6 +240,7 @@ from services.knowledge_fs.product_dto import (
|
||||
KnowledgeFSStreamCapabilityResponse,
|
||||
KnowledgeFSTraceEntriesQuery,
|
||||
KnowledgeFSTraceEntryListResponse,
|
||||
KnowledgeFSTraceListQuery,
|
||||
KnowledgeFSTraceListResponse,
|
||||
KnowledgeFSUploadPartPresignPayload,
|
||||
KnowledgeFSUploadSessionAbortPayload,
|
||||
@ -3351,7 +3352,7 @@ class KnowledgeFSSpaceResearchTaskPartialsApi(Resource):
|
||||
|
||||
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/traces")
|
||||
class KnowledgeFSSpaceTracesApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(KnowledgeFSCursorQuery))
|
||||
@console_ns.doc(params=query_params_from_model(KnowledgeFSTraceListQuery))
|
||||
@console_ns.response(
|
||||
HTTPStatus.OK,
|
||||
"KnowledgeFS traces",
|
||||
@ -3363,12 +3364,13 @@ class KnowledgeFSSpaceTracesApi(Resource):
|
||||
@_knowledge_fs_errors
|
||||
def get(self, control_space_id: str):
|
||||
actor_id, tenant_id = _actor()
|
||||
query = KnowledgeFSCursorQuery.model_validate(request.args.to_dict())
|
||||
query = KnowledgeFSTraceListQuery.model_validate(request.args.to_dict())
|
||||
result = _console_services().facade.list_traces(
|
||||
tenant_id=tenant_id,
|
||||
account_id=actor_id,
|
||||
control_space_id=control_space_id,
|
||||
cursor=query.cursor,
|
||||
source=query.source,
|
||||
)
|
||||
return dump_response(KnowledgeFSTraceListResponse, result)
|
||||
|
||||
|
||||
@ -84,6 +84,7 @@ from services.knowledge_fs.product_dto import (
|
||||
KnowledgeFSSourceUpdatePayload,
|
||||
KnowledgeFSTraceEntriesQuery,
|
||||
KnowledgeFSTraceEntryListResponse,
|
||||
KnowledgeFSTraceListQuery,
|
||||
KnowledgeFSTraceListResponse,
|
||||
)
|
||||
from services.knowledge_fs.product_operations import product_operation_action
|
||||
@ -955,7 +956,7 @@ class KnowledgeFSServiceResearchTaskPartialsApi(Resource):
|
||||
|
||||
@service_api_ns.route("/knowledge-fs/spaces/<string:control_space_id>/traces")
|
||||
class KnowledgeFSServiceTracesApi(Resource):
|
||||
@service_api_ns.doc(params=query_params_from_model(KnowledgeFSCursorQuery))
|
||||
@service_api_ns.doc(params=query_params_from_model(KnowledgeFSTraceListQuery))
|
||||
@service_api_ns.response(
|
||||
HTTPStatus.OK,
|
||||
"KnowledgeFS traces",
|
||||
@ -965,11 +966,11 @@ class KnowledgeFSServiceTracesApi(Resource):
|
||||
def get(self, control_space_id: str):
|
||||
runtime = _runtime()
|
||||
profile = _profile(runtime, operation_id="listTraces", control_space_id=control_space_id)
|
||||
query = KnowledgeFSCursorQuery.model_validate(request.args.to_dict())
|
||||
query = KnowledgeFSTraceListQuery.model_validate(request.args.to_dict())
|
||||
raw = runtime.facade.execute_service(
|
||||
profile=profile,
|
||||
operation_id="listTraces",
|
||||
query=(("cursor", query.cursor),) if query.cursor else (),
|
||||
query=tuple((name, value) for name, value in (("cursor", query.cursor), ("source", query.source)) if value),
|
||||
)
|
||||
return dump_response(KnowledgeFSTraceListResponse, raw)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 5,
|
||||
"subtreeTree": "219d157af4b240de61ce77c7c3473bdca699addd",
|
||||
"openapiSha256": "edeae0b8fd3bd6dc9512b396da508bb99862b12ad0faeebc130996c108496d85",
|
||||
"subtreeTree": "dd565881a8b5a5c113292abeaef493579eccf97c",
|
||||
"openapiSha256": "20e7f698679f6c9b51aba2cc386e49eee1fbc76d05b1c0c35609963c5fcc105a",
|
||||
"capabilityV2AuthManifestSha256": "e322a2fa779d1f40b95c54c1021cffecaec77abbd7b34899573dcdf4ff353109",
|
||||
"capabilityV2AuthTestVectorSha256": "ae0de37b1ff05c40f905cf17a7b410d8971acacf64db07d5ee3d6fecfa559ce3",
|
||||
"productOperationManifestSha256": "e4611c61c69478f20c1e1e482c44d8c48e48adfc67f1e8941ce36225e10b32c0",
|
||||
|
||||
@ -9053,6 +9053,7 @@ Claim a workspace-staged upload. Multipart file bodies remain accepted as a lega
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| cursor | query | | No | string |
|
||||
| source | query | | No | string, <br>**Available values:** "agent", "mcp", "retrieval_test", "service_api", "workflow" |
|
||||
| control_space_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
@ -21056,6 +21057,7 @@ Input field definition for snippet parameters.
|
||||
| mode | string, <br>**Available values:** "auto", "deep", "fast", "research" | *Enum:* `"auto"`, `"deep"`, `"fast"`, `"research"` | Yes |
|
||||
| query | string | | Yes |
|
||||
| query_images | [ [KnowledgeFSQueryImageResponse](#knowledgefsqueryimageresponse) ] | | No |
|
||||
| source | [KnowledgeFSTraceSource](#knowledgefstracesource) | | No |
|
||||
| steps | [ [KnowledgeFSAnswerTraceStepResponse](#knowledgefsanswertracestepresponse) ] | | Yes |
|
||||
|
||||
#### KnowledgeFSAnswerTraceStepResponse
|
||||
@ -21344,10 +21346,20 @@ Input field definition for snippet parameters.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| KnowledgeFSControlSpaceVisibility | string | | |
|
||||
|
||||
#### KnowledgeFSCrawlImportPagePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | | Yes |
|
||||
| description | string | | No |
|
||||
| sourceUrl | string | | Yes |
|
||||
| title | string | | No |
|
||||
|
||||
#### KnowledgeFSCrawlImportPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| pages | [ [KnowledgeFSCrawlImportPagePayload](#knowledgefscrawlimportpagepayload) ] | | No |
|
||||
| sourceUrls | [ string ] | | Yes |
|
||||
|
||||
#### KnowledgeFSCrawlPreviewPageListQuery
|
||||
@ -21918,6 +21930,7 @@ Input field definition for snippet parameters.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| page_id | string | | No |
|
||||
| source_url | string | | Yes |
|
||||
| title | string | | No |
|
||||
|
||||
@ -21956,6 +21969,7 @@ Input field definition for snippet parameters.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| canonical_url | string | | No |
|
||||
| pageId | string | | No |
|
||||
| source_url | string | | Yes |
|
||||
| title | string | | No |
|
||||
|
||||
@ -21972,6 +21986,7 @@ Input field definition for snippet parameters.
|
||||
| parameters | object | | No |
|
||||
| pluginId | string | | No |
|
||||
| previewConfigurationFingerprint | string | | No |
|
||||
| previewJobId | string | | No |
|
||||
| provider | string | | Yes |
|
||||
| providerDisplayName | string | | No |
|
||||
| root_url | string | | Yes |
|
||||
@ -22390,9 +22405,17 @@ Input field definition for snippet parameters.
|
||||
|
||||
#### KnowledgeFSProductPermission
|
||||
|
||||
Product-level capability of a caller on one KnowledgeFS space.
|
||||
|
||||
These values are what the console returns as ``permission_keys`` so the web can gate
|
||||
its UI. Enterprise RBAC is not evaluated in this vocabulary: KnowledgeFS spaces reuse
|
||||
the legacy knowledge base (``dataset_*``) permission points, and each capability is
|
||||
granted when the caller holds the dataset permission it maps to (see
|
||||
:data:`RBAC_PERMISSION_BY_PRODUCT_PERMISSION`).
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| KnowledgeFSProductPermission | string | | |
|
||||
| KnowledgeFSProductPermission | string | Product-level capability of a caller on one KnowledgeFS space. These values are what the console returns as ``permission_keys`` so the web can gate its UI. Enterprise RBAC is not evaluated in this vocabulary: KnowledgeFS spaces reuse the legacy knowledge base (``dataset_*``) permission points, and each capability is granted when the caller holds the dataset permission it maps to (see :data:`RBAC_PERMISSION_BY_PRODUCT_PERMISSION`). | |
|
||||
|
||||
#### KnowledgeFSProductRerankProfile
|
||||
|
||||
@ -23476,6 +23499,7 @@ the file name and a short-lived signed preview URL for files the caller still ow
|
||||
| query_images | [ [KnowledgeFSQueryImageResponse](#knowledgefsqueryimageresponse) ] | | No |
|
||||
| result_count | integer | | Yes |
|
||||
| scores | [KnowledgeFSTraceScoresResponse](#knowledgefstracescoresresponse) | | Yes |
|
||||
| source | [KnowledgeFSTraceSource](#knowledgefstracesource) | | No |
|
||||
| stages | [ [KnowledgeFSTraceStageResponse](#knowledgefstracestageresponse) ] | | Yes |
|
||||
|
||||
#### KnowledgeFSTraceScoresResponse
|
||||
@ -23486,6 +23510,12 @@ the file name and a short-lived signed preview URL for files the caller still ow
|
||||
| rerank | number | | No |
|
||||
| retrieval | number | | No |
|
||||
|
||||
#### KnowledgeFSTraceSource
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| KnowledgeFSTraceSource | string | | |
|
||||
|
||||
#### KnowledgeFSTraceStageResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@ -663,6 +663,7 @@ Deprecated legacy alias for updating an existing document by providing text cont
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| cursor | query | | No | string |
|
||||
| source | query | | No | string, <br>**Available values:** "agent", "mcp", "retrieval_test", "service_api", "workflow" |
|
||||
| control_space_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
@ -4274,6 +4275,7 @@ Model class for i18n object.
|
||||
| mode | string, <br>**Available values:** "auto", "deep", "fast", "research" | *Enum:* `"auto"`, `"deep"`, `"fast"`, `"research"` | Yes |
|
||||
| query | string | | Yes |
|
||||
| query_images | [ [KnowledgeFSQueryImageResponse](#knowledgefsqueryimageresponse) ] | | No |
|
||||
| source | [KnowledgeFSTraceSource](#knowledgefstracesource) | | No |
|
||||
| steps | [ [KnowledgeFSAnswerTraceStepResponse](#knowledgefsanswertracestepresponse) ] | | Yes |
|
||||
|
||||
#### KnowledgeFSAnswerTraceStepResponse
|
||||
@ -5223,6 +5225,7 @@ the file name and a short-lived signed preview URL for files the caller still ow
|
||||
| query_images | [ [KnowledgeFSQueryImageResponse](#knowledgefsqueryimageresponse) ] | | No |
|
||||
| result_count | integer | | Yes |
|
||||
| scores | [KnowledgeFSTraceScoresResponse](#knowledgefstracescoresresponse) | | Yes |
|
||||
| source | [KnowledgeFSTraceSource](#knowledgefstracesource) | | No |
|
||||
| stages | [ [KnowledgeFSTraceStageResponse](#knowledgefstracestageresponse) ] | | Yes |
|
||||
|
||||
#### KnowledgeFSTraceScoresResponse
|
||||
@ -5233,6 +5236,12 @@ the file name and a short-lived signed preview URL for files the caller still ow
|
||||
| rerank | number | | No |
|
||||
| retrieval | number | | No |
|
||||
|
||||
#### KnowledgeFSTraceSource
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| KnowledgeFSTraceSource | string | | |
|
||||
|
||||
#### KnowledgeFSTraceStageResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
@ -943,6 +943,9 @@ const researchTaskRuntime =
|
||||
})}\n`,
|
||||
);
|
||||
},
|
||||
...(databaseRepositories.gatewayOptions.knowledgeSpaceOverview
|
||||
? { overview: databaseRepositories.gatewayOptions.knowledgeSpaceOverview }
|
||||
: {}),
|
||||
partials: databaseRepositories.researchTaskPartialResults,
|
||||
progress: databaseRepositories.researchTaskProgressEvents,
|
||||
...(researchProjectionSnapshotResolver
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
type KnowledgeGatewayOptions,
|
||||
type KnowledgeSpaceAccessService,
|
||||
type KnowledgeSpaceManifestRepository,
|
||||
type KnowledgeSpaceOverviewRepository,
|
||||
type ModelInputModalityResolver,
|
||||
type PublishedProjectionReadSnapshotResolver,
|
||||
type QueryGenerator,
|
||||
@ -46,6 +47,8 @@ export interface CreateApiResearchTaskRuntimeOptions {
|
||||
readonly researchTaskJob?: { readonly id: string; readonly stage: string } | undefined;
|
||||
}) => void)
|
||||
| undefined;
|
||||
/** Overview activity sink so durable Research tasks count as answered/failed queries. */
|
||||
readonly overview?: Pick<KnowledgeSpaceOverviewRepository, "appendActivity"> | undefined;
|
||||
readonly partials: ResearchTaskPartialResultRepository;
|
||||
readonly progress: ResearchTaskProgressRepository;
|
||||
readonly projectionSnapshotResolver?: PublishedProjectionReadSnapshotResolver | undefined;
|
||||
@ -89,6 +92,7 @@ export function createApiResearchTaskRuntime({
|
||||
metrics,
|
||||
modelInputModalityResolver,
|
||||
onError,
|
||||
overview,
|
||||
partials,
|
||||
progress,
|
||||
projectionSnapshotResolver,
|
||||
@ -120,6 +124,7 @@ export function createApiResearchTaskRuntime({
|
||||
...(metrics ? { metrics } : {}),
|
||||
...(modelInputModalityResolver ? { modelInputModalityResolver } : {}),
|
||||
...(onError ? { onError } : {}),
|
||||
...(overview ? { overview } : {}),
|
||||
repository,
|
||||
});
|
||||
const runtime = createResearchTaskRuntime({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -293,6 +293,7 @@ describe("AnswerTrace repositories", () => {
|
||||
true,
|
||||
trace.createdAt,
|
||||
null,
|
||||
null,
|
||||
],
|
||||
tableName: "answer_traces",
|
||||
}),
|
||||
@ -1219,3 +1220,58 @@ function hasWholeSpaceDeletionFence(
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
describe("AnswerTrace source column", () => {
|
||||
it.each(["postgres", "tidb"] as const)("round-trips the trace source on %s", async (kind) => {
|
||||
let storedTrace: DatabaseRow | undefined;
|
||||
const inserts: DatabaseExecuteInput[] = [];
|
||||
const executor = async (input: DatabaseExecuteInput): Promise<DatabaseExecuteResult> => {
|
||||
if (input.tableName === "knowledge_spaces") {
|
||||
return { rows: [{ id: input.params[0], tenant_id: "tenant-1" }], rowsAffected: 1 };
|
||||
}
|
||||
if (input.tableName === "answer_traces" && input.operation === "select") {
|
||||
return { rows: storedTrace ? [storedTrace] : [], rowsAffected: storedTrace ? 1 : 0 };
|
||||
}
|
||||
if (input.tableName === "answer_traces" && input.operation === "insert") {
|
||||
inserts.push(input);
|
||||
storedTrace = {
|
||||
access_channel: input.params[10],
|
||||
capability_grant_id: input.params[3],
|
||||
completed: input.params[11],
|
||||
created_at: input.params[12],
|
||||
evidence_bundle_id: input.params[4],
|
||||
id: input.params[0],
|
||||
knowledge_space_id: input.params[2],
|
||||
mode: input.params[6],
|
||||
permission_snapshot_id: input.params[8],
|
||||
permission_snapshot_revision: input.params[9],
|
||||
query: input.params[5],
|
||||
query_images: input.params[13],
|
||||
source: input.params[14],
|
||||
subject_id: input.params[7],
|
||||
tenant_id: input.params[1],
|
||||
};
|
||||
return { rows: [], rowsAffected: 1 };
|
||||
}
|
||||
return { rows: [], rowsAffected: input.operation === "insert" ? 1 : 0 };
|
||||
};
|
||||
const repository = createDatabaseAnswerTraceRepository({
|
||||
database: createSchemaDatabaseAdapter({
|
||||
executor,
|
||||
kind,
|
||||
transaction: async (callback) => callback({ execute: executor }),
|
||||
}),
|
||||
});
|
||||
const trace = AnswerTraceSchema.parse({ ...databaseTrace(), source: "workflow" });
|
||||
|
||||
await repository.create(trace);
|
||||
|
||||
expect(inserts[0]?.sql).toContain(kind === "postgres" ? '"source"' : "`source`");
|
||||
expect(inserts[0]?.params[14]).toBe("workflow");
|
||||
expect((await repository.getById(trace.id))?.source).toBe("workflow");
|
||||
|
||||
// Rows written before the column existed read back without a source (= retrieval test).
|
||||
storedTrace = { ...storedTrace, source: null };
|
||||
expect((await repository.getById(trace.id))?.source).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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"),
|
||||
|
||||
29
knowledge-fs/packages/api/src/answer-trace-source.test.ts
Normal file
29
knowledge-fs/packages/api/src/answer-trace-source.test.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { answerTraceSourceForCaller } from "./answer-trace-source";
|
||||
|
||||
describe("answerTraceSourceForCaller", () => {
|
||||
it("prefers the precise Capability v2 caller kind over the collapsed gateway kind", () => {
|
||||
// The gateway maps workflow grants to the "agent" access channel; history must keep them apart.
|
||||
expect(
|
||||
answerTraceSourceForCaller({ callerKind: "agent", capabilityCallerKind: "workflow" }),
|
||||
).toBe("workflow");
|
||||
expect(answerTraceSourceForCaller({ capabilityCallerKind: "agent" })).toBe("agent");
|
||||
expect(answerTraceSourceForCaller({ capabilityCallerKind: "mcp" })).toBe("mcp");
|
||||
expect(answerTraceSourceForCaller({ capabilityCallerKind: "service" })).toBe("service_api");
|
||||
expect(answerTraceSourceForCaller({ capabilityCallerKind: "internal_worker" })).toBe(
|
||||
"service_api",
|
||||
);
|
||||
expect(answerTraceSourceForCaller({ capabilityCallerKind: "interactive" })).toBe(
|
||||
"retrieval_test",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the gateway caller kind for legacy sessions and API keys", () => {
|
||||
expect(answerTraceSourceForCaller({ callerKind: "api_key" })).toBe("service_api");
|
||||
expect(answerTraceSourceForCaller({ callerKind: "service_api" })).toBe("service_api");
|
||||
expect(answerTraceSourceForCaller({ callerKind: "agent" })).toBe("agent");
|
||||
expect(answerTraceSourceForCaller({ callerKind: "mcp" })).toBe("mcp");
|
||||
expect(answerTraceSourceForCaller({ callerKind: "interactive" })).toBe("retrieval_test");
|
||||
expect(answerTraceSourceForCaller({})).toBe("retrieval_test");
|
||||
});
|
||||
});
|
||||
45
knowledge-fs/packages/api/src/answer-trace-source.ts
Normal file
45
knowledge-fs/packages/api/src/answer-trace-source.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import type { AnswerTraceSource } from "@knowledge/core";
|
||||
import type { DifyCapabilityV2SanitizedGrant } from "./dify-capability-v2-grant";
|
||||
import type { KnowledgeSpaceCallerKind } from "./knowledge-space-authorization";
|
||||
|
||||
/**
|
||||
* Derive the AnswerTrace source from the authenticated caller.
|
||||
*
|
||||
* Capability v2 grants carry the precise caller kind (the gateway collapses `workflow` into
|
||||
* `agent` for access-channel purposes, which is too coarse for history); legacy sessions and
|
||||
* API keys only expose the gateway caller kind. Anything unknown is a console retrieval test.
|
||||
*/
|
||||
export function answerTraceSourceForCaller({
|
||||
callerKind,
|
||||
capabilityCallerKind,
|
||||
}: {
|
||||
readonly callerKind?: KnowledgeSpaceCallerKind | undefined;
|
||||
readonly capabilityCallerKind?: DifyCapabilityV2SanitizedGrant["callerKind"] | undefined;
|
||||
}): AnswerTraceSource {
|
||||
switch (capabilityCallerKind) {
|
||||
case "workflow":
|
||||
return "workflow";
|
||||
case "agent":
|
||||
return "agent";
|
||||
case "mcp":
|
||||
return "mcp";
|
||||
case "service":
|
||||
case "internal_worker":
|
||||
return "service_api";
|
||||
case "interactive":
|
||||
return "retrieval_test";
|
||||
default:
|
||||
break;
|
||||
}
|
||||
switch (callerKind) {
|
||||
case "agent":
|
||||
return "agent";
|
||||
case "mcp":
|
||||
return "mcp";
|
||||
case "api_key":
|
||||
case "service_api":
|
||||
return "service_api";
|
||||
default:
|
||||
return "retrieval_test";
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
{
|
||||
|
||||
@ -12872,7 +12872,7 @@ describe("createKnowledgeGateway", () => {
|
||||
trace.permissionSnapshot?.accessChannel,
|
||||
true,
|
||||
trace.createdAt,
|
||||
null,
|
||||
...[null, trace.source ?? null],
|
||||
],
|
||||
tableName: "answer_traces",
|
||||
}),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 }
|
||||
: {}),
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
encodeResearchTaskListCursor,
|
||||
researchTaskRuntimeSnapshotFromMetadata,
|
||||
} from "./index";
|
||||
import { createInMemoryKnowledgeSpaceOverviewRepository } from "./knowledge-space-overview";
|
||||
|
||||
describe("research task handlers", () => {
|
||||
it("round-trips list cursors and rejects every malformed cursor boundary", () => {
|
||||
@ -84,9 +85,14 @@ describe("research task handlers", () => {
|
||||
repository: createInMemoryResearchTaskJobRepository({ maxJobs: 10 }),
|
||||
});
|
||||
let deletionActive = false;
|
||||
const overview = createInMemoryKnowledgeSpaceOverviewRepository({
|
||||
maxEvents: 100,
|
||||
maxListLimit: 50,
|
||||
});
|
||||
const app = createKnowledgeGateway({
|
||||
adapter,
|
||||
allowLegacyResearchTaskProfileFallback: true,
|
||||
knowledgeSpaceOverview: overview,
|
||||
auth: createStaticAuthVerifier({
|
||||
subjectsByToken: {
|
||||
"editor-token": {
|
||||
@ -129,6 +135,27 @@ describe("research task handlers", () => {
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
// Research tasks count as knowledge-space queries in the overview, keyed by the task id.
|
||||
expect(
|
||||
(
|
||||
await overview.listActivity({
|
||||
candidateGrants: ["tenant:tenant-1"],
|
||||
knowledgeSpaceId,
|
||||
limit: 10,
|
||||
tenantId: "tenant-1",
|
||||
})
|
||||
).items,
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
action: "query.requested",
|
||||
details: expect.objectContaining({
|
||||
question: "Research semantic retrieval regressions",
|
||||
source: "retrieval_test",
|
||||
taskKind: "research",
|
||||
}),
|
||||
resource: { id: "research-task-job-1", type: "query" },
|
||||
}),
|
||||
]);
|
||||
const responseBody = (await response.json()) as Record<string, unknown>;
|
||||
expect(responseBody).toMatchObject({
|
||||
id: "research-task-job-1",
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
validateKnowledgeSpaceRetrievalProfileForMode,
|
||||
} from "@knowledge/core";
|
||||
|
||||
import { answerTraceSourceForCaller } from "./answer-trace-source";
|
||||
import { isAuthenticatedApiKeyBoundToKnowledgeSpace } from "./auth";
|
||||
import {
|
||||
AUTO_RETRIEVAL_MODE_DECISION_METADATA_KEY,
|
||||
@ -31,6 +32,10 @@ import {
|
||||
type KnowledgeSpaceAuthorizationGuard,
|
||||
type KnowledgeSpaceCallerKind,
|
||||
} from "./knowledge-space-authorization";
|
||||
import {
|
||||
type KnowledgeSpaceOverviewRepository,
|
||||
deterministicKnowledgeSpaceActivityId,
|
||||
} from "./knowledge-space-overview";
|
||||
import type { KnowledgeSpaceRepository } from "./knowledge-space-repository";
|
||||
import { type LooseOpenApiContext, openApiHandler } from "./openapi-handler-utils";
|
||||
import type { PublishedKnowledgeSpaceRuntimeSnapshotResolver } from "./published-knowledge-space-runtime-snapshot";
|
||||
@ -88,6 +93,8 @@ export interface RegisterResearchTaskHandlersOptions {
|
||||
readonly assets: Pick<DocumentAssetRepository, "get" | "getManyByIds">;
|
||||
readonly dryRunResearchPlanner: ResearchTaskDryRunPlanner;
|
||||
readonly deletionVisibility?: ResearchTaskDeletionVisibility | undefined;
|
||||
/** Counts each task as a knowledge-space query in the overview, like the query stream does. */
|
||||
readonly overview?: Pick<KnowledgeSpaceOverviewRepository, "appendActivity"> | undefined;
|
||||
readonly researchTaskJobs: ResearchTaskJobStateMachine;
|
||||
readonly researchTaskPartialResults: ResearchTaskPartialResultRepository;
|
||||
readonly researchTaskProgressEvents: ResearchTaskProgressRepository;
|
||||
@ -109,6 +116,7 @@ export function registerResearchTaskHandlers({
|
||||
dryRunResearchPlanner,
|
||||
deletionVisibility,
|
||||
directStream,
|
||||
overview,
|
||||
researchTaskJobs,
|
||||
researchTaskPartialResults,
|
||||
researchTaskProgressEvents,
|
||||
@ -390,6 +398,17 @@ export function registerResearchTaskHandlers({
|
||||
tenantId: subject.tenantId,
|
||||
topK: plan.retrievalPlan.topK,
|
||||
});
|
||||
await appendResearchTaskQueryRequested({
|
||||
job,
|
||||
occurredAt: new Date(now()).toISOString(),
|
||||
overview,
|
||||
query: body.query,
|
||||
source: answerTraceSourceForCaller({
|
||||
callerKind,
|
||||
capabilityCallerKind: capabilityGrant?.callerKind,
|
||||
}),
|
||||
subject,
|
||||
});
|
||||
|
||||
return context.json(toPublicResearchTaskJob(job), 201);
|
||||
} catch (error) {
|
||||
@ -927,3 +946,58 @@ async function authorizeJobAccess(input: {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the Research task as a knowledge-space query. The overview derives query volume from
|
||||
* `query.requested` events and marks the task answered when the job state machine emits
|
||||
* `query.completed` for the same id. Best effort: task creation must not fail because the
|
||||
* activity feed is unavailable.
|
||||
*/
|
||||
async function appendResearchTaskQueryRequested({
|
||||
job,
|
||||
occurredAt,
|
||||
overview,
|
||||
query,
|
||||
source,
|
||||
subject,
|
||||
}: {
|
||||
readonly job: {
|
||||
readonly id: string;
|
||||
readonly knowledgeSpaceId: string;
|
||||
readonly mode?: string | undefined;
|
||||
readonly tenantId: string;
|
||||
};
|
||||
readonly occurredAt: string;
|
||||
readonly overview: Pick<KnowledgeSpaceOverviewRepository, "appendActivity"> | undefined;
|
||||
readonly query: string | undefined;
|
||||
readonly source: string;
|
||||
readonly subject: KnowledgeGatewayEnv["Variables"]["subject"];
|
||||
}): Promise<void> {
|
||||
if (!overview) return;
|
||||
try {
|
||||
await overview.appendActivity({
|
||||
action: "query.requested",
|
||||
actor: { id: subject.subjectId, type: "member" },
|
||||
details: {
|
||||
mode: job.mode ?? "research",
|
||||
...(query ? { question: query } : {}),
|
||||
source,
|
||||
taskKind: "research",
|
||||
},
|
||||
id: deterministicKnowledgeSpaceActivityId(
|
||||
"query.requested",
|
||||
job.tenantId,
|
||||
job.knowledgeSpaceId,
|
||||
job.id,
|
||||
),
|
||||
knowledgeSpaceId: job.knowledgeSpaceId,
|
||||
occurredAt,
|
||||
requiredPermissionScope: [],
|
||||
resource: { id: job.id, type: "query" },
|
||||
result: "success",
|
||||
tenantId: job.tenantId,
|
||||
});
|
||||
} catch {
|
||||
// Best effort: see the function comment.
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,6 +4,10 @@ import {
|
||||
type JobPayload,
|
||||
type JobQueueAdapter,
|
||||
} from "@knowledge/core";
|
||||
import {
|
||||
type KnowledgeSpaceOverviewRepository,
|
||||
deterministicKnowledgeSpaceActivityId,
|
||||
} from "./knowledge-space-overview";
|
||||
|
||||
import type {
|
||||
CapabilityCallerKind,
|
||||
@ -196,6 +200,8 @@ export interface ResearchTaskJobStateMachineOptions {
|
||||
readonly maxQueryBytes?: number;
|
||||
readonly metrics?: DurableTaskOperationalMetrics | undefined;
|
||||
readonly now?: () => number;
|
||||
/** Reports terminal outcomes to the space overview so Research tasks count as answered/failed queries. */
|
||||
readonly overview?: Pick<KnowledgeSpaceOverviewRepository, "appendActivity"> | undefined;
|
||||
readonly progress?: ResearchTaskProgressPublisher | undefined;
|
||||
readonly repository: ResearchTaskJobRepository;
|
||||
}
|
||||
@ -291,6 +297,7 @@ export function createResearchTaskJobStateMachine({
|
||||
maxQueryBytes = defaultMaxQueryBytes,
|
||||
metrics,
|
||||
now = Date.now,
|
||||
overview,
|
||||
progress,
|
||||
repository,
|
||||
}: ResearchTaskJobStateMachineOptions): ResearchTaskJobStateMachine {
|
||||
@ -316,6 +323,7 @@ export function createResearchTaskJobStateMachine({
|
||||
outcome: "completed",
|
||||
taskKind: "research",
|
||||
});
|
||||
await appendResearchTaskOutcome(overview, updated, "query.completed", "success", {});
|
||||
}
|
||||
await progress?.publish(updated, "research_task.stage_changed", {
|
||||
previousStage: job.stage,
|
||||
@ -341,6 +349,9 @@ export function createResearchTaskJobStateMachine({
|
||||
outcome: "canceled",
|
||||
taskKind: "research",
|
||||
});
|
||||
await appendResearchTaskOutcome(overview, updated, "query.failed", "canceled", {
|
||||
reasonCode: "canceled",
|
||||
});
|
||||
await progress?.publish(updated, "research_task.canceled", reason ? { reason } : {});
|
||||
return cloneResearchTaskJob(updated);
|
||||
},
|
||||
@ -363,6 +374,9 @@ export function createResearchTaskJobStateMachine({
|
||||
outcome: "failed",
|
||||
taskKind: "research",
|
||||
});
|
||||
await appendResearchTaskOutcome(overview, updated, "query.failed", "failure", {
|
||||
reasonCode: "failed",
|
||||
});
|
||||
await progress?.publish(updated, "research_task.failed", { error });
|
||||
return cloneResearchTaskJob(updated);
|
||||
},
|
||||
@ -1135,3 +1149,34 @@ function clonePartialResult(result: ResearchTaskPartialResult): ResearchTaskPart
|
||||
function cloneEvidenceBundle(bundle: EvidenceBundle): EvidenceBundle {
|
||||
return JSON.parse(JSON.stringify(bundle)) as EvidenceBundle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror a terminal Research outcome into the space overview. The task was counted as a query
|
||||
* when it was created; `query.completed` marks it answered and `query.failed` keeps it in the
|
||||
* unanswered bucket. Best effort: the state transition has already been persisted.
|
||||
*/
|
||||
async function appendResearchTaskOutcome(
|
||||
overview: Pick<KnowledgeSpaceOverviewRepository, "appendActivity"> | undefined,
|
||||
job: ResearchTaskJob,
|
||||
action: "query.completed" | "query.failed",
|
||||
result: "canceled" | "failure" | "success",
|
||||
details: Readonly<Record<string, unknown>>,
|
||||
): Promise<void> {
|
||||
if (!overview) return;
|
||||
try {
|
||||
await overview.appendActivity({
|
||||
action,
|
||||
actor: { type: "system" },
|
||||
details: { ...details, taskKind: "research" },
|
||||
id: deterministicKnowledgeSpaceActivityId(action, job.tenantId, job.knowledgeSpaceId, job.id),
|
||||
knowledgeSpaceId: job.knowledgeSpaceId,
|
||||
occurredAt: new Date(job.completedAt ?? job.updatedAt).toISOString(),
|
||||
requiredPermissionScope: [],
|
||||
resource: { id: job.id, type: "query" },
|
||||
result,
|
||||
tenantId: job.tenantId,
|
||||
});
|
||||
} catch {
|
||||
// Best effort: see the function comment.
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,21 +90,28 @@ describe("retrieval test route", () => {
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// The node forwards this id when it captures an empty retrieval, so one record stays per run.
|
||||
expect((await response.json()) as Record<string, unknown>).toMatchObject({
|
||||
answerTraceId: expectedTraceId,
|
||||
});
|
||||
expect(overview.appendActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "query.requested",
|
||||
details: expect.objectContaining({ mode: "fast" }),
|
||||
details: expect.objectContaining({ mode: "fast", source: "workflow" }),
|
||||
resource: { id: expectedTraceId, type: "query" },
|
||||
}),
|
||||
);
|
||||
expect(answerTraceRecorder.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
capabilityGrantId: "10000000-0000-4000-8000-000000000011",
|
||||
source: "workflow",
|
||||
traceId: expectedTraceId,
|
||||
steps: [
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
queryOutcome: expectedOutcome,
|
||||
resultCount: result.items.length,
|
||||
stages: result.stages,
|
||||
workflowQueryId: WORKFLOW_QUERY_ID,
|
||||
}),
|
||||
name: "query.generate",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"]),
|
||||
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import {
|
||||
type AuthSubject,
|
||||
type EvidenceBundle,
|
||||
type KnowledgeSpaceEmbeddingProfile,
|
||||
type KnowledgeSpaceModelSelection,
|
||||
type KnowledgeSpaceRetrievalProfile,
|
||||
@ -9,6 +10,7 @@ import type { EmbeddingProvider } from "@knowledge/embeddings";
|
||||
|
||||
import { runWithAbortSignal } from "./bounded-concurrency";
|
||||
import { candidatePermissionScopeAllows } from "./candidate-content-authorization";
|
||||
import { createEvidenceBundleAssembler } from "./evidence-bundle-assembler";
|
||||
import {
|
||||
type KnowledgeSpaceEmbeddingResolver,
|
||||
assertEmbeddingModelMatchesProfile,
|
||||
@ -30,6 +32,7 @@ import {
|
||||
QUERY_IMAGE_MAX_COUNT,
|
||||
QueryImageDegradationReasonSchema,
|
||||
type ResolvedQueryImage,
|
||||
queryImageMetadata,
|
||||
} from "./query-images";
|
||||
import type { RetrievalMetadataFilters } from "./retrieval-candidates";
|
||||
import type { RetrievalSource } from "./retrieval-candidates";
|
||||
@ -69,6 +72,12 @@ export interface RetrievalTestStage {
|
||||
}
|
||||
|
||||
export interface RetrievalTestResult {
|
||||
/**
|
||||
* The same EvidenceBundle a query would have produced from this retrieval, so the run can be
|
||||
* recorded as an AnswerTrace with evidence for the retrieval history. Absent when the bundle
|
||||
* could not be assembled; the diagnostics above are unaffected.
|
||||
*/
|
||||
readonly evidenceBundle?: EvidenceBundle | undefined;
|
||||
readonly items: readonly {
|
||||
readonly citation: {
|
||||
readonly artifactHash: string;
|
||||
@ -294,7 +303,13 @@ export function createRetrievalTestExecutor({
|
||||
permissionScope: input.permissionScope,
|
||||
profile: input.retrievalProfile,
|
||||
});
|
||||
const evidenceBundle = assembleRetrievalTestEvidenceBundle({
|
||||
query: preparedQuery.query || input.query,
|
||||
queryImages: preparedQuery.queryImages,
|
||||
retrieval,
|
||||
});
|
||||
return {
|
||||
...(evidenceBundle ? { evidenceBundle } : {}),
|
||||
items: retrieval.items.map((item) =>
|
||||
safeRetrievalTestItem(item, input.includeText === true),
|
||||
),
|
||||
@ -391,6 +406,37 @@ async function resolveRetrievalTestEmbedding({
|
||||
return [...vector];
|
||||
}
|
||||
|
||||
const retrievalTestEvidenceBundleAssembler = createEvidenceBundleAssembler();
|
||||
|
||||
/**
|
||||
* Build the history EvidenceBundle for a retrieval test. Assembly validates every evidence item,
|
||||
* so a retrieval whose items cannot be projected (for example text-less legacy nodes) yields no
|
||||
* bundle rather than failing the diagnostics the caller asked for.
|
||||
*/
|
||||
function assembleRetrievalTestEvidenceBundle({
|
||||
query,
|
||||
queryImages,
|
||||
retrieval,
|
||||
}: {
|
||||
readonly query: string;
|
||||
readonly queryImages: readonly ResolvedQueryImage[];
|
||||
readonly retrieval: Parameters<EvidenceBundleAssemblerAssemble>[0]["retrieval"];
|
||||
}): EvidenceBundle | undefined {
|
||||
const images = queryImages.map(queryImageMetadata);
|
||||
if (!query.trim() && images.length === 0) return undefined;
|
||||
try {
|
||||
return retrievalTestEvidenceBundleAssembler.assemble({
|
||||
query,
|
||||
...(images.length > 0 ? { queryImages: images } : {}),
|
||||
retrieval,
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type EvidenceBundleAssemblerAssemble = ReturnType<typeof createEvidenceBundleAssembler>["assemble"];
|
||||
|
||||
async function prepareRetrievalTestQuery({
|
||||
input,
|
||||
provider,
|
||||
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -77,17 +77,16 @@ export function createWorkflowFailedRetrievalCaptureService({
|
||||
};
|
||||
let failedQuery = await failedQueries.get(lookup);
|
||||
let traceCapabilityGrantId = input.capabilityGrantId;
|
||||
let answerTraceId = failedQuery?.answerTraceId ?? input.eventId;
|
||||
|
||||
if (!failedQuery) {
|
||||
traceCapabilityGrantId = await ensureWorkflowAnswerTrace(
|
||||
answerTraces,
|
||||
answerTraceRecorder,
|
||||
input,
|
||||
);
|
||||
const trace = await ensureWorkflowAnswerTrace(answerTraces, answerTraceRecorder, input);
|
||||
traceCapabilityGrantId = trace.capabilityGrantId;
|
||||
answerTraceId = trace.id;
|
||||
}
|
||||
failedQuery = await failedQueries.captureWorkflowFailedRetrieval({
|
||||
actorSubjectId: input.actorSubjectId,
|
||||
answerTraceId: input.eventId,
|
||||
answerTraceId,
|
||||
candidateGrants: input.candidateGrants,
|
||||
capabilityGrantId: input.capabilityGrantId,
|
||||
id: input.eventId,
|
||||
@ -141,18 +140,36 @@ export function createWorkflowFailedRetrievalCaptureService({
|
||||
"Workflow retrieval returned no evidence even though the knowledge base appears to contain relevant answer material.",
|
||||
tags: ["workflow", "auto-captured", "retrieval-miss"],
|
||||
tenantId: input.tenantId,
|
||||
traceId: input.eventId,
|
||||
traceId: answerTraceId,
|
||||
});
|
||||
return { badCaseId: badCase.id, failedQueryId: failedQuery.id, verdict };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the AnswerTrace a failed workflow retrieval belongs to.
|
||||
*
|
||||
* The retrieval-tests route already records one trace per workflow run and returns its id as
|
||||
* `answerTraceId`, which the node forwards as `retrievalTraceId`; attaching the failed query to
|
||||
* that trace keeps one history record per retrieval and lets the overview classify the query as
|
||||
* "no evidence". Older nodes send an opaque transport trace id instead, in which case a minimal
|
||||
* trace keyed by the capture event id is created (or replayed) as before.
|
||||
*/
|
||||
async function ensureWorkflowAnswerTrace(
|
||||
answerTraces: Pick<AnswerTraceRepository, "get">,
|
||||
recorder: AnswerTraceRecorder,
|
||||
input: CaptureWorkflowFailedRetrievalInput,
|
||||
): Promise<string> {
|
||||
): Promise<{ readonly capabilityGrantId: string; readonly id: string }> {
|
||||
if (UUID_PATTERN.test(input.retrievalTraceId) && input.retrievalTraceId !== input.eventId) {
|
||||
const retrievalTrace = await answerTraces.get({
|
||||
id: input.retrievalTraceId,
|
||||
knowledgeSpaceId: input.knowledgeSpaceId,
|
||||
});
|
||||
if (retrievalTrace?.capabilityGrantId && retrievalTrace.query === input.query) {
|
||||
return { capabilityGrantId: retrievalTrace.capabilityGrantId, id: retrievalTrace.id };
|
||||
}
|
||||
}
|
||||
const existing = await answerTraces.get({
|
||||
id: input.eventId,
|
||||
knowledgeSpaceId: input.knowledgeSpaceId,
|
||||
@ -162,13 +179,14 @@ async function ensureWorkflowAnswerTrace(
|
||||
if (!existing.capabilityGrantId) {
|
||||
throw new WorkflowFailedRetrievalReplayConflictError(input.eventId);
|
||||
}
|
||||
return existing.capabilityGrantId;
|
||||
return { capabilityGrantId: existing.capabilityGrantId, id: existing.id };
|
||||
}
|
||||
const recorded = await recorder.record({
|
||||
capabilityGrantId: input.capabilityGrantId,
|
||||
knowledgeSpaceId: input.knowledgeSpaceId,
|
||||
mode: input.mode,
|
||||
query: input.query,
|
||||
source: "workflow",
|
||||
steps: [
|
||||
{
|
||||
metadata: {
|
||||
@ -188,9 +206,11 @@ async function ensureWorkflowAnswerTrace(
|
||||
if (!recorded.capabilityGrantId) {
|
||||
throw new Error("Workflow failed-retrieval AnswerTrace lost Capability provenance");
|
||||
}
|
||||
return recorded.capabilityGrantId;
|
||||
return { capabilityGrantId: recorded.capabilityGrantId, id: recorded.id };
|
||||
}
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
||||
|
||||
function assertWorkflowAnswerTraceReplay(
|
||||
existing: AnswerTrace,
|
||||
input: CaptureWorkflowFailedRetrievalInput,
|
||||
|
||||
@ -1533,6 +1533,25 @@ export const AnswerTraceStepSchema = z.object({
|
||||
});
|
||||
export type AnswerTraceStep = z.infer<typeof AnswerTraceStepSchema>;
|
||||
|
||||
/**
|
||||
* Which caller produced an AnswerTrace. Console retrieval tests, KnowledgeFS workflow nodes,
|
||||
* service API clients, agents, and MCP clients all record traces into the same history, and the
|
||||
* console distinguishes them by this value. Rows written before the column existed are
|
||||
* `retrieval_test`.
|
||||
*/
|
||||
export const AnswerTraceSourceSchema = z.enum([
|
||||
"retrieval_test",
|
||||
"workflow",
|
||||
"service_api",
|
||||
"agent",
|
||||
"mcp",
|
||||
]);
|
||||
export type AnswerTraceSource = z.infer<typeof AnswerTraceSourceSchema>;
|
||||
|
||||
export function answerTraceSourceOf(trace: { readonly source?: AnswerTraceSource | undefined }) {
|
||||
return trace.source ?? "retrieval_test";
|
||||
}
|
||||
|
||||
export const AnswerTraceSchema = z.object({
|
||||
/** Durable Capability v2 provenance; mutually exclusive with legacy member snapshots. */
|
||||
capabilityGrantId: UuidSchema.optional(),
|
||||
@ -1550,6 +1569,8 @@ export const AnswerTraceSchema = z.object({
|
||||
.optional(),
|
||||
query: z.string(),
|
||||
queryImages: z.array(QueryImageMetadataSchema).max(4).optional(),
|
||||
/** Absent on legacy rows; read it through `answerTraceSourceOf`. */
|
||||
source: AnswerTraceSourceSchema.optional(),
|
||||
/** Authenticated creator. Legacy traces without it are intentionally unreadable via the API. */
|
||||
subjectId: z.string().min(1).max(255).optional(),
|
||||
steps: z.array(AnswerTraceStepSchema),
|
||||
|
||||
@ -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");
|
||||
@ -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`);
|
||||
@ -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[];
|
||||
|
||||
@ -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",
|
||||
}),
|
||||
|
||||
@ -1197,6 +1197,7 @@ export type KnowledgeFsAnswerTraceResponse = {
|
||||
mode: 'auto' | 'deep' | 'fast' | 'research'
|
||||
query: string
|
||||
query_images?: Array<KnowledgeFsQueryImageResponse>
|
||||
source?: KnowledgeFsTraceSource
|
||||
steps: Array<KnowledgeFsAnswerTraceStepResponse>
|
||||
}
|
||||
|
||||
@ -2164,9 +2165,12 @@ export type KnowledgeFsTraceResponse = {
|
||||
query_images?: Array<KnowledgeFsQueryImageResponse>
|
||||
result_count: number
|
||||
scores: KnowledgeFsTraceScoresResponse
|
||||
source?: KnowledgeFsTraceSource
|
||||
stages: Array<KnowledgeFsTraceStageResponse>
|
||||
}
|
||||
|
||||
export type KnowledgeFsTraceSource = 'agent' | 'mcp' | 'retrieval_test' | 'service_api' | 'workflow'
|
||||
|
||||
export type KnowledgeFsAnswerTraceStepResponse = {
|
||||
ended_at?: string | null
|
||||
metadata: {
|
||||
@ -4404,6 +4408,7 @@ export type GetKnowledgeFsSpacesByControlSpaceIdTracesData = {
|
||||
}
|
||||
query?: {
|
||||
cursor?: string
|
||||
source?: 'agent' | 'mcp' | 'retrieval_test' | 'service_api' | 'workflow'
|
||||
}
|
||||
url: '/knowledge-fs/spaces/{control_space_id}/traces'
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@ -1269,6 +1269,7 @@ export type KnowledgeFsAnswerTraceResponse = {
|
||||
mode: 'auto' | 'deep' | 'fast' | 'research'
|
||||
query: string
|
||||
query_images?: Array<KnowledgeFsQueryImageResponse>
|
||||
source?: KnowledgeFsTraceSource
|
||||
steps: Array<KnowledgeFsAnswerTraceStepResponse>
|
||||
}
|
||||
|
||||
@ -2487,6 +2488,7 @@ export type KnowledgeFsTraceResponse = {
|
||||
query_images?: Array<KnowledgeFsQueryImageResponse>
|
||||
result_count: number
|
||||
scores: KnowledgeFsTraceScoresResponse
|
||||
source?: KnowledgeFsTraceSource
|
||||
stages: Array<KnowledgeFsTraceStageResponse>
|
||||
}
|
||||
|
||||
@ -2496,6 +2498,8 @@ export type KnowledgeFsTraceScoresResponse = {
|
||||
retrieval?: number | null
|
||||
}
|
||||
|
||||
export type KnowledgeFsTraceSource = 'agent' | 'mcp' | 'retrieval_test' | 'service_api' | 'workflow'
|
||||
|
||||
export type KnowledgeFsTraceStageResponse = {
|
||||
candidate_count?: number | null
|
||||
name: string
|
||||
@ -6048,6 +6052,7 @@ export type GetKnowledgeFsSpacesByControlSpaceIdTracesData = {
|
||||
}
|
||||
query?: {
|
||||
cursor?: string
|
||||
source?: 'agent' | 'mcp' | 'retrieval_test' | 'service_api' | 'workflow'
|
||||
}
|
||||
url: '/knowledge-fs/spaces/{control_space_id}/traces'
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@ -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' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import type { RetrievalHistorySourceFilter } from './state/scoped'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordButton } from './history'
|
||||
@ -9,20 +11,40 @@ import {
|
||||
retrievalHistoryFactsAtom,
|
||||
selectRetrievalRecordAtom,
|
||||
} from './state/graph'
|
||||
import { retrievalHistorySourceFilterAtom } from './state/scoped'
|
||||
|
||||
const sourceFilters = ['all', 'retrieval_test', 'workflow'] as const
|
||||
|
||||
export function RetrievalHistoryPanel() {
|
||||
const { t } = useTranslation('knowledgeSpace')
|
||||
const { activeRecordKey, displayRecords, hasNextPage, isFetchingNextPage } =
|
||||
const { activeRecordKey, displayRecords, hasNextPage, isFetchingNextPage, sourceFilter } =
|
||||
useAtomValue(retrievalHistoryFactsAtom)
|
||||
const selectRecord = useSetAtom(selectRetrievalRecordAtom)
|
||||
const loadMore = useSetAtom(loadMoreRetrievalHistoryAtom)
|
||||
const setSourceFilter = useSetAtom(retrievalHistorySourceFilterAtom)
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex min-h-0 flex-1 flex-col pt-6">
|
||||
<div className="flex shrink-0 items-center pb-2 pl-3">
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 pr-3 pb-2 pl-3">
|
||||
<h2 className="system-xs-medium text-text-tertiary">
|
||||
{t(($) => $['retrievalTest.records'])}
|
||||
</h2>
|
||||
<SegmentedControl<RetrievalHistorySourceFilter>
|
||||
aria-label={t(($) => $['retrievalTest.sourceFilter.label'])}
|
||||
className="shrink-0"
|
||||
value={sourceFilter}
|
||||
onValueChange={setSourceFilter}
|
||||
>
|
||||
{sourceFilters.map((filter) => (
|
||||
<SegmentedControlItem<RetrievalHistorySourceFilter>
|
||||
key={filter}
|
||||
value={filter}
|
||||
className="px-1.5 py-0.5 system-2xs-medium"
|
||||
>
|
||||
{t(($) => $[`retrievalTest.sourceFilter.${filter}`])}
|
||||
</SegmentedControlItem>
|
||||
))}
|
||||
</SegmentedControl>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 scrollbar-none overflow-y-auto">
|
||||
{displayRecords.length > 0 ? (
|
||||
|
||||
@ -575,6 +575,14 @@ export function RecordButton({
|
||||
{queryImageCount}
|
||||
</span>
|
||||
)}
|
||||
{record.kind === 'trace' && record.source !== 'retrieval_test' && (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center rounded-[5px] border border-divider-deep px-1 system-2xs-medium-uppercase text-text-tertiary"
|
||||
data-testid="retrieval-record-source"
|
||||
>
|
||||
{t(($) => $[`retrievalTest.source.${record.source}`])}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-1.5 flex items-center gap-1 system-xs-regular text-text-tertiary">
|
||||
<span className={cn('min-w-0 flex-1 truncate', failed && 'text-text-destructive')}>
|
||||
|
||||
@ -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 => ({
|
||||
|
||||
@ -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),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -62,6 +62,9 @@ export const retrievalAdmittedResearchTasksAtom = atom<
|
||||
Record<string, KnowledgeFsResearchTaskResponse>
|
||||
>({})
|
||||
export const retrievalResearchRetryPendingAtom = atom(false)
|
||||
/** History filter: every record, only console retrieval tests, or only workflow-node queries. */
|
||||
export type RetrievalHistorySourceFilter = 'all' | 'retrieval_test' | 'workflow'
|
||||
export const retrievalHistorySourceFilterAtom = atom<RetrievalHistorySourceFilter>('all')
|
||||
|
||||
export const retrievalScopedAtoms = [
|
||||
retrievalComposerDraftAtom,
|
||||
@ -73,5 +76,6 @@ export const retrievalScopedAtoms = [
|
||||
retrievalResearchEventsAtom,
|
||||
retrievalAdmittedResearchTasksAtom,
|
||||
retrievalResearchRetryPendingAtom,
|
||||
retrievalHistorySourceFilterAtom,
|
||||
retrievalRuntimeBridgeAtom,
|
||||
] as const
|
||||
|
||||
@ -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": "عرض في الجودة",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "نمایش در بخش کیفیت",
|
||||
|
||||
@ -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é",
|
||||
|
||||
@ -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": "गुणवत्ता में देखें",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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à",
|
||||
|
||||
@ -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": "品質で表示",
|
||||
|
||||
@ -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": "품질에서 보기",
|
||||
|
||||
@ -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": "ເບິ່ງໃນໜ້າຄຸນະພາບ",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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ść",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Открыть в разделе «Качество»",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "ดูในคุณภาพ",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Відкрити в розділі «Якість»",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "前往“质量”页查看",
|
||||
|
||||
@ -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": "在品質中查看",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user