From b253b7b098012b621958a31e0af0bb4e7b3fd4b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Mon, 13 Jul 2026 19:28:59 +0900 Subject: [PATCH 01/15] =?UTF-8?q?docs(knowledge):=20Collection=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8C=85=20=ED=86=B5=ED=95=A9=20=EA=B3=84=EC=95=BD=20?= =?UTF-8?q?=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 6 +- docs/data_model.md | 9 +- ...workflow-collection-routing-integration.md | 165 ++++++++++++++++++ docs/decisions/README.md | 3 +- docs/features/knowledge/api_spec.md | 57 ++++++ docs/features/knowledge/component_spec.md | 21 +++ docs/features/knowledge/requirements.md | 10 ++ docs/features/knowledge/test_cases.md | 41 +++++ docs/features/workflow/api_spec.md | 46 +++++ docs/features/workflow/component_spec.md | 22 +++ docs/features/workflow/requirements.md | 9 + docs/features/workflow/test_cases.md | 25 +++ 12 files changed, 410 insertions(+), 4 deletions(-) create mode 100644 docs/decisions/ADR-0039-knowledge-workflow-collection-routing-integration.md diff --git a/docs/architecture.md b/docs/architecture.md index ca526dc70..6dfd3a7f5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,7 @@ Security Alert MVP는 [ADR-0028](decisions/ADR-0028-security-alert-detection-and | Security Alert Admin Service | Gateway application/service boundary | organization owner/manager 전용 alert 조회·상태 변경, safe evidence projection, lifecycle audit transaction을 제공한다 | | Security Alert Notification Projection | Gateway/Client notification boundary | 영속 alert를 source of truth로 두고 Sidebar summary와 `notifications.changed` 재조회 신호를 제공한다 | -Knowledge 통합 목표 구조에서는 Gateway/Shared/Workflow Engine 경계에 다음 domain service를 둔다. 아래 항목은 현재 구현 컴포넌트 전체가 아니라 [ADR-0014](decisions/ADR-0014-knowledge-base-document-atom-and-collection-boundary.md), [ADR-0015](decisions/ADR-0015-knowledge-skill-context-routing-boundary.md), [ADR-0017](decisions/ADR-0017-knowledge-integration-provisional-implementation-baseline.md), [ADR-0020](decisions/ADR-0020-knowledge-mcp-incremental-sync-boundary.md), [ADR-0036](decisions/ADR-0036-knowledge-runtime-candidate-resolution.md)의 target component다. +Knowledge 통합 목표 구조에서는 Gateway/Shared/Workflow Engine 경계에 다음 domain service를 둔다. 아래 항목은 현재 구현 컴포넌트 전체가 아니라 [ADR-0014](decisions/ADR-0014-knowledge-base-document-atom-and-collection-boundary.md), [ADR-0015](decisions/ADR-0015-knowledge-skill-context-routing-boundary.md), [ADR-0017](decisions/ADR-0017-knowledge-integration-provisional-implementation-baseline.md), [ADR-0020](decisions/ADR-0020-knowledge-mcp-incremental-sync-boundary.md), [ADR-0036](decisions/ADR-0036-knowledge-runtime-candidate-resolution.md), [ADR-0039](decisions/ADR-0039-knowledge-workflow-collection-routing-integration.md)의 target component다. | 구성요소 | 책임 | | --- | --- | @@ -40,7 +40,9 @@ Knowledge 통합 목표 구조에서는 Gateway/Shared/Workflow Engine 경계에 | Knowledge Normalizer / Ingestion Pipeline | source item을 redacted canonical text와 document version artifact로 변환하고, indexing 성공 후 active version finalization을 수행한다. | | Knowledge Permission Helper | collection route 권한, KB `use`, source ACL freshness/requester authorization을 bulk 평가한다. Router, Builder, Workflow LLM node runtime은 permission row를 직접 조합하지 않는다. | | Knowledge Administration Application | KB object/property authorization, owner migration/bootstrap, organization-scoped domain delegation, self-escalation policy, lifecycle와 transaction-bound audit를 조율한다. Domain 관리 권한은 KB content/Collection route에 합산하지 않는다 ([ADR-0034](decisions/ADR-0034-knowledge-delegated-administration-and-rbac-boundary.md)). | -| Workflow Runtime Knowledge Candidate Resolver | Shared pure policy와 Workflow Engine `runtime_retrieval` use case/port, PostgreSQL adapter로 direct KB와 명시 selected Collection을 current audience 기준 재평가한다. Invocation마다 `REPEATABLE READ, READ ONLY` snapshot을 사용하고 Gateway Builder resolver를 import하지 않는다. API/graph/LLM wiring은 MBA-233 범위다 ([ADR-0036](decisions/ADR-0036-knowledge-runtime-candidate-resolution.md)). | +| Workflow Runtime Knowledge Candidate Resolver | Shared pure policy와 Workflow Engine `runtime_retrieval` use case/port, PostgreSQL adapter로 direct KB와 명시 selected Collection을 current audience 기준 재평가한다. Invocation마다 `REPEATABLE READ, READ ONLY` snapshot을 사용하고 Gateway Builder resolver를 import하지 않는다. MBA-232가 resolver seam을 구현했고 MBA-233은 additive graph/Builder/preflight/LLM retrieval wiring을 연결한다 ([ADR-0036](decisions/ADR-0036-knowledge-runtime-candidate-resolution.md), [ADR-0039](decisions/ADR-0039-knowledge-workflow-collection-routing-integration.md)). | +| Workflow Knowledge Reference Service | Gateway editable-graph write에서 direct KB effective `use`/source gate와 selected Collection `route`를 current editor 기준으로 재검증한다. Collection child를 열거하거나 runtime capability를 발급하지 않고 graph/success audit transaction 앞에서 fail-closed한다. | +| Route-safe Collection Picker | active organization의 active Collection 중 current editor가 `route` 가능한 UUID와 approved safe label만 Builder에 제공한다. Collection management projection이나 runtime resolver를 재사용하지 않는다. | | Collection Router / Retrieval Orchestrator | 권한 helper가 허용한 safe candidate set에서 collection/KB를 선택하고, metadata-aware/hierarchical retrieval 결과를 merge/rerank한다. | | Knowledge Skill Registry | Workflow Builder가 LLM node의 RAG 옵션을 구성할 때 사용할 provider-neutral Skill, version, visibility, freshness/eval 상태를 관리하는 target component다. Skill은 권한 source나 source of truth가 아니다. | | Skill Context Loader | 후속 target component로, 빌더 단계에서 safe skill metadata와 필요한 checklist/body를 gate 통과 후 점진적으로 로드한다. MBA-145 Agent Builder MVP는 Knowledge Skill body/checklist를 prompt context로 직접 로드하지 않고 ADR-0017 기본 RAG option 후보와 KB safe metadata만 사용한다. Raw skill body, hidden source reference, raw source title/path/url은 Builder input으로 제공하지 않는다. | diff --git a/docs/data_model.md b/docs/data_model.md index b713da3bf..f047605be 100644 --- a/docs/data_model.md +++ b/docs/data_model.md @@ -310,6 +310,13 @@ project/endpoint boundary. | created_at / updated_at | DATETIME | NOT NULL | - UNIQUE `(id, organization_id)` — user direct permission의 복합 FK 대상. +- MBA-233 LLM node data는 `knowledgeBases`와 additive `knowledgeCollections` reference + 목록을 함께 저장할 수 있다. 각 item은 canonical UUID와 bounded display snapshot만 + 가지며 display 값은 permission/routing/source 판단에 사용하지 않는다. +- `knowledgeCollections`는 Collection membership snapshot이 아니다. 실행 시점에 + current Collection item/permission을 MBA-232 resolver가 다시 읽으므로 이 field를 + 위해 새 relation, migration 또는 `llm_node_versions` column을 추가하지 않는다 + ([ADR-0039](decisions/ADR-0039-knowledge-workflow-collection-routing-integration.md)). #### `workflow_budgets` @@ -339,7 +346,7 @@ workflow 단위 월간 LLM 예산 ([features/budget-management](features/budget- | app_id | UUID | NOT NULL, FK→apps.id (CASCADE) | | version | INTEGER | NOT NULL | | type | VARCHAR(13) | NOT NULL — deployment type (api/webapp/widget/mcp/workflow_node/schedule/webhook/chatbot) | -| graph_snapshot | JSONB | NOT NULL — 배포 시점 graph 고정본. MBA-190 이후 server가 계산한 WorkflowNode target deployment ID/version/snapshot-hash internal binding을 포함할 수 있으며 public graph 응답에서는 제거 | +| graph_snapshot | JSONB | NOT NULL — 배포 시점 graph 고정본. MBA-190 이후 server가 계산한 WorkflowNode target deployment ID/version/snapshot-hash internal binding을 포함할 수 있다. MBA-233 LLM node의 `knowledgeBases`/`knowledgeCollections` configured intent도 보존하되 public graph 응답에서는 internal binding과 두 Knowledge reference 목록을 제거한다 | | config / input_schema / output_schema | JSONB | NULL | | description | VARCHAR | NULL | | created_by | UUID | NOT NULL, FK→users.id | diff --git a/docs/decisions/ADR-0039-knowledge-workflow-collection-routing-integration.md b/docs/decisions/ADR-0039-knowledge-workflow-collection-routing-integration.md new file mode 100644 index 000000000..9e3253f80 --- /dev/null +++ b/docs/decisions/ADR-0039-knowledge-workflow-collection-routing-integration.md @@ -0,0 +1,165 @@ +# ADR-0039: Workflow Knowledge Collection 라우팅 통합 + +Status: Accepted + +Related ADRs: [ADR-0018](ADR-0018-workflow-rag-anonymous-public-only-runtime.md), [ADR-0019](ADR-0019-agent-builder-preview-apply-save-boundary.md), [ADR-0022](ADR-0022-incremental-hexagonal-architecture-adoption.md), [ADR-0034](ADR-0034-knowledge-delegated-administration-and-rbac-boundary.md), [ADR-0036](ADR-0036-knowledge-runtime-candidate-resolution.md) + +## Context + +MBA-232는 direct Knowledge Base와 명시 selected Knowledge Collection을 current +execution audience 기준으로 해석하는 pure policy, Workflow Engine application +use case/port, PostgreSQL `REPEATABLE READ, READ ONLY` adapter와 composition을 +구현했다. 그러나 현재 LLM node graph는 `knowledgeBases`만 저장하고, Builder, +Gateway save/preflight, LLM runtime과 retrieval fan-out은 MBA-232 resolver를 호출하지 +않는다. + +기존 Worker는 모르는 Pydantic field를 무시할 수 있다. 따라서 Client가 +`knowledgeCollections`를 먼저 저장하면 구 Worker가 Collection-only LLM node를 +Knowledge 비활성 node로 실행하고 근거 없는 provider 호출을 할 수 있다. 또한 +Collection 관리 목록의 `read/manage` projection을 picker에 재사용하거나 저장된 label, +Client capability, preflight 결과를 실행 권한으로 사용하면 Collection `route`, child KB +`use`, source authorization 책임이 섞인다. + +## Options + +1. 저장 시 Collection child를 `knowledgeBases`로 정적 확장한다. +2. Gateway Builder candidate resolver를 Worker에서 호출하거나 import한다. +3. Graph에는 direct KB와 selected Collection intent를 별도로 저장하고 Builder/save, + preflight, runtime마다 자기 audience와 책임에 맞는 검증을 수행한다. + +## Decision + +선택지 3을 채택한다. + +### Additive graph contract + +- LLM node data는 기존 `knowledgeBases`와 새 `knowledgeCollections`를 함께 가질 수 + 있다. 두 목록은 상호 배타적인 mode가 아니다. +- `knowledgeBases` item은 canonical UUID `id`와 legacy display snapshot `name`, + `knowledgeCollections` item은 canonical UUID `id`와 optional `safeLabel`만 가진다. +- 각 목록의 최대 configured reference는 20개다. 21번째 reference, malformed object, + non-canonical UUID, 허용되지 않은 field와 control character가 있는 display 값은 + 저장·실행·배포 전에 거부하고 silent slicing이나 자동 삭제를 하지 않는다. +- Display snapshot은 최대 255자이고 UI round-trip에만 사용한다. Permission, source, + routing, audit와 trace 판단은 server-loaded UUID resource만 사용한다. +- Collection membership은 graph에 materialize하지 않는다. 기존 `workflows.graph`와 + `workflow_deployments.graph_snapshot` JSONB 안의 additive field이므로 MBA-233에서 DB + migration이나 `llm_node_versions` column을 추가하지 않는다. + +### Builder picker와 save authorization + +- Direct KB picker는 기존 active-organization, effective KB `use`, source gate와 + retrieval-selectable 조건을 통과한 `/knowledge/llm-selectable`을 사용한다. +- Collection picker는 별도 authenticated endpoint를 사용하고, active organization의 + active Collection 중 current editor가 Collection `route`를 가진 항목만 반환한다. + Response는 UUID와 display-policy-approved optional safe label만 허용한다. +- Collection 관리 목록의 `read/manage/sync`, Knowledge domain 관리 action, raw name, + description, child ID/count, source metadata와 permission row를 picker authority나 + response로 사용하지 않는다. +- 모든 editable graph persistence path는 structural validation 뒤 current editor 기준 + direct KB effective `use`와 selected Collection `route`를 다시 확인한다. Direct + source-managed KB는 기존 source authorization gate도 통과해야 한다. +- Save authorization은 Collection child를 열거하거나 child KB/source permission을 + 평가하지 않는다. Child authorization은 invocation-time MBA-232 resolver가 소유한다. +- Graph와 success audit write는 기존 transaction 안에서 원자적으로 처리한다. 권한 + read 뒤 동시 revoke가 commit되어 configuration intent가 남을 수 있어도 저장 결과를 + runtime capability나 lease로 재사용하지 않고 다음 invocation에서 다시 평가한다. +- 저장된 reference가 picker에서 사라지면 Client는 순서와 reference를 자동 삭제하지 + 않고 generic unavailable 상태로 표시한다. 새 저장은 해당 reference를 제거하거나 + 권한이 복구될 때까지 generic error로 차단한다. + +### Deployment preflight + +- Preflight는 두 목록의 shape/limit와 selected resource의 organization/lifecycle, + deployment type에서 server-derived한 audience 정책을 검증한다. +- Anonymous surface는 private Collection/KB와 public exposure primitive가 없는 + source-managed content를 active deployment에 올리지 못한다. +- `workflow_node_inherited`는 owner나 credential principal로 대체하지 않고 inherited + subject warning을 반환한다. +- Preflight는 child ID, hidden identity, exact denied count를 반환하거나 runtime + capability를 발급하지 않는다. Candidate budget 초과 가능성은 bucket과 + `candidate_budget_limited`의 보수적 warning으로만 표현한다. +- Runtime은 preflight 성공 여부와 무관하게 invocation 시점에 다시 resolve한다. + +### Workflow runtime wiring + +- Direct-only, Collection-only, mixed Knowledge configuration은 모두 MBA-232 + `KnowledgeRuntimeCandidateResolver`를 LLM invocation마다 정확히 한 번 호출한다. +- Execution audience는 server-verified organization과 explicit user + `execution_subject` 또는 `AnonymousPublicAudience`로만 만든다. Workflow owner, + builder, deployment owner, app creator, credential principal과 `user_id`를 Knowledge + audience로 fallback하지 않는다. +- Resolver가 반환한 ordered canonical KB ID만 기존 bounded retrieval fan-out에 + 전달한다. Direct와 여러 Collection에서 중복된 KB는 한 번만 검색한다. +- Policy상 candidate 0개는 retrieval/embedding/provider를 호출하지 않는 safe + no-result다. Resolver infrastructure failure는 `ragFailurePolicy`로 정상 empty + result로 낮추지 않고 raw exception 없는 retryable workflow failure로 전파한다. +- Resolver 이후 일부 authorized KB retrieval timeout은 기존 bounded partial-result와 + evidence sufficiency policy가 처리한다. 근거가 충분하지 않으면 ungrounded provider + 호출을 하지 않는다. +- LLM node는 Collection membership, Collection permission과 child KB permission SQL을 + 직접 실행하지 않는다. MBA-232 adapter가 유일한 runtime candidate authorization + 경계다. + +### Graph consumers and projection + +- Agent Builder, cost optimizer, model-routing refresh, compare/copy/import와 deployment + snapshot은 operation이 Collection selection을 명시적으로 편집하지 않는 한 + `knowledgeCollections`를 그대로 보존한다. +- Agent Builder의 현재 recommendation은 direct KB만 materialize하며 Collection을 + 자동 선택하지 않는다. +- Pre-execution Knowledge sync는 explicit `knowledgeBases`만 처리한다. Collection + children을 미리 확장하거나 live connector를 호출하지 않는다. +- Public app/deployment graph projection은 `knowledgeBases`와 + `knowledgeCollections`를 모두 제거한다. + +### Observability and failure projection + +- Durable trace/log/audit에는 routing mode, configured/selected count bucket, + budget/scan limited flag, partial/failure bucket, fixed reason code와 latency만 추가할 + 수 있다. +- Collection ID/name, provenance Collection ID, hidden KB ID, child list/count, raw graph, + query, source path/URL/title/ACL, credential, prompt/completion과 provider raw payload를 + 새로 저장하거나 response/SSE error에 포함하지 않는다. +- Policy exclusion은 hidden resource별 denial audit을 만들지 않는다. 기존 authorized + KB retrieval audit과 generic request-scoped policy/audit boundary는 유지한다. + +### Rollout and rollback + +- 새 graph field를 노출하기 전에 모든 Workflow Worker를 MBA-233 code로 배포하고 구 + task를 drain한다. 그 다음 Gateway save/preflight를 배포하고 Client selector를 + 마지막에 노출한다. +- Rollback은 Client 노출 중지, Gateway write 중지, queue drain, Worker rollback의 + 역순이다. Collection graph를 구 Worker가 소비할 수 있는 동안에는 rollback을 + 완료한 것으로 보지 않는다. +- 현재 일반 Worker capability registry가 없으므로 자동 version negotiation을 + 구현됐다고 주장하지 않는다. 기존 direct-only graph는 additive field 부재를 빈 + Collection 목록으로 해석해 호환한다. + +## Consequences + +장점: + +- 사용자는 direct KB와 관리자가 묶은 Collection을 같은 LLM node에서 명시적으로 + 조합할 수 있다. +- Builder visibility, save authorization, deployment preflight와 runtime authorization이 + 서로 capability를 재사용하지 않는다. +- Dynamic Collection membership과 permission 변경이 graph rewrite 없이 다음 + invocation에 반영된다. +- 구 graph는 유지하면서 silent truncation과 구 Worker의 ungrounded 실행을 차단한다. + +비용: + +- Shared, Gateway, Client, Workflow Engine과 deployment preflight의 graph consumer를 + 모두 분류하고 테스트해야 한다. +- Worker-first drain이 필요하며 현재는 자동 capability negotiation이 없다. +- Invocation마다 MBA-232 snapshot query 비용이 발생한다. + +## Follow-up + +- Query-aware organization-wide Collection discovery와 Agent Builder Collection 자동 + 추천은 별도 이슈다. +- Service account/operator Knowledge audience, live source authorization/cache와 source + public exposure store는 각각 별도 결정이 필요하다. +- Hierarchical chunking, reranking, query rewrite와 retrieval quality tuning은 이 ADR의 + graph/routing 통합 범위가 아니다. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index add590890..44786a29c 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -55,9 +55,10 @@ ADR 본문은 작성 시점의 결정 과정을 보존하는 기록 문서다. ` | [ADR-0033](ADR-0033-conversation-memory-contract-completion.md) | Accepted | Conversation Memory 계약 공백 보정 | Runtime provenance에 활성 control dependency를 포함하고 ProviderExecutionCapability authority를 LLM Credentials로 고정한다. 초기 session surface에서 Workflow Editor test를 제외하며 Access Grant V1은 standalone rotation/grace 없이 즉시 replacement/revoke한다. 현재 target 설계이며 legacy Memory 구현 완료를 의미하지 않는다. | | [ADR-0034](ADR-0034-knowledge-delegated-administration-and-rbac-boundary.md) | Accepted | Knowledge 위임 관리와 KB RBAC 경계 | MBA-231은 owner를 귀속 정보로 전환하고 KB object/property action, Team/User Knowledge domain delegation, self-escalation 차단, transaction-bound audit, public exposure·hard delete 조직 관리자 경계를 구현한다. | | [ADR-0035](ADR-0035-external-effect-idempotency-boundary.md) | Accepted | Workflow external effect 멱등성 경계 | 모든 실행 표면의 stable execution/node invocation identity와 compare variant 분리, operation 변경 우회를 막는 stable effect slot, 독립 DB session을 사용하는 durable attempt, 같은 execution 재진입과 claim loser 처리, 고정 replay deadline, supported operation에만 적용하는 versioned HMAC key, canonical JSON 최대 65,536-byte replay result와 변경 불가능한 provider contract profile을 확정했다. MBA-190 시점 Generic HTTP mutation과 Slack은 replay `unknown`, GitHub issue comment는 `unsupported`인 보수적 baseline을 구현했으며, 현재 Slack provider 계약은 ADR-0037이 보정한다. | -| [ADR-0036](ADR-0036-knowledge-runtime-candidate-resolution.md) | Accepted | Knowledge runtime candidate resolution 경계 | MBA-232 target은 direct KB와 명시 selected Collection을 execution audience 기준으로 재평가하는 pure policy, Workflow Engine use case/port, PostgreSQL repeatable-read read-only adapter다. API/graph/Builder/preflight/LLM wiring은 MBA-233 범위이며 MBA-232 code 반영 여부는 구현 커밋에서 갱신한다. | +| [ADR-0036](ADR-0036-knowledge-runtime-candidate-resolution.md) | Accepted | Knowledge runtime candidate resolution 경계 | MBA-232는 direct KB와 명시 selected Collection을 execution audience 기준으로 재평가하는 pure policy, Workflow Engine use case/port, PostgreSQL repeatable-read read-only adapter와 실제 PostgreSQL snapshot/동시성 검증을 구현했다. Graph/Builder/preflight/LLM wiring은 [ADR-0039](ADR-0039-knowledge-workflow-collection-routing-integration.md)이 소유한다. | | [ADR-0037](ADR-0037-slack-dedicated-delivery-boundary.md) | Accepted | Slack 전용 전달과 외부 부수효과 경계 | Slack API/Webhook을 전용 operation과 strict response parser로 분리한다. Provider replay는 `unknown`으로 유지하고 성공 safe projection만 durable result로 재사용하며, `429`와 outcome-unknown은 자동 replay하지 않는다. 기존 `slack.http.request.v1`은 과거 attempt 해석용으로 보존한다. | | [ADR-0038](ADR-0038-workflow-aware-adaptive-routing.md) | Accepted | Workflow-Aware Adaptive Routing | 채택된 목표는 운영/Replay evidence, Hard Gate, 적합성 분석, 품질 gate와 결정론적 optimizer를 통과한 후보만 active policy에 반영하는 것이다. 현재 코드는 policy/runtime/trace 기반까지 구현됐고 Replay evidence 연결과 optimizer는 FR-011 진행중이다. | +| [ADR-0039](ADR-0039-knowledge-workflow-collection-routing-integration.md) | Accepted | Workflow Knowledge Collection 라우팅 통합 | MBA-233은 additive `knowledgeCollections` graph, route-safe Builder picker, save-time `use`/`route` 재검증, deployment preflight와 MBA-232 resolver의 LLM/retrieval 연결을 구현한다. Worker-first drain 뒤 Client를 노출하고 public projection과 durable observability에서 Collection identity를 제거한다. | ## 참고 보고서 diff --git a/docs/features/knowledge/api_spec.md b/docs/features/knowledge/api_spec.md index 152222b4b..e691a721f 100644 --- a/docs/features/knowledge/api_spec.md +++ b/docs/features/knowledge/api_spec.md @@ -126,6 +126,63 @@ Membership은 configured Collection별 ordered LATERAL cap을 먼저 적용한 b intermediate relation에서 round-robin ranking한다. Source-policy/provenance expiry는 같은 transaction에서 한 번 읽은 `transaction_timestamp()`를 전체 invocation에 재사용한다. +### MBA-233 Workflow Builder Collection Picker + +`GET /api/v1/knowledge/llm-selectable-collections`는 authenticated Workflow Builder +전용 route-safe projection이다. Collection 관리 목록이나 MBA-232 runtime resolver +response를 재사용하지 않는다. + +Request context: + +| 항목 | 규칙 | +| --- | --- | +| Authentication | 로그인 사용자 필수 | +| Organization | `X-Organization-Id`로 해석한 active organization | +| Permission | active Collection에 대한 current user effective `route` | + +Response: + +```json +{ + "collections": [ + { + "id": "00000000-0000-0000-0000-000000000000", + "safe_label": "사내 문서" + } + ] +} +``` + +`safe_label`은 optional이며 approved safe metadata에 값이 없거나 display policy를 +통과하지 못하면 `null`이다. Response에는 raw Collection name/description, +organization ID, lifecycle/source/system-managed field, member KB ID, exact child count, +permission row/capability, hidden/unavailable total을 포함하지 않는다. `read`, `manage`, +`sync` 또는 Knowledge domain action만 있고 `route`가 없는 Collection은 반환하지 않는다. +다른 organization, inactive/archived/deleted Collection은 존재 여부를 구분하지 않고 +생략한다. Schema/DB failure는 raw SQL/exception 없이 fixed safe error envelope로 닫는다. + +### MBA-233 Editable Graph Reference Authorization + +Workflow draft save, Agent Builder apply, optimizer/model-routing graph persistence는 +graph structural validation 뒤 current editor와 active organization으로 Knowledge +reference를 다시 authorize한다. + +| Reference | Save-time gate | +| --- | --- | +| Direct KB | same organization, active, retrieval-selectable, effective KB `use`, applicable materialized source authorization | +| Selected Collection | same organization, active, effective Collection `route` | + +Collection child membership/KB/source authorization은 save-time에 열거하지 않는다. +Reference 하나라도 실패하면 전체 write와 success audit을 commit하지 않는다. Hidden, +cross-organization, missing, inactive, revoked와 denied 상태는 외부에서 구분하지 않는 +`knowledge_reference_unavailable` 계열 fixed code와 safe field path만 반환하며 UUID, +label, raw graph와 permission reason을 echo하지 않는다. Permission query/DB failure는 +retryable safe infrastructure error이고 partial graph를 만들지 않는다. + +Save authorization result, picker item과 preflight 결과는 capability/token이 아니다. +Direct execute/stream graph는 같은 structural contract를 통과하고 invocation-time +MBA-232 resolver로 current audience를 authorize한다. + ### KB Permission Endpoints MBA-176의 Knowledge 직접 권한 API는 Organization resource permission surface와 같은 응답 envelope를 사용한다. KB 권한은 organization membership의 대체물이 아니며, active organization member에게만 effective permission으로 적용된다. diff --git a/docs/features/knowledge/component_spec.md b/docs/features/knowledge/component_spec.md index 594c70fcc..6b10cdce5 100644 --- a/docs/features/knowledge/component_spec.md +++ b/docs/features/knowledge/component_spec.md @@ -71,6 +71,27 @@ authorization infrastructure failure는 partial candidate를 반환하지 않는 whole-resolution failure다. Budget cap은 successful safe warning이며 downstream retrieval timeout과 구분한다. +### MBA-233 Workflow Collection Routing Integration + +| Component | 책임 | 금지 | +| --- | --- | --- | +| Shared Workflow Knowledge Reference Parser | 두 graph list의 shape, canonical UUID, display snapshot, per-list 20 cap을 pure validation하고 configured order/deduped ID를 제공한다 | Graph mutation, silent slicing, permission/DB 조회 | +| Route-safe Collection Query Service | active organization에서 current editor가 `route` 가능한 active Collection의 UUID와 optional safe label만 bulk projection한다 | Management response 재사용, raw name/description/child count, runtime authorization | +| Workflow Knowledge Reference Service | Editable graph write 전에 direct KB effective `use`/source gate와 Collection `route`를 current editor로 검증하고 whole-write failure를 반환한다 | Collection child expansion, saved label/Client capability 신뢰, runtime lease 발급 | +| Deployment Preflight | 두 list의 structure, lifecycle와 server-derived audience/public gate를 재귀 graph에 적용하고 safe bucket/action을 반환한다 | Child ID/exact hidden count 공개, preflight를 runtime capability로 재사용 | +| Workflow LLM Integration | explicit execution audience와 두 configured ID list로 MBA-232 resolver를 invocation당 한 번 호출하고 ordered KB ID를 Retrieval Orchestrator에 전달한다 | Gateway resolver import, LLM node 내부 permission SQL, owner/credential fallback | +| Public/Observability Projector | public graph에서 두 reference list를 제거하고 durable output은 routing/count/failure safe summary로 제한한다 | Collection identity/provenance, raw graph/query/source/provider payload 저장 | + +Builder는 고정 KB와 Knowledge Collection을 별도 selector group으로 표시한다. 각 group은 +독립 `n/20` limit을 가지며 Collection membership이 실행 시점에 다시 계산된다는 설명을 +표시한다. Picker에서 사라진 saved item은 generic unavailable chip으로 보존하고 +사용자가 제거하거나 권한이 복구되기 전 새 저장을 차단한다. Builder 안에서 +Collection 생성/삭제/permission/membership을 관리하지 않는다. + +Agent Builder와 optimizer는 기존 Collection selection을 보존하지만 자동으로 새 +Collection을 추천하거나 선택하지 않는다. Runtime sync는 explicit direct KB만 처리하고 +Collection child는 MBA-232 materialized provenance/readiness 결과를 사용한다. + Conversation Memory target adapter는 Knowledge Permission Helper의 bulk 결과를 `decision`, `principal_kind`, opaque `authorization_decision_revision`, `resource_revision`, `policy_revision`, `evaluated_at` contract로 투영한다. Source-managed KB의 source ACL revision은 decision revision에 반영한다. Lifecycle, KB permission, source ACL 중 필요한 revision이 없으면 allow를 추정하지 않고 `unknown`을 반환한다. Anonymous public audience에는 subject ID/revision을 합성하지 않는다. Retrieval Orchestrator는 최종 evidence와 함께 KB/document version, organization, sensitivity와 authorization-safe reference를 `RuntimeDataDependencyEnvelope`로 발급한다. Raw title/path/URL/content/ACL은 envelope에 포함하지 않는다. Client나 Workflow node가 canonical Knowledge dependency를 발급할 수 없고, V1에서는 answer content에 영향을 준 모든 Knowledge dependency를 필수로 취급한다. diff --git a/docs/features/knowledge/requirements.md b/docs/features/knowledge/requirements.md index fab99f0df..812a549e3 100644 --- a/docs/features/knowledge/requirements.md +++ b/docs/features/knowledge/requirements.md @@ -130,6 +130,16 @@ Workflow canvas에는 독립형 RAG 실행 노드를 도입하지 않는다. Kno - FR-092 (MBA-232): `KnowledgeCollectionItem`은 lifecycle object가 아니다. Present row는 linked, unlink/missing은 membership 없음이며 Collection과 child KB lifecycle/readiness를 독립적으로 평가한다. - FR-093 (MBA-232): Candidate policy exclusion은 identity를 노출하지 않는 safe omission이고 0개는 `safe_no_result`다. DB/session/snapshot/repository/authorization infrastructure failure는 이미 평가한 후보를 반환하지 않는 whole-resolution retryable safe failure이며 retrieval/provider 호출 전에 끝나야 한다. Partial authorized-KB retrieval timeout은 downstream Retrieval Orchestrator/MBA-233 범위다. - FR-094 (MBA-232): Shared에는 framework/ORM/runtime concrete import가 없는 immutable contract와 pure merge policy만 두고 Workflow Engine `runtime_retrieval` application use case/port와 PostgreSQL adapter/composition이 concrete resolution을 소유한다. Gateway API, graph schema, Builder/preflight, LLM node와 retrieval wiring은 MBA-233 전까지 변경하지 않는다. +- FR-095 (MBA-233): LLM node graph는 기존 `knowledgeBases`와 additive `knowledgeCollections`를 함께 저장할 수 있어야 한다. 각 목록은 최대 20개 canonical UUID reference이며 21번째, malformed object, non-canonical UUID, unknown field와 invalid display snapshot을 silent truncation·자동 삭제 없이 저장/실행/배포 전에 거부해야 한다. Collection membership은 graph에 정적 materialize하지 않는다. +- FR-096 (MBA-233): Builder Collection picker는 active organization의 active Collection 중 current editor가 `route` 가능한 항목만 별도 endpoint에서 받아야 한다. Response는 UUID와 display-policy-approved optional safe label만 포함하고 management `read/manage/sync`, domain action, raw name/description, child identity/count, source metadata와 Client capability를 authority로 사용하지 않아야 한다. +- FR-097 (MBA-233): Editable graph persistence는 current editor 기준으로 direct KB의 active/retrieval-selectable/effective `use`와 applicable source authorization, selected Collection의 active `route`를 server에서 다시 확인해야 한다. 실패한 reference가 하나라도 있으면 graph와 success audit을 원자적으로 저장하지 않고 generic safe error로 닫아야 하며 Collection child를 save-time에 열거하거나 authorize하지 않아야 한다. +- FR-098 (MBA-233): 저장 시 권한 판정 결과는 runtime capability나 lease가 아니다. 권한 read 뒤 동시 revoke가 commit되어 configuration intent가 남아도 다음 invocation은 MBA-232 resolver로 current permission/source state를 다시 평가해야 한다. Picker에서 사라진 saved reference는 Client가 generic unavailable 상태로 보존하되 새 저장은 제거 또는 권한 복구 전까지 차단한다. +- FR-099 (MBA-233): Deployment preflight는 direct KB와 selected Collection의 shape/limit, organization/lifecycle과 server-derived deployment audience를 검증해야 한다. Anonymous surface의 private resource와 source public exposure primitive가 없는 source-managed content는 active deployment를 차단하고, workflow-node inherited audience는 owner/credential fallback 없이 warning 처리한다. Child identity와 exact hidden/denied count는 반환하지 않는다. +- FR-100 (MBA-233): Direct-only, Collection-only, mixed LLM invocation은 MBA-232 resolver를 정확히 한 번 호출하고 ordered canonical candidate KB만 기존 bounded retrieval fan-out에 전달해야 한다. Candidate 0개는 embedding/retrieval/provider 호출 없는 safe no-result이며 resolver infrastructure failure는 `ragFailurePolicy`로 낮추지 않는 sanitized retryable whole-invocation failure다. +- FR-101 (MBA-233): LLM node runtime은 Collection membership/permission 또는 child KB permission SQL을 직접 실행하지 않아야 한다. Execution audience는 server-verified organization과 explicit user execution subject 또는 anonymous public audience만 사용하고 workflow owner, builder, deployment owner, app creator, credential principal과 `user_id`를 Knowledge subject로 fallback하지 않아야 한다. +- FR-102 (MBA-233): Agent Builder, cost optimizer, compare/copy/import, model-routing refresh와 deployment snapshot은 명시적으로 Collection selection을 편집하지 않는 한 `knowledgeCollections`를 보존해야 한다. 현재 Agent Builder recommendation은 direct KB만 materialize하고 Collection을 자동 선택하지 않으며 pre-execution sync는 explicit direct KB만 처리한다. +- FR-103 (MBA-233): Public app/deployment graph projection은 `knowledgeBases`와 `knowledgeCollections`를 모두 제거해야 한다. Durable trace/log/audit에는 routing mode, count bucket, limit/failure flag와 fixed safe reason만 허용하고 Collection/hidden KB identity, child structure, raw graph/query/source/credential/provider payload를 추가하지 않아야 한다. +- FR-104 (MBA-233): `knowledgeCollections`는 기존 graph JSONB의 additive field이며 MBA-233에서 새 DB relation, `llm_node_versions` column 또는 static membership snapshot을 추가하지 않는다. 새 field는 Worker-first 배포와 queue drain 뒤 Gateway write, Client 순서로 노출하고 rollback은 역순으로 수행해야 한다. ## Policies And Edge Cases diff --git a/docs/features/knowledge/test_cases.md b/docs/features/knowledge/test_cases.md index 2e3b8fc5c..b7e1f2cd6 100644 --- a/docs/features/knowledge/test_cases.md +++ b/docs/features/knowledge/test_cases.md @@ -82,6 +82,47 @@ Status: Draft - Query count는 candidate/Collection 수에 비례하는 N+1이 아니고 selected 20 Collections/5,000 membership fixture에서도 scan/memory/result가 bounded하고 fair해야 한다. Membership SQL은 Collection별 LATERAL cap을 global window보다 먼저 적용하고 outer `LIMIT`만으로 boundedness를 주장하지 않는다. - Shared pure policy는 SQLAlchemy/FastAPI/Celery/Gateway/Workflow Engine concrete package를 import하지 않고 Workflow Engine runtime retrieval production code는 `apps.gateway.*`를 import하지 않는다. +## MBA-233 Workflow Collection Routing Integration Tests + +- Legacy direct-only graph, Collection-only graph와 mixed graph가 Shared/Gateway/Worker/ + Client validator에서 같은 pass/fail 결과를 사용한다. 각 list의 19/20은 성공하고 + 21은 silent slicing 없이 실패한다. Malformed/non-canonical UUID, unknown item field, + overlong/control display snapshot과 raw object echo를 거부한다. +- Route-safe Collection picker는 user-direct/active-Team/organization-manager effective + `route` positive case와 read/manage/sync/domain-only, inactive membership, revoked, + cross-organization, archived/deleted negative case를 검증한다. Response는 UUID와 optional + approved safe label만 가지며 child/source/permission/hidden count를 포함하지 않는다. +- Draft/Agent Builder/optimizer/model-routing graph save는 direct KB effective `use`와 + source authorization, Collection `route`를 current editor로 다시 검증한다. 하나라도 + stale/forged/denied/cross-org이면 partial graph/success audit 없이 whole-write를 + rollback하고 safe generic error만 반환한다. +- Collection route는 있지만 child KB/source access가 전부 denied인 graph는 save할 수 + 있고 runtime에서 zero candidate/no provider로 닫힌다. Save-time에 child membership이나 + source를 query하면 테스트 실패다. +- PostgreSQL coordinated revoke/save test는 revoke가 authorization read 전에 commit되면 + save가 실패함을 보이고, read 뒤 revoke 경합으로 configuration intent가 남더라도 + 다음 MBA-232 invocation이 current permission으로 제외하며 save capability를 재사용하지 + 않음을 검증한다. +- Deployment preview/create/activation/toggle과 nested workflow-node graph는 두 list를 + 검증한다. Anonymous private Collection/direct KB와 source public exposure primitive가 + 없는 source-managed content는 fixed blocker이고 hidden child ID/count를 반환하지 않는다. +- 모든 production execution surface는 same resolver dependency를 주입한다. Direct-only, + Collection-only와 mixed invocation은 resolver를 정확히 한 번 호출하며 ordered canonical + candidate마다 retrieval을 최대 한 번 실행한다. +- Resolver policy zero-result와 insufficient evidence는 embedding/retrieval/provider를 + 호출하지 않는다. Resolver infrastructure exception은 safe retryable workflow failure로 + Celery retry되고 `ragFailurePolicy=safe_no_result`로 낮아지지 않는다. Retry/redelivery는 + fresh snapshot을 열 수 있지만 exactly-once provider 호출을 주장하지 않는다. +- Agent Builder/optimizer/compare/copy/import/model routing/deployment snapshot은 unrelated + edit에서 `knowledgeCollections`를 보존한다. Pre-execution sync는 direct KB만 처리하고 + Collection child를 열거하거나 connector를 호출하지 않는다. +- Public app/deployment graph는 두 reference list를 제거한다. API/SSE/error/log/trace/audit + fixture는 Collection ID/name/provenance, hidden KB ID, raw graph/query/source/credential/ + provider payload가 없고 safe bucket/fixed code만 있음을 검증한다. +- Worker-first canary는 구 task drain 뒤 Gateway write와 Client를 순서대로 노출하고, + rollback은 Client/Gateway write 중지와 drain 뒤 Worker를 되돌린다. 구 Worker가 + Collection graph를 소비할 수 있는 상태에서는 rollout/rollback acceptance가 실패다. + ## Knowledge Base API Tests - KB create는 blank name을 DB insert 전에 거부하고 safe validation reason code만 반환한다. diff --git a/docs/features/workflow/api_spec.md b/docs/features/workflow/api_spec.md index 64e2f5fac..728db22fe 100644 --- a/docs/features/workflow/api_spec.md +++ b/docs/features/workflow/api_spec.md @@ -22,6 +22,52 @@ Workflow test stream은 별도 계약 전 Conversation Memory session을 자동 ## Request And Response Models +### MBA-233 LLM Knowledge reference graph contract + +LLM node `data`는 다음 두 configured reference list를 함께 가질 수 있다. + +```json +{ + "knowledgeBases": [ + { + "id": "00000000-0000-0000-0000-000000000000", + "name": "직접 선택 KB" + } + ], + "knowledgeCollections": [ + { + "id": "00000000-0000-0000-0000-000000000000", + "safeLabel": "사내 문서" + } + ] +} +``` + +- Field 부재는 빈 목록과 같다. Direct-only legacy graph는 그대로 유효하다. +- 각 목록은 최대 20개다. 21번째는 `knowledge_reference_limit_exceeded`로 거부하며 + server와 Client 모두 silent slicing을 하지 않는다. +- Item은 canonical UUID string `id`와 해당 type의 display field만 허용한다. + `knowledgeBases.name`은 legacy 호환을 위해 빈 문자열을 허용하지만 string이어야 + 하고, `knowledgeCollections.safeLabel`은 optional string이다. Present display field는 + 255자 이하이고 control character를 포함할 수 없다. +- Unknown item field, non-object item, duplicate field shape, malformed/non-canonical UUID는 + fixed validation code와 safe field path로 거부한다. Error는 item value, label과 raw + graph를 echo하지 않는다. +- Display field는 Builder snapshot이며 authorization/routing/audit/trace input이 아니다. + Duplicate reference는 stored graph에서 자동 삭제하지 않고 runtime request에서 첫 + canonical occurrence만 사용한다. + +`PUT /api/v1/workflows/{workflow_id}/draft`, Agent Builder apply-save와 graph를 저장하는 +optimizer/model-routing path는 위 structural contract와 Knowledge API의 save-time +reference authorization을 모두 적용한다. Direct execute/stream은 structural validation을 +통과하고 current invocation audience로 MBA-232 resolver를 호출한다. + +Deployment preflight result는 기존 safe summary에 additive +`knowledge_collection_count_bucket`과 `candidate_budget_limited`를 포함할 수 있다. +Reason/action은 fixed allowlist만 사용하며 Collection/child UUID, label, exact hidden count, +permission/source detail을 반환하지 않는다. Malformed/over-limit graph와 anonymous +private/source-public-exposure 위반은 active publish에서 non-downgradable blocker다. + ### Workflow run actor compatibility Workflow run list/detail 또는 node execution log가 run actor를 포함하는 경우 `user_id`는 `UUID | null`이다. Null은 canonical schedule claim에서 내부 입력 `schedule`이 저장 계약 `trigger_mode="scheduler"`로 정규화된 system execution에서만 허용한다. Client는 null을 App creator로 대체하지 않고 actor를 표시하는 화면에서는 `System`으로 표현한다. Manual/API/webhook 등 기존 user-attributed run의 non-null 계약은 유지한다. diff --git a/docs/features/workflow/component_spec.md b/docs/features/workflow/component_spec.md index 0fef29ec1..e5380d8ec 100644 --- a/docs/features/workflow/component_spec.md +++ b/docs/features/workflow/component_spec.md @@ -50,6 +50,28 @@ V1 canonical envelope은 값 dependency와 활성 control dependency의 합집 Main generation과 Memory summary provider adapter는 Workflow admission 안에서 provider effect 없는 server-issued attempt reference를 먼저 만든 뒤 LLM Credentials domain의 authoritative port에서 해당 invocation/admission/attempt에 binding된 opaque capability identity/revision을 받는다. 상세 schema, credential principal과 permission decision revision은 [LLM Credentials API Spec](../llm-credentials/api_spec.md#target-provider-execution-capability-contract)이 소유한다. Runtime은 capability identity/revision을 Memory context lease, budget reservation, provider attempt와 usage reconciliation에 그대로 전달하고 client/Access Grant/owner 값으로 scope를 바꾸거나 credential principal을 합성하지 않는다. +## MBA-233 LLM Knowledge Selection And Runtime + +- `LLMNodePanel`과 `LLMReferenceSidePanel`은 “고정 지식 베이스”와 “지식 + Collection”을 분리해 표시하고 각 목록의 `n / 20` 상태를 독립적으로 관리한다. +- Direct KB는 `/knowledge/llm-selectable`, Collection은 + `/knowledge/llm-selectable-collections`의 route-safe projection에서 선택한다. +- Saved item이 current picker response에 없으면 generic unavailable 상태로 보존한다. + Picker load failure와 성공한 empty response를 구분하며 어느 경우에도 자동 삭제하지 + 않는다. +- LLM node의 Knowledge-enabled 상태는 두 목록 중 하나라도 non-empty이면 true다. + Collection-only node도 RAG evidence/failure controls를 사용할 수 있다. +- Gateway save는 Client validation을 신뢰하지 않고 current editor direct KB `use`와 + Collection `route`를 재검증한다. Save denial은 hidden resource identity 없이 + 제거/권한 복구가 필요하다는 generic action만 표시한다. +- Workflow Engine은 runtime dependency로 주입된 MBA-232 resolver를 direct-only, + Collection-only, mixed invocation에 한 번 사용한다. Resolver zero-result와 evidence + insufficient는 provider 호출 전 safe 결과로 종료하고 infrastructure failure는 Celery + retry 경계로 전달한다. +- Agent Builder, cost optimizer, compare/apply와 model-routing refresh는 unrelated edit에서 + `knowledgeCollections`를 보존한다. Public app graph와 실행 로그 option projection은 + Collection identity를 표시하지 않는다. + ## Screens - Workflow Builder 화면: 캔버스, 노드 라이브러리, 상단 액션, 테스트 실행 사이드바, 하단 캔버스 도구를 포함한다. diff --git a/docs/features/workflow/requirements.md b/docs/features/workflow/requirements.md index 40a0b4439..6f4deb1f6 100644 --- a/docs/features/workflow/requirements.md +++ b/docs/features/workflow/requirements.md @@ -96,6 +96,15 @@ FR-048 기존 실행 계약 보존: Draft/test·Compare·stream publisher는 DB - FR-050 (MBA-190): Generic HTTP `GET` 기존 read 경로도 FR-035의 trace/log 비노출 규칙을 적용해 raw request/response payload를 durable trace에 남겨서는 안 된다. 기존 `status/data/headers` node output과 safe `host`/`path`/method/status/size/latency metadata는 유지해야 한다. - FR-051 (MBA-190): WorkflowNode binding 생성 시에는 target이 해당 app의 current active deployment이고 child surface에서 허용되는 type인지 확인해야 한다. Binding 소비 시에는 exact deployment row, app/workflow/organization provenance, 허용 type, version과 snapshot hash를 검증해야 한다. 새 child D2 활성화로 bound D1이 자동 비활성화된 경우에는 D1의 `is_active`나 app pointer와 D1의 일치를 요구하지 않고 기존 parent snapshot/command가 D1을 계속 사용해야 한다. 다만 app의 `active_deployment_id`가 null이거나 같은 app의 실제 active deployment row로 해석되지 않는 전체 비활성/불일치 상태는 kill switch로 보고 provider 전에 실패해야 하며 현재 pointer의 deployment로 bound graph를 대체해서는 안 된다. Bound row 삭제 또는 provenance/type/hash 불일치도 provider 전에 실패해야 한다. - FR-052 (MBA-218): ADR-0037 적용 이후 `slackPostNode`는 FR-029/FR-036/FR-042/FR-047의 MBA-190 Slack 호환 baseline을 현재 실행 계약으로 사용하지 않고 전용 data/node/adapter를 사용해야 한다. API mode는 `slack.chat.post_message.v1`, Webhook mode는 `slack.incoming_webhook.post.v1`을 선택하며 두 profile의 provider replay는 `unknown`, safe result reuse는 `supported`여야 한다. 기존 `slack.http.request.v1`은 과거 attempt 해석용으로 등록 상태를 유지해야 한다. 성공 projection은 `status`, `delivery_status`, `delivery_mode`와 API mode의 검증된 `message_ref`만 허용하고 같은 stable slot 재진입은 provider 호출 없이 이를 재사용해야 한다. API 성공은 HTTP `200` JSON `ok=true`와 유효한 `ts`, Webhook 성공은 HTTP `200`과 정확한 `ok`를 모두 요구한다. 확인된 rejection과 `429`는 `failed_before_effect + stop`, partial effect 가능 오류·미확인 `ok=false`·malformed success·redirect·write/read timeout·응답 유실·`5xx`는 `effect_outcome_unknown + stop`이어야 하며 `Retry-After`를 포함한 어떤 Slack provider 실패도 자동 replay하지 않아야 한다. Template은 실제 사용된 등록 변수의 단순 치환만 허용하고 JSON template 값은 구조를 바꾸지 못하게 escape해야 한다. API endpoint는 고정하고 Webhook은 exact commercial URL만 허용하며 arbitrary method/header/body/timeout을 request source로 사용하지 않아야 한다. 제거된 `data`/`headers` 및 Webhook `message_ref` selector는 Client/deployment/runtime에서 fail-closed하고, token/webhook URL/message/raw response와 `message_ref` 원문은 public graph/log/trace에 저장하지 않아야 한다. +- FR-053 (MBA-233): LLM node data는 `knowledgeBases`와 additive `knowledgeCollections`를 동시에 지원하고 각 목록을 최대 20개로 strict validation해야 한다. 구 graph에서 Collection field 부재는 빈 목록이며 over-limit reference를 slice하거나 unknown reference field를 무시해서는 안 된다. +- FR-054 (MBA-233): Workflow Builder는 고정 Knowledge Base와 실행 시 membership을 해석하는 Knowledge Collection을 별도 그룹과 독립 `n/20` count로 표시해야 한다. Collection 관리/권한/child 편집은 Builder가 아니라 Knowledge 관리 화면의 책임이며 stale selection은 generic unavailable로 표시하고 자동 삭제하지 않아야 한다. +- FR-055 (MBA-233): Draft save, Agent Builder apply, optimizer/model-routing graph mutation은 server-side structural validation과 current editor의 direct KB `use`/Collection `route` 재검증을 우회하지 않아야 한다. 저장된 label과 Client capability는 권한이 아니며 실패는 partial graph 또는 success audit을 만들지 않아야 한다. +- FR-056 (MBA-233): Deployment preview/create/activation/toggle은 direct KB와 selected Collection을 server-derived audience로 preflight하고 private/anonymous 또는 source-public-exposure 차단을 safe reason/action과 bucket으로만 반환해야 한다. Preflight 성공은 runtime authorization capability가 아니다. +- FR-057 (MBA-233): 모든 production Workflow execution surface와 nested Loop/WorkflowNode는 같은 MBA-232 resolver dependency를 LLM node에 전달해야 한다. Knowledge configuration이 있으면 invocation마다 resolver를 한 번 호출하고 direct/Collection duplicate KB를 한 번만 retrieval해야 한다. +- FR-058 (MBA-233): Resolver policy zero-result 또는 insufficient evidence는 LLM provider를 호출하지 않아야 한다. Resolver infrastructure failure는 raw 원인 없는 retryable workflow failure로 Celery retry 경계에 전달하고, authorized candidate resolution 이후 일부 retrieval failure만 기존 partial-result/evidence policy를 적용해야 한다. +- FR-059 (MBA-233): Agent Builder, cost optimizer, compare, copy/import, model routing policy와 deployment snapshot은 operation이 Collection selection을 명시적으로 변경하지 않는 한 `knowledgeCollections`를 round-trip해야 한다. Public graph와 durable trace/log projection은 두 Knowledge reference 목록과 Collection identity를 제거해야 한다. +- FR-060 (MBA-233): Pre-execution Knowledge sync는 explicit `knowledgeBases`만 대상으로 하고 Collection membership을 resolver 전에 확장하거나 live connector를 호출하지 않아야 한다. Source-managed Collection child는 MBA-232의 materialized provenance/readiness gate를 따른다. +- FR-061 (MBA-233): 새 Collection graph는 모든 Worker가 MBA-233를 실행하고 기존 task를 drain한 뒤에만 Gateway/Client에서 저장 가능해야 한다. 자동 Worker capability negotiation이 없는 현재 구현은 Worker-first 배포 절차와 역순 rollback evidence를 요구한다. ### 1. 실행 편의성 diff --git a/docs/features/workflow/test_cases.md b/docs/features/workflow/test_cases.md index 84f435c24..a5ae03449 100644 --- a/docs/features/workflow/test_cases.md +++ b/docs/features/workflow/test_cases.md @@ -437,6 +437,31 @@ Frontend 공통 그래프 검증은 catalog v2의 incoming/outgoing 금지 정 - Missing execution subject는 anonymous public-only 결과를 반환하고 workflow owner fallback을 만들지 않는다. Ambiguous execution subject는 private retrieval fail-closed로 처리한다. - LLM node의 RAG 옵션은 Builder-time skill selection과 runtime data access 권한을 분리한다. +## MBA-233 Workflow Knowledge Collection Tests + +- Builder는 direct KB와 Collection을 별도 group으로 선택하고 각각 20개 cap을 적용한다. + Collection-only/mixed graph가 save/reload 뒤 ID, configured order와 bounded display + snapshot을 보존한다. +- Current picker에서 사라진 saved reference는 stale label 대신 generic unavailable로 + 남고 picker failure/empty response가 selection을 자동 삭제하지 않는다. 새 save가 + server authorization에 실패하면 hidden identity 없이 제거/권한 복구 action을 표시한다. +- Client validation을 우회한 malformed/over-limit graph는 draft save, direct stream, + deployment와 Worker NodeFactory에서 provider 실행 전 거부된다. +- Agent Builder apply, cost optimizer candidate/apply/compare, model routing refresh와 graph + copy path는 기존 `knowledgeCollections`를 보존하고 Collection을 자동 추천하지 않는다. +- `knowledgeBases` 또는 `knowledgeCollections` 중 하나라도 있으면 model router와 LLM + runtime은 Knowledge-enabled로 판정한다. Collection-only node는 MBA-232 resolver 결과를 + 기존 bounded retrieval/evidence path에 전달한다. +- Authenticated execution은 explicit user subject, subject 부재는 anonymous public + audience를 사용한다. Credential principal/owner/builder가 Collection route를 가져도 + execution subject가 denied이면 candidate를 얻지 못한다. +- Resolver zero-result는 LLM provider를 호출하지 않고 infrastructure failure는 retryable + task failure다. Authorized resolution 뒤 일부 retrieval timeout만 partial-result policy를 + 사용할 수 있다. +- Public graph, node option display, trace/log/audit/error/SSE는 Collection identity와 child + structure를 노출하지 않는다. Runtime trace는 routing mode와 count/limit/failure safe + summary만 허용한다. + ## API Tests - 로그인 LLM node의 RAG 옵션 실행 요청은 Knowledge service에 `execution_subject=current_user`를 전달한다. From 4d78cdbac2c810fa3a6ddb871e56f3e0cc365a32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Mon, 13 Jul 2026 19:45:18 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat(knowledge):=20Workflow=20Collection?= =?UTF-8?q?=20=EC=B0=B8=EC=A1=B0=20=EA=B2=80=EC=A6=9D=EA=B3=BC=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/gateway/api/v1/endpoints/knowledge.py | 39 +++ apps/gateway/api/v1/endpoints/workflow.py | 36 ++- .../gateway/services/agent_builder_service.py | 6 + apps/gateway/services/deployment_service.py | 6 + ...owledge_collection_picker_query_service.py | 102 ++++++++ .../workflow_knowledge_reference_service.py | 212 ++++++++++++++++ apps/gateway/services/workflow_service.py | 66 +++++ .../test_active_organization_app_workflow.py | 7 + .../api/test_knowledge_collection_api.py | 52 ++++ ...owledge_collection_picker_query_service.py | 129 ++++++++++ ...st_workflow_knowledge_reference_service.py | 121 +++++++++ .../domain/workflow_knowledge_references.py | 236 +++++++++++++++++ apps/shared/schemas/knowledge.py | 11 + .../test_workflow_knowledge_references.py | 238 ++++++++++++++++++ .../tests/nodes/test_llm_node_runtime.py | 11 +- .../workflow/core/workflow_node_factory.py | 10 + .../workflow/nodes/llm/__init__.py | 4 +- .../workflow/nodes/llm/entities.py | 41 ++- 18 files changed, 1315 insertions(+), 12 deletions(-) create mode 100644 apps/gateway/services/knowledge_collection_picker_query_service.py create mode 100644 apps/gateway/services/workflow_knowledge_reference_service.py create mode 100644 apps/gateway/tests/services/test_knowledge_collection_picker_query_service.py create mode 100644 apps/gateway/tests/services/test_workflow_knowledge_reference_service.py create mode 100644 apps/shared/domain/workflow_knowledge_references.py create mode 100644 apps/shared/tests/domain/test_workflow_knowledge_references.py diff --git a/apps/gateway/api/v1/endpoints/knowledge.py b/apps/gateway/api/v1/endpoints/knowledge.py index 601c284d8..88762810f 100644 --- a/apps/gateway/api/v1/endpoints/knowledge.py +++ b/apps/gateway/api/v1/endpoints/knowledge.py @@ -43,6 +43,10 @@ KnowledgeCollectionService, KnowledgeCollectionServiceError, ) +from apps.gateway.services.knowledge_collection_picker_query_service import ( + KnowledgeCollectionPickerQueryService, + KnowledgeCollectionPickerUnavailable, +) from apps.gateway.services.knowledge_document_content_service import ( KnowledgeDocumentContentService, ) @@ -90,6 +94,7 @@ KnowledgeCollectionItemReorderRequest, KnowledgeCollectionItemsResponse, KnowledgeCollectionLinkCandidatesResponse, + KnowledgeCollectionLLMSelectableResponse, KnowledgeCollectionListResponse, KnowledgeCollectionPermissionGrantRequest, KnowledgeCollectionPermissionBundleGrantRequest, @@ -512,6 +517,40 @@ def list_llm_selectable_knowledge_bases( ) +@router.get( + "/llm-selectable-collections", + response_model=KnowledgeCollectionLLMSelectableResponse, +) +def list_llm_selectable_knowledge_collections( + request: Request, + x_organization_id: str | None = Header(default=None, alias="X-Organization-Id"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Return only active Collections the current editor may route through.""" + + organization_id = resolve_active_organization_id( + db, + request, + x_organization_id, + current_user.id, + ) + service = KnowledgeCollectionPickerQueryService( + db, + user_id=current_user.id, + organization_id=organization_id, + ) + try: + return service.list_llm_selectable() + except KnowledgeCollectionPickerUnavailable: + raise_api_error( + request, + status.HTTP_503_SERVICE_UNAVAILABLE, + "knowledge.collection_picker_unavailable", + "Knowledge Collection candidates are temporarily unavailable.", + ) + + @router.post("/candidates/resolve", response_model=KnowledgeCandidateResolution) def resolve_knowledge_candidates( candidate_request: KnowledgeCandidateResolveRequest, diff --git a/apps/gateway/api/v1/endpoints/workflow.py b/apps/gateway/api/v1/endpoints/workflow.py index ffb6eff11..b3612d001 100644 --- a/apps/gateway/api/v1/endpoints/workflow.py +++ b/apps/gateway/api/v1/endpoints/workflow.py @@ -56,6 +56,10 @@ from apps.shared.domain.external_effect_error import ( safe_external_effect_error_payload, ) +from apps.shared.domain.workflow_knowledge_references import ( + WorkflowKnowledgeReferenceError, + parse_workflow_knowledge_references, +) from apps.shared.permissions import workflow_auth_state_allows from apps.workflow_engine.services.model_router import ModelCandidate, ModelRouter from apps.workflow_engine.services.model_routing_policy_refresh import ( @@ -3288,6 +3292,14 @@ def _run_cost_optimizer_candidate( def validate_execution_graph(graph: dict): + try: + parse_workflow_knowledge_references(graph) + except WorkflowKnowledgeReferenceError as exc: + raise HTTPException( + status_code=422, + detail={"code": exc.reason_code, "field": exc.field_path}, + ) from exc + nodes = graph.get("nodes") or [] edges = graph.get("edges") or [] node_map = { @@ -3468,6 +3480,12 @@ def patch_model_routing_policy_endpoint( legacy_policy["refresh"] = legacy_refresh node_data["model_routing_policy"] = legacy_policy node["data"] = node_data + WorkflowService.validate_knowledge_references( + db, + next_graph, + user_id=current_user.id, + organization_id=workflow.organization_id, + ) workflow.graph = next_graph policy = _get_model_routing_policy_for_workflow(db, workflow, node_id) @@ -3783,11 +3801,18 @@ def apply_cost_optimizer_recommendations( SimpleNamespace(id=workflow.id, graph=current_graph), node_id, ) - workflow.graph = _patch_cost_optimizer_candidate_graph( + next_graph = _patch_cost_optimizer_candidate_graph( current_graph, node_id, candidate_settings, ) + WorkflowService.validate_knowledge_references( + db, + next_graph, + user_id=current_user.id, + organization_id=workflow.organization_id, + ) + workflow.graph = next_graph db.commit() try: db.refresh(workflow) @@ -4442,11 +4467,18 @@ def apply_cost_optimizer_candidate( SimpleNamespace(id=workflow.id, graph=current_graph), node_id, ) - workflow.graph = _patch_cost_optimizer_candidate_graph( + next_graph = _patch_cost_optimizer_candidate_graph( current_graph, node_id, candidate_settings, ) + WorkflowService.validate_knowledge_references( + db, + next_graph, + user_id=current_user.id, + organization_id=workflow.organization_id, + ) + workflow.graph = next_graph if applied_candidate_row is not None: applied_at = datetime.now(timezone.utc) for candidate_row in comparison_candidate_rows: diff --git a/apps/gateway/services/agent_builder_service.py b/apps/gateway/services/agent_builder_service.py index 57f2e2fe6..c3e5236db 100644 --- a/apps/gateway/services/agent_builder_service.py +++ b/apps/gateway/services/agent_builder_service.py @@ -1860,6 +1860,12 @@ def apply_draft( ) try: + WorkflowService.validate_knowledge_references( + self.db, + save_graph, + user_id=self.user.id, + organization_id=self.organization_id, + ) WorkflowService.validate_mail_credential_references( self.db, save_graph, diff --git a/apps/gateway/services/deployment_service.py b/apps/gateway/services/deployment_service.py index f5ac97b62..f4a39d390 100644 --- a/apps/gateway/services/deployment_service.py +++ b/apps/gateway/services/deployment_service.py @@ -119,6 +119,12 @@ def create_deployment( workflow.id, deployment_in.graph_snapshot, ) + WorkflowService.validate_knowledge_references( + db, + graph_snapshot, + user_id=user_id, + organization_id=workflow.organization_id, + ) graph_snapshot = DeploymentService.bind_workflow_node_targets( db, graph_snapshot, diff --git a/apps/gateway/services/knowledge_collection_picker_query_service.py b/apps/gateway/services/knowledge_collection_picker_query_service.py new file mode 100644 index 000000000..f14558109 --- /dev/null +++ b/apps/gateway/services/knowledge_collection_picker_query_service.py @@ -0,0 +1,102 @@ +"""Route-authorized, disclosure-minimal Collection projection for Builder.""" + +from __future__ import annotations + +import uuid + +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session, selectinload + +from apps.shared.db.models.knowledge import KnowledgeCollection +from apps.shared.schemas.knowledge import ( + KnowledgeCollectionLLMSelectableItem, + KnowledgeCollectionLLMSelectableResponse, +) +from apps.shared.services.knowledge_permission_service import KnowledgePermissionHelper +from apps.shared.services.knowledge_safe_text import safe_label_from_text + +MAX_LLM_SELECTABLE_COLLECTION_SCAN = 500 + + +class KnowledgeCollectionPickerUnavailable(RuntimeError): + """Sanitized infrastructure failure for the route-safe picker.""" + + +class KnowledgeCollectionPickerQueryService: + """List active Collections the current editor may route through. + + This projection intentionally does not reuse Collection management responses: + it exposes neither raw names/descriptions nor child-derived facts/capabilities. + """ + + def __init__( + self, + db: Session, + *, + user_id: uuid.UUID, + organization_id: uuid.UUID, + ) -> None: + self.db = db + self.user_id = user_id + self.organization_id = organization_id + self.permission_helper = KnowledgePermissionHelper( + db, + user_id=user_id, + organization_id=organization_id, + ) + + def list_llm_selectable(self) -> KnowledgeCollectionLLMSelectableResponse: + try: + collections = ( + self.db.query(KnowledgeCollection) + .options(selectinload(KnowledgeCollection.source_identity)) + .filter( + KnowledgeCollection.organization_id == self.organization_id, + KnowledgeCollection.lifecycle_state == "active", + ) + .order_by( + KnowledgeCollection.created_at.desc(), + KnowledgeCollection.id.asc(), + ) + .limit(MAX_LLM_SELECTABLE_COLLECTION_SCAN) + .all() + ) + decisions = self.permission_helper.bulk_evaluate_collection_action( + collections, + "route", + ) + except SQLAlchemyError as exc: + raise KnowledgeCollectionPickerUnavailable( + "knowledge_collection_picker_unavailable" + ) from exc + + return KnowledgeCollectionLLMSelectableResponse( + collections=[ + KnowledgeCollectionLLMSelectableItem( + id=collection.id, + safe_label=self._approved_safe_label(collection), + ) + for collection in collections + if decisions.get(collection.id) is not None + and decisions[collection.id].allowed + ] + ) + + @staticmethod + def _approved_safe_label(collection: KnowledgeCollection) -> str | None: + source_identity = getattr(collection, "source_identity", None) + if getattr(collection, "source_identity_id", None) is not None: + if ( + source_identity is None + or getattr(source_identity, "display_policy_state", None) != "approved" + or not getattr(source_identity, "is_active", False) + ): + return None + return safe_label_from_text( + getattr(source_identity, "safe_display_name", None) + ) + + safe_metadata = getattr(collection, "safe_metadata", None) + if not isinstance(safe_metadata, dict): + return None + return safe_label_from_text(safe_metadata.get("safe_label")) diff --git a/apps/gateway/services/workflow_knowledge_reference_service.py b/apps/gateway/services/workflow_knowledge_reference_service.py new file mode 100644 index 000000000..b47a7edfc --- /dev/null +++ b/apps/gateway/services/workflow_knowledge_reference_service.py @@ -0,0 +1,212 @@ +"""Editable Workflow graph Knowledge reference authorization boundary.""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass + +from sqlalchemy import and_, or_ +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from apps.shared.db.models.knowledge import ( + Document, + DocumentChunk, + DocumentVersion, + KnowledgeBase, + KnowledgeCollection, +) +from apps.shared.domain.workflow_knowledge_references import ( + WorkflowNodeKnowledgeReferences, + aggregate_workflow_knowledge_reference_ids, + parse_workflow_knowledge_references, +) +from apps.shared.services.knowledge_permission_service import KnowledgePermissionHelper + + +@dataclass(frozen=True, slots=True) +class WorkflowKnowledgeReferenceUnavailable(Exception): + """Generic resource-policy failure without a resource identifier.""" + + field_path: str + reason_code: str = "knowledge_reference_unavailable" + + +class WorkflowKnowledgeReferenceAuthorizationUnavailable(RuntimeError): + """Sanitized retryable database/authorization infrastructure failure.""" + + +class WorkflowKnowledgeReferenceService: + """Validate graph intent and current editor authorization before a write. + + Collection children are deliberately not loaded here. Invocation-time + child KB/source authorization belongs to the MBA-232 runtime resolver. + """ + + def __init__( + self, + db: Session, + *, + user_id: uuid.UUID, + organization_id: uuid.UUID, + ) -> None: + self.db = db + self.user_id = user_id + self.organization_id = organization_id + self.permission_helper = KnowledgePermissionHelper( + db, + user_id=user_id, + organization_id=organization_id, + ) + + def validate_editable_graph( + self, + graph: dict, + ) -> tuple[WorkflowNodeKnowledgeReferences, ...]: + parsed_nodes = parse_workflow_knowledge_references(graph) + direct_ids, collection_ids = aggregate_workflow_knowledge_reference_ids( + parsed_nodes + ) + if not direct_ids and not collection_ids: + return parsed_nodes + + try: + direct_kbs = self._load_direct_kbs(direct_ids) + collections = self._load_collections(collection_ids) + direct_decisions = self.permission_helper.bulk_evaluate_kb_use(direct_kbs) + collection_decisions = ( + self.permission_helper.bulk_evaluate_collection_action( + collections, + "route", + ) + ) + retrieval_ready_ids = self._retrieval_ready_ids(direct_ids) + except SQLAlchemyError as exc: + raise WorkflowKnowledgeReferenceAuthorizationUnavailable( + "knowledge_reference_authorization_unavailable" + ) from exc + + direct_by_id = {kb.id: kb for kb in direct_kbs} + for direct_id in direct_ids: + decision = direct_decisions.get(direct_id) + if ( + direct_id not in direct_by_id + or decision is None + or not decision.allowed + or direct_id not in retrieval_ready_ids + ): + raise WorkflowKnowledgeReferenceUnavailable( + self._first_reference_path( + parsed_nodes, + reference_id=direct_id, + collection=False, + ) + ) + + collection_by_id = {collection.id: collection for collection in collections} + for collection_id in collection_ids: + decision = collection_decisions.get(collection_id) + if ( + collection_id not in collection_by_id + or decision is None + or not decision.allowed + ): + raise WorkflowKnowledgeReferenceUnavailable( + self._first_reference_path( + parsed_nodes, + reference_id=collection_id, + collection=True, + ) + ) + + return parsed_nodes + + def _load_direct_kbs( + self, + direct_ids: tuple[uuid.UUID, ...], + ) -> list[KnowledgeBase]: + if not direct_ids: + return [] + return ( + self.db.query(KnowledgeBase) + .filter( + KnowledgeBase.id.in_(direct_ids), + KnowledgeBase.organization_id == self.organization_id, + KnowledgeBase.lifecycle_state == "active", + ) + .all() + ) + + def _load_collections( + self, + collection_ids: tuple[uuid.UUID, ...], + ) -> list[KnowledgeCollection]: + if not collection_ids: + return [] + return ( + self.db.query(KnowledgeCollection) + .filter( + KnowledgeCollection.id.in_(collection_ids), + KnowledgeCollection.organization_id == self.organization_id, + KnowledgeCollection.lifecycle_state == "active", + ) + .all() + ) + + def _retrieval_ready_ids( + self, + direct_ids: tuple[uuid.UUID, ...], + ) -> set[uuid.UUID]: + if not direct_ids: + return set() + rows = ( + self.db.query(DocumentChunk.knowledge_base_id) + .select_from(DocumentChunk) + .join(KnowledgeBase, KnowledgeBase.id == DocumentChunk.knowledge_base_id) + .join(Document, Document.id == DocumentChunk.document_id) + .outerjoin( + DocumentVersion, + DocumentVersion.id == DocumentChunk.document_version_id, + ) + .filter( + KnowledgeBase.id.in_(direct_ids), + KnowledgeBase.organization_id == self.organization_id, + KnowledgeBase.lifecycle_state == "active", + Document.status == "completed", + or_( + and_( + KnowledgeBase.active_document_version_id.is_(None), + DocumentChunk.document_version_id.is_(None), + ), + and_( + KnowledgeBase.active_document_version_id.is_not(None), + DocumentChunk.document_version_id + == KnowledgeBase.active_document_version_id, + DocumentVersion.status == "ready", + ), + ), + ) + .distinct() + .all() + ) + return { + row[0] if isinstance(row, tuple) else row.knowledge_base_id + for row in rows + } + + @staticmethod + def _first_reference_path( + parsed_nodes: tuple[WorkflowNodeKnowledgeReferences, ...], + *, + reference_id: uuid.UUID, + collection: bool, + ) -> str: + for node in parsed_nodes: + references = ( + node.collection_references if collection else node.direct_references + ) + field_name = "knowledgeCollections" if collection else "knowledgeBases" + for index, reference in enumerate(references): + if reference.id == reference_id: + return f"{node.field_path}.{field_name}[{index}]" + return "graph.knowledgeReferences" diff --git a/apps/gateway/services/workflow_service.py b/apps/gateway/services/workflow_service.py index 35786e5f5..848fbe421 100644 --- a/apps/gateway/services/workflow_service.py +++ b/apps/gateway/services/workflow_service.py @@ -20,6 +20,9 @@ validate_mail_node_credential_boundary, validate_mail_processing_node_boundary, ) +from apps.shared.domain.workflow_knowledge_references import ( + WorkflowKnowledgeReferenceError, +) from apps.shared.domain.slack_delivery import ( SlackGraphBoundaryError, validate_slack_graph_boundary, @@ -30,6 +33,11 @@ get_effective_mail_credential_auth_state, has_mail_credential_permission, ) +from apps.gateway.services.workflow_knowledge_reference_service import ( + WorkflowKnowledgeReferenceAuthorizationUnavailable, + WorkflowKnowledgeReferenceService, + WorkflowKnowledgeReferenceUnavailable, +) class WorkflowService: @@ -145,6 +153,13 @@ def save_draft( detail="Workflow not found", # 상세 메시지 ) + WorkflowService.validate_knowledge_references( + db, + request, + user_id=user_id, + organization_id=workflow.organization_id, + ) + WorkflowService.validate_mail_credential_references( db, request, @@ -185,6 +200,57 @@ def save_draft( "workflow_id": workflow_id, } + @staticmethod + def validate_knowledge_references( + db: Session, + request: WorkflowDraftRequest | Mapping[str, Any], + *, + user_id: str | UUID, + organization_id: UUID, + ) -> None: + graph = ( + request.model_dump(mode="python") + if isinstance(request, WorkflowDraftRequest) + else dict(request) + ) + try: + user_uuid = uuid.UUID(str(user_id)) + organization_uuid = uuid.UUID(str(organization_id)) + except (TypeError, ValueError) as exc: + raise HTTPException( + status_code=422, + detail={ + "code": "knowledge_reference_context_invalid", + "field": "graph", + }, + ) from exc + + service = WorkflowKnowledgeReferenceService( + db, + user_id=user_uuid, + organization_id=organization_uuid, + ) + try: + service.validate_editable_graph(graph) + except WorkflowKnowledgeReferenceError as exc: + raise HTTPException( + status_code=422, + detail={"code": exc.reason_code, "field": exc.field_path}, + ) from exc + except WorkflowKnowledgeReferenceUnavailable as exc: + raise HTTPException( + status_code=403, + detail={"code": exc.reason_code, "field": exc.field_path}, + ) from exc + except WorkflowKnowledgeReferenceAuthorizationUnavailable as exc: + raise HTTPException( + status_code=503, + detail={ + "code": "knowledge_reference_authorization_unavailable", + "field": "graph.knowledgeReferences", + }, + ) from exc + @staticmethod def validate_mail_credential_references( db: Session, diff --git a/apps/gateway/tests/api/test_active_organization_app_workflow.py b/apps/gateway/tests/api/test_active_organization_app_workflow.py index 113fbf6ad..95486ab25 100644 --- a/apps/gateway/tests/api/test_active_organization_app_workflow.py +++ b/apps/gateway/tests/api/test_active_organization_app_workflow.py @@ -961,6 +961,13 @@ def test_owner_or_manager_without_membership_can_create_and_list_apps_and_workfl def test_active_member_can_manage_app_draft_after_creating_app(monkeypatch): _patch_audit(monkeypatch) + # Knowledge reference authorization is covered by its focused service/API + # tests; this route fake intentionally models only App/Workflow RBAC rows. + monkeypatch.setattr( + WorkflowService, + "validate_knowledge_references", + lambda *args, **kwargs: None, + ) # App 생성 권한 판정(ADR-0016)은 전용 테스트에서 검증한다. 이 테스트의 # 관심사는 생성 후 draft manage 권한이므로 생성 능력은 허용으로 고정한다. monkeypatch.setattr( diff --git a/apps/gateway/tests/api/test_knowledge_collection_api.py b/apps/gateway/tests/api/test_knowledge_collection_api.py index 680c5228b..90d972879 100644 --- a/apps/gateway/tests/api/test_knowledge_collection_api.py +++ b/apps/gateway/tests/api/test_knowledge_collection_api.py @@ -10,6 +10,7 @@ from apps.gateway.services.knowledge_collection_service import ( KnowledgeCollectionServiceError, ) +from apps.shared.schemas.knowledge import KnowledgeCollectionLLMSelectableResponse from apps.shared.schemas.knowledge import ( KnowledgeCollectionResponse, KnowledgeCollectionVisibilityResponse, @@ -120,6 +121,57 @@ def management_capabilities(self): assert body["can_change_public_visibility"] is True +def test_collection_picker_uses_active_organization_and_minimal_projection(monkeypatch): + organization_id = uuid.uuid4() + user_id = uuid.uuid4() + collection_id = uuid.uuid4() + captured = {} + + monkeypatch.setattr( + knowledge_endpoint, + "resolve_active_organization_id", + lambda db, request, raw, current_user_id: organization_id, + ) + + class FakePicker: + def __init__(self, db, *, user_id, organization_id): + captured["user_id"] = user_id + captured["organization_id"] = organization_id + + def list_llm_selectable(self): + return KnowledgeCollectionLLMSelectableResponse( + collections=[ + {"id": collection_id, "safe_label": "사내 문서"} + ] + ) + + monkeypatch.setattr( + knowledge_endpoint, + "KnowledgeCollectionPickerQueryService", + FakePicker, + ) + app.dependency_overrides[knowledge_endpoint.get_db] = lambda: object() + app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(id=user_id) + try: + response = TestClient(app).get( + "/api/v1/knowledge/llm-selectable-collections", + headers={"X-Organization-Id": str(organization_id)}, + ) + finally: + app.dependency_overrides = {} + + assert response.status_code == 200 + assert response.json() == { + "collections": [ + {"id": str(collection_id), "safe_label": "사내 문서"} + ] + } + assert captured == { + "user_id": user_id, + "organization_id": organization_id, + } + + def test_collection_visibility_error_uses_safe_envelope(monkeypatch): organization_id = uuid.uuid4() user_id = uuid.uuid4() diff --git a/apps/gateway/tests/services/test_knowledge_collection_picker_query_service.py b/apps/gateway/tests/services/test_knowledge_collection_picker_query_service.py new file mode 100644 index 000000000..88dba7da7 --- /dev/null +++ b/apps/gateway/tests/services/test_knowledge_collection_picker_query_service.py @@ -0,0 +1,129 @@ +import uuid +from types import SimpleNamespace + +from apps.gateway.services.knowledge_collection_picker_query_service import ( + MAX_LLM_SELECTABLE_COLLECTION_SCAN, + KnowledgeCollectionPickerQueryService, +) + + +class _Query: + def __init__(self, rows): + self.rows = rows + self.filters = [] + self.limit_value = None + + def options(self, *_args): + return self + + def filter(self, *criteria): + self.filters.extend(criteria) + return self + + def order_by(self, *_args): + return self + + def limit(self, value): + self.limit_value = value + return self + + def all(self): + return self.rows + + +class _Db: + def __init__(self, rows): + self.query_value = _Query(rows) + + def query(self, _model): + return self.query_value + + +def _collection(*, organization_id, safe_metadata=None, source_identity=None): + return SimpleNamespace( + id=uuid.uuid4(), + organization_id=organization_id, + lifecycle_state="active", + name="RAW COLLECTION NAME", + description="RAW DESCRIPTION", + safe_metadata=safe_metadata or {}, + source_identity_id=(uuid.uuid4() if source_identity is not None else None), + source_identity=source_identity, + ) + + +def test_picker_returns_only_route_allowed_minimal_projection(monkeypatch): + organization_id = uuid.uuid4() + allowed = _collection( + organization_id=organization_id, + safe_metadata={"safe_label": "사내 규정"}, + ) + denied = _collection( + organization_id=organization_id, + safe_metadata={"safe_label": "숨김 문서"}, + ) + db = _Db([allowed, denied]) + service = KnowledgeCollectionPickerQueryService( + db, + user_id=uuid.uuid4(), + organization_id=organization_id, + ) + monkeypatch.setattr( + service.permission_helper, + "bulk_evaluate_collection_action", + lambda collections, action: { + allowed.id: SimpleNamespace(allowed=True), + denied.id: SimpleNamespace(allowed=False), + }, + ) + + response = service.list_llm_selectable() + + assert response.model_dump(mode="json") == { + "collections": [{"id": str(allowed.id), "safe_label": "사내 규정"}] + } + assert db.query_value.limit_value == MAX_LLM_SELECTABLE_COLLECTION_SCAN + assert len(db.query_value.filters) == 2 + assert "RAW COLLECTION NAME" not in response.model_dump_json() + assert "RAW DESCRIPTION" not in response.model_dump_json() + + +def test_source_managed_label_requires_active_approved_display_policy(monkeypatch): + organization_id = uuid.uuid4() + approved = _collection( + organization_id=organization_id, + source_identity=SimpleNamespace( + display_policy_state="approved", + is_active=True, + safe_display_name="공개 승인 라벨", + ), + ) + unapproved = _collection( + organization_id=organization_id, + source_identity=SimpleNamespace( + display_policy_state="unreviewed", + is_active=True, + safe_display_name="노출 금지 라벨", + ), + ) + db = _Db([approved, unapproved]) + service = KnowledgeCollectionPickerQueryService( + db, + user_id=uuid.uuid4(), + organization_id=organization_id, + ) + monkeypatch.setattr( + service.permission_helper, + "bulk_evaluate_collection_action", + lambda collections, action: { + collection.id: SimpleNamespace(allowed=True) + for collection in collections + }, + ) + + response = service.list_llm_selectable() + + assert [item.safe_label for item in response.collections] == [ + "공개 승인 라벨", + None, + ] diff --git a/apps/gateway/tests/services/test_workflow_knowledge_reference_service.py b/apps/gateway/tests/services/test_workflow_knowledge_reference_service.py new file mode 100644 index 000000000..ee5700190 --- /dev/null +++ b/apps/gateway/tests/services/test_workflow_knowledge_reference_service.py @@ -0,0 +1,121 @@ +import uuid +from types import SimpleNamespace + +import pytest + +from apps.gateway.services.workflow_knowledge_reference_service import ( + WorkflowKnowledgeReferenceService, + WorkflowKnowledgeReferenceUnavailable, +) + + +def _graph(kb_id, collection_id): + return { + "nodes": [ + { + "id": "llm", + "type": "llmNode", + "data": { + "knowledgeBases": [{"id": str(kb_id), "name": "KB"}], + "knowledgeCollections": [ + {"id": str(collection_id), "safeLabel": "Collection"} + ], + }, + } + ] + } + + +def _service(monkeypatch, *, allow_kb=True, allow_collection=True, ready=True): + organization_id = uuid.uuid4() + user_id = uuid.uuid4() + kb_id = uuid.uuid4() + collection_id = uuid.uuid4() + service = WorkflowKnowledgeReferenceService( + None, + user_id=user_id, + organization_id=organization_id, + ) + kb = SimpleNamespace(id=kb_id, organization_id=organization_id) + collection = SimpleNamespace(id=collection_id, organization_id=organization_id) + monkeypatch.setattr(service, "_load_direct_kbs", lambda ids: [kb]) + monkeypatch.setattr(service, "_load_collections", lambda ids: [collection]) + monkeypatch.setattr( + service, + "_retrieval_ready_ids", + lambda ids: {kb_id} if ready else set(), + ) + monkeypatch.setattr( + service.permission_helper, + "bulk_evaluate_kb_use", + lambda kbs: {kb_id: SimpleNamespace(allowed=allow_kb)}, + ) + monkeypatch.setattr( + service.permission_helper, + "bulk_evaluate_collection_action", + lambda collections, action: { + collection_id: SimpleNamespace(allowed=allow_collection) + }, + ) + return service, kb_id, collection_id + + +def test_editable_graph_checks_direct_use_readiness_and_collection_route(monkeypatch): + service, kb_id, collection_id = _service(monkeypatch) + + parsed = service.validate_editable_graph(_graph(kb_id, collection_id)) + + assert len(parsed) == 1 + assert parsed[0].direct_kb_ids == (kb_id,) + assert parsed[0].collection_ids == (collection_id,) + + +@pytest.mark.parametrize( + ("allow_kb", "allow_collection", "ready", "expected_field"), + [ + (False, True, True, "graph.nodes[0].data.knowledgeBases[0]"), + (True, True, False, "graph.nodes[0].data.knowledgeBases[0]"), + (True, False, True, "graph.nodes[0].data.knowledgeCollections[0]"), + ], +) +def test_unavailable_references_use_generic_code_and_index_path( + monkeypatch, + allow_kb, + allow_collection, + ready, + expected_field, +): + service, kb_id, collection_id = _service( + monkeypatch, + allow_kb=allow_kb, + allow_collection=allow_collection, + ready=ready, + ) + + with pytest.raises(WorkflowKnowledgeReferenceUnavailable) as error: + service.validate_editable_graph(_graph(kb_id, collection_id)) + + assert error.value.reason_code == "knowledge_reference_unavailable" + assert error.value.field_path == expected_field + assert str(kb_id) not in str(error.value) + assert str(collection_id) not in str(error.value) + + +def test_empty_graph_does_not_query_permission_or_collection_children(monkeypatch): + service = WorkflowKnowledgeReferenceService( + None, + user_id=uuid.uuid4(), + organization_id=uuid.uuid4(), + ) + monkeypatch.setattr( + service, + "_load_direct_kbs", + lambda ids: pytest.fail("direct KB query must not run"), + ) + monkeypatch.setattr( + service, + "_load_collections", + lambda ids: pytest.fail("Collection query must not run"), + ) + + assert service.validate_editable_graph({"nodes": []}) == () diff --git a/apps/shared/domain/workflow_knowledge_references.py b/apps/shared/domain/workflow_knowledge_references.py new file mode 100644 index 000000000..a2ac8f8a5 --- /dev/null +++ b/apps/shared/domain/workflow_knowledge_references.py @@ -0,0 +1,236 @@ +"""Pure validation for Workflow LLM Knowledge graph references. + +The stored graph carries user configuration intent, not an authorization +capability. This module therefore validates and projects identifiers only; it +does not import an ORM, framework, queue, or concrete runtime implementation. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any +from uuid import UUID + +from apps.shared.domain.knowledge_runtime_candidates import ( + MAX_RUNTIME_COLLECTION_REFERENCES, + MAX_RUNTIME_DIRECT_KB_REFERENCES, +) + +MAX_KNOWLEDGE_REFERENCE_DISPLAY_LENGTH = 255 + + +class WorkflowKnowledgeReferenceError(ValueError): + """Fixed-code graph validation error that never echoes graph values.""" + + def __init__(self, reason_code: str, field_path: str) -> None: + self.reason_code = reason_code + self.field_path = field_path + super().__init__(reason_code) + + +def _raise(reason_code: str, field_path: str) -> None: + raise WorkflowKnowledgeReferenceError(reason_code, field_path) + + +def _canonical_uuid(value: object, *, field_path: str) -> UUID: + if not isinstance(value, str): + _raise("knowledge_reference_id_invalid", field_path) + try: + parsed = UUID(value) + except (AttributeError, TypeError, ValueError): + _raise("knowledge_reference_id_invalid", field_path) + if str(parsed) != value: + _raise("knowledge_reference_id_invalid", field_path) + return parsed + + +def _display_value( + value: object, + *, + field_path: str, + required: bool, +) -> str | None: + if value is None and not required: + return None + if not isinstance(value, str): + _raise("knowledge_reference_display_invalid", field_path) + if len(value) > MAX_KNOWLEDGE_REFERENCE_DISPLAY_LENGTH or any( + ord(character) < 32 or ord(character) == 127 for character in value + ): + _raise("knowledge_reference_display_invalid", field_path) + return value + + +@dataclass(frozen=True, slots=True) +class KnowledgeBaseGraphReference: + id: UUID + name: str + + +@dataclass(frozen=True, slots=True) +class KnowledgeCollectionGraphReference: + id: UUID + safe_label: str | None = None + + +def _unique_ids(values: tuple[UUID, ...]) -> tuple[UUID, ...]: + return tuple(dict.fromkeys(values)) + + +@dataclass(frozen=True, slots=True) +class WorkflowNodeKnowledgeReferences: + """Validated references for one LLM node without mutating its graph data.""" + + field_path: str + direct_references: tuple[KnowledgeBaseGraphReference, ...] = () + collection_references: tuple[KnowledgeCollectionGraphReference, ...] = () + + @property + def direct_kb_ids(self) -> tuple[UUID, ...]: + return _unique_ids(tuple(item.id for item in self.direct_references)) + + @property + def collection_ids(self) -> tuple[UUID, ...]: + return _unique_ids(tuple(item.id for item in self.collection_references)) + + @property + def has_references(self) -> bool: + return bool(self.direct_references or self.collection_references) + + +def _reference_list( + data: Mapping[str, Any], + *, + key: str, + field_path: str, + limit: int, +) -> list[object]: + if key not in data: + return [] + raw_items = data[key] + if not isinstance(raw_items, list): + _raise("knowledge_reference_list_invalid", f"{field_path}.{key}") + if len(raw_items) > limit: + _raise("knowledge_reference_limit_exceeded", f"{field_path}.{key}") + return raw_items + + +def parse_llm_knowledge_references( + data: Mapping[str, Any], + *, + field_path: str = "data", +) -> WorkflowNodeKnowledgeReferences: + """Validate one LLM node's additive direct-KB and Collection references.""" + + if not isinstance(data, Mapping): + _raise("knowledge_reference_data_invalid", field_path) + + raw_direct = _reference_list( + data, + key="knowledgeBases", + field_path=field_path, + limit=MAX_RUNTIME_DIRECT_KB_REFERENCES, + ) + direct: list[KnowledgeBaseGraphReference] = [] + for index, raw_item in enumerate(raw_direct): + item_path = f"{field_path}.knowledgeBases[{index}]" + if not isinstance(raw_item, Mapping): + _raise("knowledge_reference_item_invalid", item_path) + if set(raw_item) != {"id", "name"}: + _raise("knowledge_reference_item_invalid", item_path) + direct.append( + KnowledgeBaseGraphReference( + id=_canonical_uuid(raw_item["id"], field_path=f"{item_path}.id"), + name=_display_value( + raw_item["name"], + field_path=f"{item_path}.name", + required=True, + ) + or "", + ) + ) + + raw_collections = _reference_list( + data, + key="knowledgeCollections", + field_path=field_path, + limit=MAX_RUNTIME_COLLECTION_REFERENCES, + ) + collections: list[KnowledgeCollectionGraphReference] = [] + for index, raw_item in enumerate(raw_collections): + item_path = f"{field_path}.knowledgeCollections[{index}]" + if not isinstance(raw_item, Mapping): + _raise("knowledge_reference_item_invalid", item_path) + allowed_fields = {"id", "safeLabel"} + if "id" not in raw_item or not set(raw_item).issubset(allowed_fields): + _raise("knowledge_reference_item_invalid", item_path) + collections.append( + KnowledgeCollectionGraphReference( + id=_canonical_uuid(raw_item["id"], field_path=f"{item_path}.id"), + safe_label=_display_value( + raw_item.get("safeLabel"), + field_path=f"{item_path}.safeLabel", + required=False, + ), + ) + ) + + return WorkflowNodeKnowledgeReferences( + field_path=field_path, + direct_references=tuple(direct), + collection_references=tuple(collections), + ) + + +def parse_workflow_knowledge_references( + graph: Mapping[str, Any], +) -> tuple[WorkflowNodeKnowledgeReferences, ...]: + """Validate Knowledge references in root and nested Loop subgraphs.""" + + if not isinstance(graph, Mapping): + _raise("workflow_graph_invalid", "graph") + + results: list[WorkflowNodeKnowledgeReferences] = [] + pending: list[tuple[Mapping[str, Any], str]] = [(graph, "graph")] + while pending: + current_graph, graph_path = pending.pop() + nodes = current_graph.get("nodes", []) + if not isinstance(nodes, list): + _raise("workflow_graph_invalid", f"{graph_path}.nodes") + for index, node in enumerate(nodes): + node_path = f"{graph_path}.nodes[{index}]" + if not isinstance(node, Mapping): + _raise("workflow_graph_invalid", node_path) + data = node.get("data") + if node.get("type") == "llmNode": + if not isinstance(data, Mapping): + _raise("knowledge_reference_data_invalid", f"{node_path}.data") + results.append( + parse_llm_knowledge_references( + data, + field_path=f"{node_path}.data", + ) + ) + if not isinstance(data, Mapping): + continue + subgraph = data.get("subGraph") + if subgraph is None: + continue + if not isinstance(subgraph, Mapping): + _raise("workflow_graph_invalid", f"{node_path}.data.subGraph") + pending.append((subgraph, f"{node_path}.data.subGraph")) + return tuple(results) + + +def aggregate_workflow_knowledge_reference_ids( + parsed_nodes: tuple[WorkflowNodeKnowledgeReferences, ...], +) -> tuple[tuple[UUID, ...], tuple[UUID, ...]]: + """Return first-occurrence IDs across a graph for bulk authorization.""" + + direct: list[UUID] = [] + collections: list[UUID] = [] + for node in parsed_nodes: + direct.extend(node.direct_kb_ids) + collections.extend(node.collection_ids) + return _unique_ids(tuple(direct)), _unique_ids(tuple(collections)) diff --git a/apps/shared/schemas/knowledge.py b/apps/shared/schemas/knowledge.py index 57df9c91f..58bb5670d 100644 --- a/apps/shared/schemas/knowledge.py +++ b/apps/shared/schemas/knowledge.py @@ -142,6 +142,17 @@ class KnowledgeCollectionListResponse(BaseModel): can_change_public_visibility: bool = False +class KnowledgeCollectionLLMSelectableItem(BaseModel): + id: UUID + safe_label: str | None = None + + +class KnowledgeCollectionLLMSelectableResponse(BaseModel): + collections: list[KnowledgeCollectionLLMSelectableItem] = Field( + default_factory=list + ) + + class KnowledgeCollectionItemLinkRequest(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/apps/shared/tests/domain/test_workflow_knowledge_references.py b/apps/shared/tests/domain/test_workflow_knowledge_references.py new file mode 100644 index 000000000..e7c5dcb4a --- /dev/null +++ b/apps/shared/tests/domain/test_workflow_knowledge_references.py @@ -0,0 +1,238 @@ +from uuid import UUID + +import pytest + +from apps.shared.domain.workflow_knowledge_references import ( + WorkflowKnowledgeReferenceError, + aggregate_workflow_knowledge_reference_ids, + parse_llm_knowledge_references, + parse_workflow_knowledge_references, +) + + +KB_1 = "00000000-0000-0000-0000-000000000001" +KB_2 = "00000000-0000-0000-0000-000000000002" +COLLECTION_1 = "10000000-0000-0000-0000-000000000001" +ALPHA_UUID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + + +def _llm_data(**overrides): + data = { + "knowledgeBases": [{"id": KB_1, "name": "규칙"}], + "knowledgeCollections": [ + {"id": COLLECTION_1, "safeLabel": "사내 문서"} + ], + } + data.update(overrides) + return data + + +def _assert_error(data, reason_code, field_path): + with pytest.raises(WorkflowKnowledgeReferenceError) as error: + parse_llm_knowledge_references(data) + assert error.value.reason_code == reason_code + assert error.value.field_path == field_path + assert KB_1 not in str(error.value) + + +def test_legacy_direct_only_and_missing_collection_field_are_valid(): + parsed = parse_llm_knowledge_references( + {"knowledgeBases": [{"id": KB_1, "name": ""}]} + ) + + assert parsed.direct_kb_ids == (UUID(KB_1),) + assert parsed.collection_ids == () + assert parsed.direct_references[0].name == "" + + +def test_collection_only_and_mixed_references_are_valid_without_mode(): + collection_only = parse_llm_knowledge_references( + {"knowledgeCollections": [{"id": COLLECTION_1}]} + ) + mixed = parse_llm_knowledge_references(_llm_data()) + + assert collection_only.has_references is True + assert collection_only.collection_references[0].safe_label is None + assert mixed.direct_kb_ids == (UUID(KB_1),) + assert mixed.collection_ids == (UUID(COLLECTION_1),) + + +@pytest.mark.parametrize("key", ["knowledgeBases", "knowledgeCollections"]) +@pytest.mark.parametrize("value", [None, "bad", {}, 1, True]) +def test_reference_lists_must_be_explicit_lists(key, value): + _assert_error( + {key: value}, + "knowledge_reference_list_invalid", + f"data.{key}", + ) + + +@pytest.mark.parametrize("key", ["knowledgeBases", "knowledgeCollections"]) +@pytest.mark.parametrize("value", [None, "bad", 1, True]) +def test_reference_items_must_be_objects(key, value): + _assert_error( + {key: [value]}, + "knowledge_reference_item_invalid", + f"data.{key}[0]", + ) + + +def test_direct_and_collection_item_shapes_are_strict(): + _assert_error( + {"knowledgeBases": [{"id": KB_1, "name": "ok", "extra": True}]}, + "knowledge_reference_item_invalid", + "data.knowledgeBases[0]", + ) + _assert_error( + {"knowledgeCollections": [{"safeLabel": "missing id"}]}, + "knowledge_reference_item_invalid", + "data.knowledgeCollections[0]", + ) + _assert_error( + {"knowledgeCollections": [{"id": COLLECTION_1, "name": "raw"}]}, + "knowledge_reference_item_invalid", + "data.knowledgeCollections[0]", + ) + + +@pytest.mark.parametrize( + "value", + [ + "", + "not-a-uuid", + "00000000000000000000000000000001", + ALPHA_UUID.upper(), + "{00000000-0000-0000-0000-000000000001}", + 1, + True, + ], +) +def test_reference_ids_must_be_canonical_uuid_strings(value): + _assert_error( + {"knowledgeBases": [{"id": value, "name": "safe"}]}, + "knowledge_reference_id_invalid", + "data.knowledgeBases[0].id", + ) + + +@pytest.mark.parametrize("field,value", [("name", None), ("name", 1)]) +def test_direct_display_name_is_required_string(field, value): + _assert_error( + {"knowledgeBases": [{"id": KB_1, field: value}]}, + "knowledge_reference_display_invalid", + "data.knowledgeBases[0].name", + ) + + +@pytest.mark.parametrize("display", ["line\nbreak", "tab\tvalue", "x\x00y"]) +def test_display_snapshots_reject_control_characters(display): + _assert_error( + {"knowledgeCollections": [{"id": COLLECTION_1, "safeLabel": display}]}, + "knowledge_reference_display_invalid", + "data.knowledgeCollections[0].safeLabel", + ) + + +def test_display_length_boundary_is_strict(): + parse_llm_knowledge_references( + {"knowledgeCollections": [{"id": COLLECTION_1, "safeLabel": "가" * 255}]} + ) + _assert_error( + {"knowledgeCollections": [{"id": COLLECTION_1, "safeLabel": "가" * 256}]}, + "knowledge_reference_display_invalid", + "data.knowledgeCollections[0].safeLabel", + ) + + +@pytest.mark.parametrize("key", ["knowledgeBases", "knowledgeCollections"]) +def test_each_reference_list_has_an_independent_twenty_item_limit(key): + if key == "knowledgeBases": + item = {"id": KB_1, "name": "safe"} + else: + item = {"id": COLLECTION_1} + parse_llm_knowledge_references({key: [item] * 20}) + _assert_error( + {key: [item] * 21}, + "knowledge_reference_limit_exceeded", + f"data.{key}", + ) + + +def test_duplicates_remain_configured_but_runtime_ids_are_first_deduplicated(): + parsed = parse_llm_knowledge_references( + { + "knowledgeBases": [ + {"id": KB_1, "name": "first"}, + {"id": KB_2, "name": "second"}, + {"id": KB_1, "name": "duplicate"}, + ] + } + ) + + assert len(parsed.direct_references) == 3 + assert parsed.direct_kb_ids == (UUID(KB_1), UUID(KB_2)) + + +def test_root_and_nested_loop_graphs_are_validated_and_aggregated(): + graph = { + "nodes": [ + {"id": "root", "type": "llmNode", "data": _llm_data()}, + { + "id": "loop", + "type": "loopNode", + "data": { + "subGraph": { + "nodes": [ + { + "id": "nested", + "type": "llmNode", + "data": { + "knowledgeBases": [ + {"id": KB_2, "name": "nested"}, + {"id": KB_1, "name": "duplicate"}, + ] + }, + } + ] + } + }, + }, + ] + } + + parsed = parse_workflow_knowledge_references(graph) + direct, collections = aggregate_workflow_knowledge_reference_ids(parsed) + + assert len(parsed) == 2 + assert direct == (UUID(KB_1), UUID(KB_2)) + assert collections == (UUID(COLLECTION_1),) + + +def test_nested_error_uses_index_path_without_echoing_node_identity(): + graph = { + "nodes": [ + { + "id": "secret-node-id", + "type": "loopNode", + "data": { + "subGraph": { + "nodes": [ + { + "id": "hidden-llm-id", + "type": "llmNode", + "data": {"knowledgeCollections": None}, + } + ] + } + }, + } + ] + } + + with pytest.raises(WorkflowKnowledgeReferenceError) as error: + parse_workflow_knowledge_references(graph) + + assert error.value.field_path == ( + "graph.nodes[0].data.subGraph.nodes[0].data.knowledgeCollections" + ) + assert "secret-node-id" not in str(error.value) diff --git a/apps/workflow_engine/tests/nodes/test_llm_node_runtime.py b/apps/workflow_engine/tests/nodes/test_llm_node_runtime.py index dc3377e18..540605d9c 100644 --- a/apps/workflow_engine/tests/nodes/test_llm_node_runtime.py +++ b/apps/workflow_engine/tests/nodes/test_llm_node_runtime.py @@ -26,6 +26,9 @@ SourceType, ) from apps.shared.db.models.llm import LLMModel # noqa: E402 +from apps.shared.domain.workflow_knowledge_references import ( # noqa: E402 + WorkflowKnowledgeReferenceError, +) from apps.shared.schemas.rag import ChunkPreview # noqa: E402 from apps.shared.services.rag_evidence_policy import RAGEvidenceDecision # noqa: E402 from apps.shared.services.tracing.metadata import TraceMetadataSanitizer # noqa: E402 @@ -1761,7 +1764,7 @@ def raise_retrieval_error(self, query, db_session): assert result["metadata"]["rag"]["insufficiency_reason"] == "operational_error" -def test_llm_node_rag_options_are_capped_during_validation(): +def test_llm_node_rejects_over_limit_kbs_without_silent_slicing(): data = LLMNodeData( title="LLM", provider="openai", @@ -1774,10 +1777,12 @@ def test_llm_node_rag_options_are_capped_during_validation(): ], ) - data.validate() + with pytest.raises(WorkflowKnowledgeReferenceError) as error: + data.validate() + assert error.value.reason_code == "knowledge_reference_limit_exceeded" assert data.topK == MAX_RAG_CHUNKS_PER_KB - assert len(data.knowledgeBases) == MAX_RAG_RETRIEVAL_KBS + assert len(data.knowledgeBases) == MAX_RAG_RETRIEVAL_KBS + 5 def test_llm_node_rag_partial_retrieval_failure_uses_safe_partial_result( diff --git a/apps/workflow_engine/workflow/core/workflow_node_factory.py b/apps/workflow_engine/workflow/core/workflow_node_factory.py index a3288e333..d33888e79 100644 --- a/apps/workflow_engine/workflow/core/workflow_node_factory.py +++ b/apps/workflow_engine/workflow/core/workflow_node_factory.py @@ -1,6 +1,10 @@ from typing import Dict from apps.shared.schemas.workflow import NodeSchema +from apps.shared.domain.workflow_knowledge_references import ( + WorkflowKnowledgeReferenceError, + parse_llm_knowledge_references, +) from apps.shared.domain.mail_credential import ( validate_mail_node_credential_boundary, validate_mail_processing_node_boundary, @@ -49,6 +53,7 @@ VariableExtractionNode, VariableExtractionNodeData, ) +from apps.workflow_engine.workflow.errors import NonRetryableWorkflowError class NodeFactory: @@ -105,6 +110,11 @@ def create(schema: NodeSchema, context: Dict = None) -> Node: validate_mail_node_credential_boundary(schema.data) elif schema.type in {"gmailDraftNode", "mailAcknowledgeNode"}: validate_mail_processing_node_boundary(schema.type, schema.data) + elif schema.type == "llmNode": + try: + parse_llm_knowledge_references(schema.data) + except WorkflowKnowledgeReferenceError as exc: + raise NonRetryableWorkflowError(exc.reason_code) from exc NodeClass, DataClass = NodeFactory.NODE_REGISTRY[schema.type] data = DataClass(**schema.data) diff --git a/apps/workflow_engine/workflow/nodes/llm/__init__.py b/apps/workflow_engine/workflow/nodes/llm/__init__.py index ade251416..982af2365 100644 --- a/apps/workflow_engine/workflow/nodes/llm/__init__.py +++ b/apps/workflow_engine/workflow/nodes/llm/__init__.py @@ -1,4 +1,4 @@ -from .entities import LLMNodeData +from .entities import KnowledgeCollectionRef, LLMNodeData from .llm_node import LLMNode -__all__ = ["LLMNode", "LLMNodeData"] +__all__ = ["KnowledgeCollectionRef", "LLMNode", "LLMNodeData"] diff --git a/apps/workflow_engine/workflow/nodes/llm/entities.py b/apps/workflow_engine/workflow/nodes/llm/entities.py index 7c1e808a0..4d2313065 100644 --- a/apps/workflow_engine/workflow/nodes/llm/entities.py +++ b/apps/workflow_engine/workflow/nodes/llm/entities.py @@ -1,7 +1,14 @@ from typing import Any, Dict, List, Literal, Optional -from pydantic import BaseModel, Field - +from pydantic import BaseModel, ConfigDict, Field + +from apps.shared.domain.knowledge_runtime_candidates import ( + MAX_RUNTIME_COLLECTION_REFERENCES, + MAX_RUNTIME_DIRECT_KB_REFERENCES, +) +from apps.shared.domain.workflow_knowledge_references import ( + parse_llm_knowledge_references, +) from apps.workflow_engine.workflow.nodes.base.entities import BaseNodeData @@ -18,15 +25,25 @@ class LLMVariable(BaseModel): class KnowledgeBaseRef(BaseModel): + model_config = ConfigDict(extra="forbid") + id: str name: str +class KnowledgeCollectionRef(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + safeLabel: Optional[str] = None + + EvidenceSufficiencyPolicy = Literal["minimum_evidence", "strict_citation"] RAGFailurePolicy = Literal["safe_no_result", "fail_node"] SourceTierPolicy = Literal["tie_break", "off"] QueryRewriteMode = Literal["off", "template", "llm_assisted"] -MAX_RAG_RETRIEVAL_KBS = 20 +MAX_RAG_RETRIEVAL_KBS = MAX_RUNTIME_DIRECT_KB_REFERENCES +MAX_RAG_COLLECTIONS = MAX_RUNTIME_COLLECTION_REFERENCES MAX_RAG_CHUNKS_PER_KB = 8 MAX_RAG_QUERY_REWRITE_TEMPLATE_LENGTH = 512 @@ -72,6 +89,10 @@ class LLMNodeData(BaseNodeData): default_factory=list, description="검색할 지식 베이스 목록", ) + knowledgeCollections: List[KnowledgeCollectionRef] = Field( + default_factory=list, + description="실행 시점에 멤버십을 해석할 지식 컬렉션 목록", + ) scoreThreshold: float = Field(default=0.5, description="유사도 점수 임계값") topK: int = Field(default=3, description="상위 K개 문서 반환") dedupeRetrievedContext: bool = Field( @@ -174,8 +195,18 @@ def validate(self) -> None: self.topK = 1 if self.topK > MAX_RAG_CHUNKS_PER_KB: self.topK = MAX_RAG_CHUNKS_PER_KB - if len(self.knowledgeBases) > MAX_RAG_RETRIEVAL_KBS: - self.knowledgeBases = self.knowledgeBases[:MAX_RAG_RETRIEVAL_KBS] + parse_llm_knowledge_references( + { + "knowledgeBases": [ + reference.model_dump(mode="python") + for reference in self.knowledgeBases + ], + "knowledgeCollections": [ + reference.model_dump(mode="python") + for reference in self.knowledgeCollections + ], + } + ) if self.queryRewriteMode == "llm_assisted": raise ValueError("llm_assisted query rewrite는 아직 사용할 수 없습니다.") From 887bcc7894c291e63df7c82d409f122b347124ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Mon, 13 Jul 2026 20:00:53 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat(workflow):=20KB=EC=99=80=20Collectio?= =?UTF-8?q?n=20=ED=98=BC=ED=95=A9=20=EC=84=A0=ED=83=9D=20UI=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../knowledge/api/knowledgeApi.test.ts | 25 ++ .../features/knowledge/api/knowledgeApi.ts | 16 + .../app/features/knowledge/types/Knowledge.ts | 9 + .../nodes/llm/components/LLMNodePanel.tsx | 54 +-- .../llm/components/LLMReferenceSidePanel.tsx | 418 +++++++++++++++--- .../features/workflow/config/nodeRegistry.tsx | 1 + .../nodes/llm-reference-side-panel.test.tsx | 164 +++++++ .../utils/llmKnowledgeBaseSelection.test.ts | 89 +++- .../tests/utils/validateWorkflowGraph.test.ts | 126 +++++- .../app/features/workflow/types/Nodes.ts | 13 +- .../utils/llmKnowledgeBaseSelection.ts | 162 ++++--- .../workflow/utils/validateWorkflowGraph.ts | 86 ++++ 12 files changed, 1009 insertions(+), 154 deletions(-) create mode 100644 apps/client/app/features/workflow/tests/nodes/llm-reference-side-panel.test.tsx diff --git a/apps/client/app/features/knowledge/api/knowledgeApi.test.ts b/apps/client/app/features/knowledge/api/knowledgeApi.test.ts index e892cf9d9..d8d559448 100644 --- a/apps/client/app/features/knowledge/api/knowledgeApi.test.ts +++ b/apps/client/app/features/knowledge/api/knowledgeApi.test.ts @@ -371,6 +371,31 @@ describe('knowledgeApi safe failure logging', () => { }); describe('knowledgeApi collection management', () => { + it('loads the route-safe Workflow Collection picker projection', async () => { + vi.mocked(apiClient.get).mockResolvedValueOnce({ + data: { + collections: [ + { + id: '11111111-1111-1111-1111-111111111111', + safe_label: '사내 문서', + }, + ], + }, + }); + + const response = await knowledgeApi.getLLMSelectableKnowledgeCollections(); + + expect(apiClient.get).toHaveBeenCalledWith( + '/knowledge/llm-selectable-collections', + ); + expect(response.collections).toEqual([ + { + id: '11111111-1111-1111-1111-111111111111', + safe_label: '사내 문서', + }, + ]); + }); + it('updates Knowledge Base safe metadata', async () => { vi.mocked(apiClient.patch).mockResolvedValueOnce({ data: { diff --git a/apps/client/app/features/knowledge/api/knowledgeApi.ts b/apps/client/app/features/knowledge/api/knowledgeApi.ts index 1f5cbdbf6..2eb88201c 100644 --- a/apps/client/app/features/knowledge/api/knowledgeApi.ts +++ b/apps/client/app/features/knowledge/api/knowledgeApi.ts @@ -12,6 +12,8 @@ import { KnowledgeCollectionRoleBundle, KnowledgeCollectionResponse, KnowledgeCollectionListResponse, + KnowledgeCollectionLLMSelectableItem, + KnowledgeCollectionLLMSelectableResponse, KnowledgeCollectionItemResponse, KnowledgeCollectionItemsResponse, KnowledgeCollectionLinkCandidate, @@ -174,6 +176,8 @@ export type { KnowledgeCollectionRoleBundle, KnowledgeCollectionResponse, KnowledgeCollectionListResponse, + KnowledgeCollectionLLMSelectableItem, + KnowledgeCollectionLLMSelectableResponse, KnowledgeCollectionItemResponse, KnowledgeCollectionItemsResponse, KnowledgeCollectionLinkCandidate, @@ -335,6 +339,18 @@ export const knowledgeApi = { } }, + // LLM 노드에서 route 권한으로 선택 가능한 Collection의 최소 projection 조회 + getLLMSelectableKnowledgeCollections: + async (): Promise => { + try { + const response = await api.get('/knowledge/llm-selectable-collections'); + return response.data; + } catch (error) { + logKnowledgeApiFailure('getLLMSelectableKnowledgeCollections', error); + throw error; + } + }, + // 지식 상세 조회 getKnowledgeBase: async ( id: string, diff --git a/apps/client/app/features/knowledge/types/Knowledge.ts b/apps/client/app/features/knowledge/types/Knowledge.ts index efde2a700..bae11096b 100644 --- a/apps/client/app/features/knowledge/types/Knowledge.ts +++ b/apps/client/app/features/knowledge/types/Knowledge.ts @@ -191,6 +191,15 @@ export interface KnowledgeCollectionListResponse { can_change_public_visibility: boolean; } +export interface KnowledgeCollectionLLMSelectableItem { + id: string; + safe_label?: string | null; +} + +export interface KnowledgeCollectionLLMSelectableResponse { + collections: KnowledgeCollectionLLMSelectableItem[]; +} + export interface KnowledgeCollectionItemResponse { item_id: string; knowledge_base_id: string; diff --git a/apps/client/app/features/workflow/components/nodes/llm/components/LLMNodePanel.tsx b/apps/client/app/features/workflow/components/nodes/llm/components/LLMNodePanel.tsx index 85b1b4511..14718f848 100644 --- a/apps/client/app/features/workflow/components/nodes/llm/components/LLMNodePanel.tsx +++ b/apps/client/app/features/workflow/components/nodes/llm/components/LLMNodePanel.tsx @@ -17,11 +17,6 @@ import { import { PromptWizardModal } from '../../../modals/PromptWizardModal'; import { ModelSelectDropdown } from './ModelSelectDropdown'; import { LLMParameterSidePanel } from './LLMParameterSidePanel'; -import { - fetchEligibleKnowledgeBases, - sanitizeSelectedKnowledgeBases, - isSameKnowledgeSelection, -} from '@/app/features/workflow/utils/llmKnowledgeBaseSelection'; import { resolveWorkflowWizardOrganizationId } from '@/app/features/workflow/utils/resolveWorkflowWizardOrganizationId'; import { DraggedOutputVariable, @@ -897,35 +892,6 @@ export function LLMNodePanel({ void loadRoutingPolicy(); }, [data.auto_model_routing, loadRoutingPolicy]); - useEffect(() => { - if (!data.knowledgeBases || data.knowledgeBases.length === 0) return; - let active = true; - const syncKnowledgeBases = async () => { - try { - const { bases, preserveSelectionIds = [] } = - await fetchEligibleKnowledgeBases(); - if (!active) return; - const nextSelected = sanitizeSelectedKnowledgeBases( - data.knowledgeBases || [], - bases, - { preserveMissingIds: preserveSelectionIds }, - ); - if ( - !isSameKnowledgeSelection(nextSelected, data.knowledgeBases || []) - ) { - updateNodeData(nodeId, { knowledgeBases: nextSelected }); - } - } catch { - // 지식 베이스 동기화 실패는 노드 편집 자체를 막지 않습니다. - } - }; - - syncKnowledgeBases(); - return () => { - active = false; - }; - }, [data.knowledgeBases, nodeId, updateNodeData]); - useEffect(() => { if (!activeHelp) return; @@ -1435,7 +1401,7 @@ export function LLMNodePanel({ - {/* 2.5 지식 베이스 버튼 (지식 베이스 그룹 통합) */} + {/* 2.5 Knowledge 선택 버튼 */}