diff --git a/.DS_Store b/.DS_Store index 1dad8b8..1c4b76e 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..2011851 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "[ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files.\"}}' || true" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.context/compound-engineering/ce-review/20260418T172025-current-branch/adversarial.json b/.context/compound-engineering/ce-review/20260418T172025-current-branch/adversarial.json new file mode 100644 index 0000000..4751a5e --- /dev/null +++ b/.context/compound-engineering/ce-review/20260418T172025-current-branch/adversarial.json @@ -0,0 +1,182 @@ +{ + "reviewer": "adversarial", + "timestamp": "2026-04-18T08:41:02Z", + "scope": { + "base": "d34d48ff786160c3fc30ec09ba1135803eb9e0e1", + "branch": "codex/rag-v1-3-team-exec-tracking", + "tracked_files_only": true, + "diff_source": ".context/compound-engineering/ce-review/20260418T172025-current-branch/diff.patch" + }, + "focus": [ + "auth and privilege boundaries", + "data mutation durability and rollback integrity", + "external model interaction failure modes", + "RAG retrieval/runtime boundary correctness under adversarial inputs" + ], + "summary": { + "critical": 1, + "high": 5, + "medium": 3, + "low": 0 + }, + "findings": [ + { + "title": "Header-spoofable admin trust boundary is amplified by newly added admin mutation surfaces", + "severity": "critical", + "file": "apps/backend/src/modules/settings/settings.controller.ts", + "line": 65, + "confidence": "high", + "autofix_class": "auth_boundary_fix", + "owner": "backend", + "requires_verification": true, + "pre_existing": true, + "worst_case": "Remote caller self-asserts admin via headers, then mutates prompt templates/provider settings/memory feedback to alter SQL behavior and governance decisions.", + "suggested_fix": "Stop deriving actor/admin from client-controlled headers; bind actor from verified auth claims only, and make `AdminOnlyGuard` trust only `req.actor` set by auth middleware (not raw headers)." + }, + { + "title": "Prompt template listing endpoint is unguarded and exposes runtime prompt content", + "severity": "high", + "file": "apps/backend/src/modules/settings/settings.controller.ts", + "line": 52, + "confidence": "high", + "autofix_class": "access_control", + "owner": "backend", + "requires_verification": true, + "pre_existing": false, + "worst_case": "Attacker enumerates active SQL prompt templates and scope keys, then tailors prompt-injection payloads and model bypass probes.", + "suggested_fix": "Protect `GET /api/v1/settings/prompts` with admin/workspace ACL, and provide a redacted listing mode that omits `content` by default." + }, + { + "title": "RAG quality endpoints are unauthenticated, allowing quality-metric tampering and replay probing", + "severity": "high", + "file": "apps/backend/src/modules/rag/quality/rag-quality.controller.ts", + "line": 25, + "confidence": "high", + "autofix_class": "access_control", + "owner": "backend", + "requires_verification": true, + "pre_existing": false, + "worst_case": "Unauthenticated actor writes forged quality reports and probes run replay completeness by runId, distorting release-gate signal and exposing operational internals.", + "suggested_fix": "Require admin/internal auth for all `/api/v1/rag/quality/*` routes and validate `runId` shape with strict DTOs/pipes." + }, + { + "title": "R6 gate uses hardcoded values for critical metrics, enabling false confidence under adversarial or sparse telemetry", + "severity": "high", + "file": "apps/backend/src/modules/rag/quality/rag-quality.service.ts", + "line": 701, + "confidence": "high", + "autofix_class": "gate_correctness", + "owner": "backend", + "requires_verification": true, + "pre_existing": false, + "worst_case": "Gate decisions can appear healthier than reality because `staleCacheReadRate=0`, `graphFallbackActivationRate=0`, and `securityGatePass=true` are synthetic constants, not observed telemetry.", + "suggested_fix": "Source all R6 metrics from measured telemetry/replay facts; when unavailable, mark as `insufficient_samples` instead of static pass values." + }, + { + "title": "Semantic promotion publishes one semantic version per term winner, causing active semantic snapshot truncation", + "severity": "high", + "file": "apps/backend/src/modules/rag/events/rag-event-consumer.service.ts", + "line": 282, + "confidence": "high", + "autofix_class": "data_model_consistency", + "owner": "backend", + "requires_verification": true, + "pre_existing": false, + "worst_case": "A single `semantic_promoted` event containing multiple terms can end with only the last term set in the active semantic version, degrading semantic resolution and downstream SQL correctness.", + "suggested_fix": "Publish exactly one semantic registry version per scope/domain per event using the complete resolved term set, not per-term loop publishes." + }, + { + "title": "New RAG persistence layers fail open to in-memory writes on Prisma errors", + "severity": "high", + "file": "apps/backend/src/modules/rag/observability/rag-replay.repository.ts", + "line": 338, + "confidence": "high", + "autofix_class": "durability_guard", + "owner": "backend", + "requires_verification": true, + "pre_existing": false, + "worst_case": "During DB partial outage, writes are acknowledged but silently non-durable; restart drops replay/index/semantic state and invalidates audit/reproducibility guarantees.", + "suggested_fix": "On primary persistence failure, return explicit degraded/error status and queue durable retries; avoid silent success paths that only persist in process memory." + }, + { + "title": "Required-domain coverage can be skipped when top-N is already full", + "severity": "medium", + "file": "apps/backend/src/modules/rag/retrieval/rag-retrieval.service.ts", + "line": 590, + "confidence": "high", + "autofix_class": "selection_logic_fix", + "owner": "backend", + "requires_verification": true, + "pre_existing": false, + "worst_case": "Retrieval context lacks required schema/semantic domains despite policy intent, increasing hallucinated SQL and brittle planning behavior on hard queries.", + "suggested_fix": "When required domains are missing and limit is reached, replace lowest-ranked non-required candidates so required-domain constraints are actually enforced." + }, + { + "title": "Model rerank parser fails open to mock ranking on malformed output", + "severity": "medium", + "file": "apps/backend/src/modules/llm/provider-router.service.ts", + "line": 127, + "confidence": "high", + "autofix_class": "external_dependency_hardening", + "owner": "backend", + "requires_verification": true, + "pre_existing": false, + "worst_case": "Provider instability or adversarial model output can force deterministic mock reranking, silently changing ranking quality and masking upstream failures.", + "suggested_fix": "On parse failure, mark rerank degraded and preserve primary ranking (or explicit error path) rather than silently substituting mock ranking." + }, + { + "title": "Prompt builder labels retrieved/template text as trusted and high-priority guidance", + "severity": "medium", + "file": "apps/backend/src/modules/agent/sql/sql-prompt.builder.ts", + "line": 56, + "confidence": "high", + "autofix_class": "prompt_injection_guard", + "owner": "backend", + "requires_verification": true, + "pre_existing": false, + "worst_case": "Injected corpus/template text is treated as authoritative instruction, increasing risk of SQL-generation steering and policy evasion attempts.", + "suggested_fix": "Mark retrieval/template blocks explicitly as untrusted data, bound length aggressively, and wrap in delimiters with instruction-hierarchy warnings." + } + ], + "residual_risks": [ + "Even after endpoint guards are added, role derivation from unsigned headers remains a single-point compromise unless auth trust boundaries are redesigned.", + "Current quality/replay APIs expose rich operational detail by runId; without tenancy scoping and rate limits, enumeration and telemetry abuse remain plausible.", + "In-memory fallback patterns across new RAG modules can still create state divergence unless a strict durability policy is enforced." + ], + "testing_gaps": [ + "No adversarial integration test proving header spoofing cannot escalate privileges on settings/memory/quality routes.", + "No test asserting required-domain coverage replacement behavior when candidate limit is saturated.", + "No test that multi-term `semantic_promoted` events preserve all winners in the final active semantic version.", + "No negative-path test ensuring malformed rerank model output does not silently switch to mock ranking in production mode." + ], + "fresh_verification": { + "verified_at_utc": "2026-04-18T08:42:54Z", + "head_commit": "105684cf7a23ec4b6e6f3ee7a08c1a73ef100fa2", + "branch": "codex/rag-v1-3-team-exec-tracking", + "line_rechecks": [ + "apps/backend/src/modules/settings/settings.controller.ts:52", + "apps/backend/src/modules/auth/request-actor.middleware.ts:70", + "apps/backend/src/modules/auth/admin-only.guard.ts:17", + "apps/backend/src/modules/rag/quality/rag-quality.controller.ts:25", + "apps/backend/src/modules/rag/quality/rag-quality.service.ts:701", + "apps/backend/src/modules/rag/retrieval/rag-retrieval.service.ts:590", + "apps/backend/src/modules/rag/events/rag-event-consumer.service.ts:282", + "apps/backend/src/modules/llm/provider-router.service.ts:127", + "apps/backend/src/modules/agent/sql/sql-prompt.builder.ts:56" + ], + "targeted_tests": [ + { + "command": "pnpm --filter @text2sql/backend test -- test/integration/memory-feedback-api.spec.ts --runInBand", + "result": "pass", + "tests_passed": "4/4" + }, + { + "command": "pnpm --filter @text2sql/backend test -- test/integration/rag-quality.spec.ts --runInBand", + "result": "pass", + "tests_passed": "3/3" + } + ], + "note": "Passing tests do not cover header-spoof privilege escalation or required-domain replacement behavior under saturated candidate limits." + }, + "recommendation": "REQUEST_CHANGES" +} diff --git a/.context/compound-engineering/ce-review/20260418T172025-current-branch/agent-native.json b/.context/compound-engineering/ce-review/20260418T172025-current-branch/agent-native.json new file mode 100644 index 0000000..8d38636 --- /dev/null +++ b/.context/compound-engineering/ce-review/20260418T172025-current-branch/agent-native.json @@ -0,0 +1,104 @@ +{ + "reviewer": "agent-native-reviewer", + "scope": "tracked diff from d34d48ff786160c3fc30ec09ba1135803eb9e0e1", + "summary": "Core governance actions were added to user-facing UI (prompt templates, glossary lifecycle, RAG memory/anchor governance), but assistant runtime tooling still exposes only runReadOnlySql. This creates agent-native action parity gaps for newly introduced operational controls.", + "gaps": [ + { + "id": "ANP-001", + "severity": "P1", + "title": "Prompt template lifecycle is user-operable but not assistant-tool operable", + "priority": "must-have", + "ui_actions": [ + { + "action": "Create/update/delete prompt template", + "location": "apps/frontend/src/app/prompts/page.tsx", + "lines": [177, 211, 213, 225, 229, 249, 343, 356] + } + ], + "backend_user_surface": [ + { + "route": "GET/POST/PATCH/DELETE /api/v1/settings/prompts", + "location": "apps/backend/src/modules/settings/settings.controller.ts", + "lines": [52, 65, 80, 100] + } + ], + "agent_surface": [ + { + "location": "apps/backend/src/modules/agent/sql/tools/sql-tool-registry.service.ts", + "lines": [17, 22], + "details": "Only runReadOnlySql is registered; no prompt template management tool" + } + ], + "impact": "Users can change runtime prompt governance from UI, but the assistant cannot perform the same mutation workflow when asked.", + "suggested_fix": "Add assistant-callable tools for prompt template list/create/update/delete with admin guardrails and audit metadata, then expose capability hints in system/runtime context.", + "confidence": 0.95 + }, + { + "id": "ANP-002", + "severity": "P1", + "title": "Glossary term lifecycle (create/edit/toggle) lacks assistant action parity", + "priority": "must-have", + "ui_actions": [ + { + "action": "Create/edit/toggle glossary terms and scoped datasource linkage", + "location": "apps/frontend/src/app/glossary/page.tsx", + "lines": [271, 295, 305, 330, 334, 373, 560, 631, 640] + } + ], + "agent_surface": [ + { + "location": "apps/backend/src/modules/agent/sql/tools/sql-tool-registry.service.ts", + "lines": [17, 22], + "details": "No glossary management tool present" + } + ], + "impact": "Glossary controls directly influence semantic retrieval behavior, but assistant cannot execute the same glossary governance actions available to users.", + "suggested_fix": "Introduce primitive glossary tools (list_terms/create_term/update_term/toggle_term/list_anchors) scoped by auth and datasource, returning audit-friendly structured results.", + "confidence": 0.93 + }, + { + "id": "ANP-003", + "severity": "P2", + "title": "RAG governance actions (memory feedback, anchor create/rollback) are UI/API only", + "priority": "should-have", + "ui_actions": [ + { + "action": "Submit memory feedback", + "location": "apps/frontend/src/app/settings/page.tsx", + "lines": [476, 483, 894, 923] + }, + { + "action": "Create glossary anchor and rollback anchor", + "location": "apps/frontend/src/app/settings/page.tsx", + "lines": [503, 517, 536, 550, 977, 992, 1003, 1024] + } + ], + "backend_user_surface": [ + { + "route": "POST /api/v1/rag/memory/feedback", + "location": "apps/backend/src/modules/memory/memory.controller.ts", + "lines": [10, 14] + } + ], + "agent_surface": [ + { + "location": "apps/backend/src/modules/agent/sql/tools/sql-tool-registry.service.ts", + "lines": [17, 22], + "details": "No memory feedback or anchor governance tools" + } + ], + "impact": "Operators can run critical RAG governance operations via UI, but assistant cannot execute equivalent workflows in-band.", + "suggested_fix": "Add guarded assistant primitives for memory feedback and anchor governance (create_anchor/rollback_anchor) with idempotency + explicit confirmation fields.", + "confidence": 0.9 + } + ], + "diagnostics": { + "lsp_diagnostics_directory": "unavailable (omx_code_intel transport closed)", + "fallback_checks": [ + "pnpm --filter @text2sql/backend run build (pass)", + "pnpm --filter @text2sql/frontend run build (pass)", + "pattern scan: console.log / empty catch / hardcoded apiKey on tracked TS/JS diff (no matches)" + ] + }, + "verdict": "NEEDS_WORK" +} diff --git a/.context/compound-engineering/ce-review/20260418T172025-current-branch/api-contract.json b/.context/compound-engineering/ce-review/20260418T172025-current-branch/api-contract.json new file mode 100644 index 0000000..994ca1f --- /dev/null +++ b/.context/compound-engineering/ce-review/20260418T172025-current-branch/api-contract.json @@ -0,0 +1,112 @@ +{ + "reviewer": "api-contract", + "scope": { + "base": "d34d48ff786160c3fc30ec09ba1135803eb9e0e1", + "branch": "codex/rag-v1-3-team-exec-tracking", + "tracked_files_only": true + }, + "stage1_spec_compliance": { + "status": "failed", + "summary": "Core chat sync/stream contract additions are generally aligned, but newly introduced RAG quality and memory contracts are not consistently modeled in shared-types and controller signatures." + }, + "stage2_code_quality": { + "lsp_diagnostics": { + "status": "fallback_used", + "reason": "omx_code_intel MCP transport closed during this review", + "fallback_checks": [ + { + "command": "pnpm --filter @text2sql/shared-types run build", + "result": "pass" + }, + { + "command": "pnpm --filter @text2sql/backend run build", + "result": "pass" + }, + { + "command": "pnpm --filter @text2sql/frontend run build", + "result": "pass" + } + ] + }, + "pattern_scans": { + "console_log_in_diff": "none", + "empty_catch_in_diff": "none", + "hardcoded_api_key_assignment_in_diff": "none" + } + }, + "findings": [ + { + "title": "RagQuality shared contract is missing fields returned by the new backend response", + "severity": "P1", + "file": "packages/shared-types/src/api.ts", + "line": 165, + "confidence": 0.97, + "autofix_class": "manual", + "owner": "review-fixer", + "requires_verification": true, + "pre_existing": false, + "suggested_fix": "Extend `RagQualityGateReport` in shared-types to include `datasourceOrchestration`, `cacheBudget`, and `r6` so it matches backend snapshot payload. Keep field names and nested shapes aligned with `apps/backend/src/modules/rag/quality/rag-quality.service.ts` to avoid typed-client blind spots." + }, + { + "title": "New API controllers use `ApiResponse`, which disables compile-time DTO/response contract locking", + "severity": "P2", + "file": "apps/backend/src/modules/rag/quality/rag-quality.controller.ts", + "line": 15, + "confidence": 0.93, + "autofix_class": "gated_auto", + "owner": "review-fixer", + "requires_verification": true, + "pre_existing": false, + "suggested_fix": "Replace `ApiResponse` with concrete shared-types contracts: `ApiResponse` for `/report`, `ApiResponse` for `/replay/:runId`, and similarly type memory feedback endpoint as `ApiResponse` in `memory.controller.ts`." + }, + { + "title": "RAG quality report write endpoint uses service-local interface instead of a shared request DTO contract", + "severity": "P2", + "file": "apps/backend/src/modules/rag/quality/rag-quality.controller.ts", + "line": 21, + "confidence": 0.9, + "autofix_class": "manual", + "owner": "review-fixer", + "requires_verification": true, + "pre_existing": false, + "suggested_fix": "Introduce a shared request contract (for example `RagQualityEvaluationRequest`) in `packages/shared-types`, mirror it with a backend class-validator DTO, and use that DTO in controller signatures to keep request shape consistent across producer/consumer code." + } + ], + "residual_risks": [ + "Typed frontend/backend integrations can silently miss R6 and orchestration metrics even though backend already emits them.", + "`unknown`-typed controller responses increase future contract drift risk for newly added RAG endpoints." + ], + "testing_gaps": [ + "No contract-freeze test currently asserts that shared `RagQualityGateReport` exactly matches backend `/api/v1/rag/quality/report` payload keys.", + "No compile-time API contract assertion binds `memory.controller` and `rag-quality.controller` return types to shared-types interfaces." + ], + "recommendation": "REQUEST_CHANGES", + "fresh_verification_evidence": { + "verified_at": "2026-04-18T17:46:37+09:00", + "branch": "codex/rag-v1-3-team-exec-tracking", + "head": "105684cf7a23ec4b6e6f3ee7a08c1a73ef100fa2", + "base_ancestor": "d34d48ff786160c3fc30ec09ba1135803eb9e0e1", + "checks": [ + { + "name": "backend_rag_quality_fields", + "command": "rg -n \"datasourceOrchestration|cacheBudget|\\br6\\b\" apps/backend/src/modules/rag/quality/rag-quality.service.ts", + "result": "109: datasourceOrchestration: RagDatasourceOrchestrationReport;\n110: cacheBudget: RagCacheBudgetReport;\n124: r6: RagR6GateReport;\n199: \"data/reports/r6/gate-summary.json\",\n200: \"data/reports/r6/cache-budget-validation.json\",\n201: \"data/reports/r6/graph-fallback-chaos.json\",\n202: \"data/reports/r6/release-checklist.md\",\n203: \"data/reports/r6/rollback-rehearsal.md\"\n294: private readonly cacheBudgetRecords: RagCacheBudgetRecord[] = [];\n315: this.cacheBudgetRecords.length = 0;\n334: this.cacheBudgetRecords.push({\n372: const r6 = this.snapshotR6(latest);\n379: datasourceOrchestration: this.snapshotDatasourceOrchestration(),\n380: cacheBudget: this.snapshotCacheBudget(),\n396: r6,\n618: const samples = this.cacheBudgetRecords.filter(\n651: const cacheBudget = this.snapshotCacheBudget();\n653: const cacheSamples = cacheBudget.sampleSize1h;\n696: value: cacheBudget.cacheEligibleHitRate,\n710: value: cacheBudget.budgetDegradeRate,\n761: releaseCandidate: process.env.RELEASE_CANDIDATE?.trim() || \"r6-local\"," + }, + { + "name": "shared_rag_quality_fields", + "command": "rg -n \"interface RagQualityGateReport|datasourceOrchestration|cacheBudget|\\br6\\b\" packages/shared-types/src/api.ts", + "result": "189:export interface RagQualityGateReport {" + }, + { + "name": "unknown_typed_api_responses", + "command": "rg -n \"ApiResponse\" apps/backend/src/modules/rag/quality/rag-quality.controller.ts apps/backend/src/modules/memory/memory.controller.ts", + "result": "apps/backend/src/modules/memory/memory.controller.ts:20: ): Promise> {\napps/backend/src/modules/rag/quality/rag-quality.controller.ts:15: report(@Req() req: Request): ApiResponse {\napps/backend/src/modules/rag/quality/rag-quality.controller.ts:20: reportGlossarySelectedContext(@Req() req: Request): ApiResponse {\napps/backend/src/modules/rag/quality/rag-quality.controller.ts:29: ): ApiResponse {\napps/backend/src/modules/rag/quality/rag-quality.controller.ts:38: ): Promise> {" + }, + { + "name": "shared_types_build", + "command": "pnpm --filter @text2sql/shared-types run build", + "result": "pass" + } + ] + } +} diff --git a/.context/compound-engineering/ce-review/20260418T172025-current-branch/base.txt b/.context/compound-engineering/ce-review/20260418T172025-current-branch/base.txt new file mode 100644 index 0000000..d997626 --- /dev/null +++ b/.context/compound-engineering/ce-review/20260418T172025-current-branch/base.txt @@ -0,0 +1 @@ +d34d48ff786160c3fc30ec09ba1135803eb9e0e1 \ No newline at end of file diff --git a/.context/compound-engineering/ce-review/20260418T172025-current-branch/correctness.json b/.context/compound-engineering/ce-review/20260418T172025-current-branch/correctness.json new file mode 100644 index 0000000..62ec776 --- /dev/null +++ b/.context/compound-engineering/ce-review/20260418T172025-current-branch/correctness.json @@ -0,0 +1,62 @@ +{ + "reviewer": "correctness", + "findings": [ + { + "title": "Semantic promotion publishes a new active semantic version per term, so earlier terms in the same event are deprecated", + "severity": "P1", + "file": "apps/backend/src/modules/rag/events/rag-event-consumer.service.ts", + "line": 297, + "confidence": 0.95, + "autofix_class": "manual", + "owner": "review-fixer", + "requires_verification": true, + "pre_existing": false, + "suggested_fix": "Batch winners by canonical domain and publish exactly one semantic registry version per domain per event (containing the full promoted term set). Keep per-term chunk creation, but move publishSemanticRegistryFromGlossary outside the per-term loop." + }, + { + "title": "Domain coverage enforcement is skipped when initial top-N already reaches limit", + "severity": "P2", + "file": "apps/backend/src/modules/rag/retrieval/rag-retrieval.service.ts", + "line": 590, + "confidence": 0.9, + "autofix_class": "manual", + "owner": "review-fixer", + "requires_verification": true, + "pre_existing": false, + "suggested_fix": "When required domains are missing and selected.length===limit, replace the lowest-ranked non-required candidates (or reserve slots) so schema/sql_example/semantic_term coverage is guaranteed instead of silently skipped." + }, + { + "title": "Planner cache hit can reuse direct_sql strategy even when current semantic lock is degraded/fallback", + "severity": "P2", + "file": "apps/backend/src/modules/agent/nodes/build-physical-plan.node.ts", + "line": 45, + "confidence": 0.82, + "autofix_class": "manual", + "owner": "review-fixer", + "requires_verification": true, + "pre_existing": false, + "suggested_fix": "On cache hit, recompute strategy from current semanticPlan status/lockStatus (or bypass cache for non-locked states). Never return cached direct_sql when semanticPlan is degraded/fallback." + }, + { + "title": "R6 gate uses hardcoded pass values for stale cache read rate, graph fallback activation, and security gate", + "severity": "P2", + "file": "apps/backend/src/modules/rag/quality/rag-quality.service.ts", + "line": 701, + "confidence": 0.78, + "autofix_class": "gated_auto", + "owner": "downstream-resolver", + "requires_verification": true, + "pre_existing": false, + "suggested_fix": "Source these metrics from recorded telemetry/replay signals; if unavailable, mark them insufficient_samples instead of fixed 0/true to avoid false green gate decisions." + } + ], + "residual_risks": [ + "Incremental semantic_promoted events with multiple distinct terms can appear healthy in replay while semantic registry active version silently drops earlier terms.", + "RAG quality gate readiness may over-report safety because several R6 metrics are synthetic constants rather than observed runtime measurements." + ], + "testing_gaps": [ + "No integration test asserts multi-term semantic_promoted events keep all promoted terms in the final active semantic registry version.", + "No test covers applyDomainCoverage behavior when candidate count exceeds finalCandidateLimit and required domains are initially missing.", + "No planner-cache test covers cached strategy behavior when semanticPlan transitions from locked/ready to fallback/degraded for the same semanticVersion and question." + ] +} diff --git a/.context/compound-engineering/ce-review/20260418T172025-current-branch/data-migrations.json b/.context/compound-engineering/ce-review/20260418T172025-current-branch/data-migrations.json new file mode 100644 index 0000000..4584afc --- /dev/null +++ b/.context/compound-engineering/ce-review/20260418T172025-current-branch/data-migrations.json @@ -0,0 +1,158 @@ +{ + "reviewer": "data-migrations", + "timestamp": "2026-04-18T08:39:45Z", + "scope": { + "base": "d34d48ff786160c3fc30ec09ba1135803eb9e0e1", + "branch": "codex/rag-v1-3-team-exec-tracking", + "tracked_files_only": true, + "focus": [ + "Prisma schema/migration safety", + "data integrity", + "rollback compatibility", + "migration-vs-schema drift" + ] + }, + "files_reviewed": [ + "apps/backend/prisma/schema.prisma", + "apps/backend/prisma/migrations/20260417223016_r2_rag_foundation/migration.sql", + "apps/backend/prisma/migrations/20260417154612_r3_semantic_registry/migration.sql", + "apps/backend/test/integration/rag-foundation-schema.spec.ts", + "apps/backend/test/integration/r3-semantic-registry-migration.spec.ts", + ".github/workflows/backend-prisma-quality.yml", + "apps/backend/src/modules/rag/index/rag-index.repository.ts", + "apps/backend/src/modules/semantic-registry/semantic-registry.service.ts" + ], + "risk_level": "HIGH", + "summary": { + "critical": 0, + "high": 1, + "medium": 1, + "low": 0, + "total": 2 + }, + "stage1_spec_compliance": { + "status": "PASS_WITH_GAPS", + "checks": [ + { + "item": "Schema-first then migration artifacts present", + "result": "PASS", + "evidence": [ + "apps/backend/prisma/schema.prisma", + "apps/backend/prisma/migrations/20260417154612_r3_semantic_registry/migration.sql", + "apps/backend/prisma/migrations/20260417223016_r2_rag_foundation/migration.sql" + ] + }, + { + "item": "Empty DB replay + migration status gate", + "result": "PASS", + "evidence": [ + "pnpm --filter @text2sql/backend run prisma:verify-empty-db passed; 14 migrations applied and status up to date" + ] + }, + { + "item": "Prisma client generation", + "result": "PASS", + "evidence": [ + "pnpm --filter @text2sql/backend run prisma:generate passed" + ] + }, + { + "item": "Data-integrity constraints for denormalized tenant keys", + "result": "FAIL", + "evidence": [ + "Missing composite FK/unique constraints tying datasourceId to referenced document/chunk/index entities" + ] + } + ] + }, + "findings": [ + { + "id": "DMIG-001", + "severity": "HIGH", + "title": "Cross-datasource integrity is not enforced for denormalized datasourceId in RAG tables", + "category": "Data integrity / tenant isolation", + "locations": [ + "apps/backend/prisma/migrations/20260417223016_r2_rag_foundation/migration.sql:22", + "apps/backend/prisma/migrations/20260417223016_r2_rag_foundation/migration.sql:57", + "apps/backend/prisma/migrations/20260417223016_r2_rag_foundation/migration.sql:73", + "apps/backend/prisma/migrations/20260417223016_r2_rag_foundation/migration.sql:148", + "apps/backend/prisma/migrations/20260417223016_r2_rag_foundation/migration.sql:163", + "apps/backend/prisma/migrations/20260417223016_r2_rag_foundation/migration.sql:178", + "apps/backend/prisma/schema.prisma:531", + "apps/backend/prisma/schema.prisma:580", + "apps/backend/prisma/schema.prisma:601" + ], + "issue": "`rag_chunks`, `rag_chunk_index_entries`, and `rag_run_replays` each store `datasourceId` plus foreign keys to parent entities, but constraints validate these keys independently. The schema allows inconsistent tuples (e.g., `chunkId` from datasource A with `datasourceId` B), which can corrupt replay/index state and break datasource isolation guarantees.", + "impact": "Potential cross-datasource data contamination and nondeterministic retrieval/replay behavior under bugs, retries, or manual data repair operations.", + "fix": "Add DB-level consistency constraints: either remove redundant datasourceId columns and derive via parent joins, or enforce composite integrity with `(id, datasourceId)` unique keys on parent tables and composite foreign keys on child tables (e.g., `(documentId, datasourceId)`, `(chunkId, datasourceId)`, `(indexVersionId, datasourceId)`)." + }, + { + "id": "DMIG-002", + "severity": "MEDIUM", + "title": "Single-active version invariants are enforced only in application logic, not by database constraints", + "category": "Concurrency / correctness", + "locations": [ + "apps/backend/prisma/migrations/20260417223016_r2_rag_foundation/migration.sql:115", + "apps/backend/prisma/migrations/20260417154612_r3_semantic_registry/migration.sql:39", + "apps/backend/src/modules/rag/index/rag-index.repository.ts:350", + "apps/backend/src/modules/semantic-registry/semantic-registry.service.ts:623" + ], + "issue": "`rag_index_versions` and `semantic_registry_versions` lack partial unique constraints for active rows. Code reads assume one active row (`take: 1` / first sorted active), so concurrent writers across processes can leave multiple active versions.", + "impact": "Ambiguous active-version selection, inconsistent retrieval behavior, and hard-to-debug production drift during concurrent activation paths.", + "fix": "Add partial unique indexes in migrations and schema: `UNIQUE (datasourceId) WHERE status='active'` for `rag_index_versions`; `UNIQUE (domain) WHERE status='active'` for `semantic_registry_versions`. Keep transactional downgrade/upgrade logic as defense-in-depth, and add race-focused integration coverage." + } + ], + "tooling": { + "diff_source": "git diff d34d48ff786160c3fc30ec09ba1135803eb9e0e1...HEAD", + "lsp_diagnostics": { + "status": "UNAVAILABLE", + "details": "omx_code_intel transport closed in this session; used backend TypeScript compile lint as fallback." + }, + "fallback_checks": [ + { + "command": "pnpm --filter @text2sql/backend run lint", + "result": "PASS" + }, + { + "command": "pnpm --filter @text2sql/backend run test -- test/integration/rag-foundation-schema.spec.ts test/integration/r3-semantic-registry-migration.spec.ts --runInBand", + "result": "PASS" + }, + { + "command": "pnpm --filter @text2sql/backend run prisma:generate", + "result": "PASS" + }, + { + "command": "(apps/backend) set -a; source .env; set +a; pnpm run prisma:verify-empty-db", + "result": "PASS" + } + ], + "pattern_scan": { + "status": "PASS", + "checks": [ + "console.log($$$ARGS)", + "catch ($E) { }", + "apiKey = \"$VALUE\"" + ], + "result": "No matches in migration-scope reviewed files." + } + }, + "post_hook_verification": { + "verified_at": "2026-04-18T08:41:35Z", + "artifact_sha256": "34fa015368f26f2f5c09e850a8b991efc0f46e2ef4d5a7247f9b80789391ca91", + "checks": [ + { + "command": "jq -c . .context/compound-engineering/ce-review/20260418T172025-current-branch/data-migrations.json", + "result": "PASS" + }, + { + "command": "pnpm --filter @text2sql/backend run test -- test/integration/rag-foundation-schema.spec.ts test/integration/r3-semantic-registry-migration.spec.ts --runInBand", + "result": "PASS" + }, + { + "command": "(apps/backend) set -a; source .env; set +a; pnpm run prisma:verify-empty-db", + "result": "PASS" + } + ] + }, + "verdict": "REQUEST_CHANGES" +} diff --git a/.context/compound-engineering/ce-review/20260418T172025-current-branch/deployment-notes.json b/.context/compound-engineering/ce-review/20260418T172025-current-branch/deployment-notes.json new file mode 100644 index 0000000..7f9a7ed --- /dev/null +++ b/.context/compound-engineering/ce-review/20260418T172025-current-branch/deployment-notes.json @@ -0,0 +1,68 @@ +{ + "reviewer": "deployment-verification-agent", + "generatedAt": "2026-04-18T17:20:25+09:00", + "branch": "codex/rag-v1-3-team-exec-tracking", + "artifact": ".context/compound-engineering/ce-review/20260418T172025-current-branch/deployment-notes.json", + "decision": "NO-GO until all blocking checks pass", + "preDeployChecks": [ + { + "check": "Prisma client + empty DB replay", + "command": "pnpm --filter @text2sql/backend run prisma:generate && pnpm --filter @text2sql/backend run prisma:verify-empty-db" + }, + { + "check": "Backend regression slice for glossary, prompt templates, semantic registry, and RAG refresh", + "command": "pnpm --filter @text2sql/backend run test -- test/integration/glossary-release-rollback.spec.ts test/integration/glossary-selected-context-gate.spec.ts test/integration/settings-prompts-api.spec.ts test/integration/semantic-registry.spec.ts test/integration/rag-incremental-refresh.spec.ts test/integration/agent-sql-prompt-template-runtime.spec.ts --runInBand" + }, + { + "check": "Frontend quality gate", + "command": "pnpm --filter @text2sql/frontend run lint && pnpm --filter @text2sql/frontend run test && pnpm --filter @text2sql/frontend run build" + }, + { + "check": "Health and quality endpoints respond", + "command": "curl -fsS http://localhost:3002/health && curl -fsS http://localhost:3002/api/v1/rag/quality/report && curl -fsS http://localhost:3002/api/v1/rag/quality/report/glossary-selected-context" + } + ], + "sqlVerificationQueries": [ + { + "phase": "post-deploy", + "name": "New schema objects exist", + "sql": "SELECT to_regclass('public.glossary_anchors') AS glossary_anchors, to_regclass('public.glossary_terms') AS glossary_terms, to_regclass('public.prompt_templates') AS prompt_templates, to_regclass('public.semantic_registry_versions') AS semantic_registry_versions, to_regclass('public.semantic_registry_terms') AS semantic_registry_terms;", + "expected": "all relations are non-null" + }, + { + "phase": "post-deploy", + "name": "No duplicate active semantic registry versions", + "sql": "SELECT domain, COUNT(*) AS active_versions FROM semantic_registry_versions WHERE status = 'active' GROUP BY domain HAVING COUNT(*) > 1;", + "expected": "0 rows" + }, + { + "phase": "post-deploy", + "name": "No duplicate prompt templates per unique key", + "sql": "SELECT scene, scope, scopeKey, name, COUNT(*) AS n FROM prompt_templates WHERE deletedAt IS NULL GROUP BY scene, scope, scopeKey, name HAVING COUNT(*) > 1;", + "expected": "0 rows" + }, + { + "phase": "post-deploy", + "name": "Glossary anchors and terms stay linked", + "sql": "SELECT COUNT(*) AS orphan_terms FROM glossary_terms t LEFT JOIN glossary_anchors a ON a.id = t.versionAnchorId WHERE t.versionAnchorId IS NOT NULL AND a.id IS NULL;", + "expected": "0" + }, + { + "phase": "post-deploy", + "name": "Replay/index activity looks sane", + "sql": "SELECT datasourceId, status, COUNT(*) AS n FROM rag_index_versions GROUP BY datasourceId, status; SELECT stage, COUNT(*) AS n FROM rag_run_replays GROUP BY stage ORDER BY n DESC;", + "expected": "one active index version per datasource; recent smoke run stages are present" + } + ], + "rollbackCaveats": [ + "This branch adds tables and indexes only; reverting code will not remove rows already written to glossary, prompt template, semantic registry, or replay tables.", + "There is no down-migration in this note. If rollback must remove newly written data, take a DB snapshot or export first.", + "Do not use Prisma reset on a shared environment to unwind this rollout." + ], + "monitoringFocus": [ + "Watch `/api/v1/rag/quality/report` and `/api/v1/rag/quality/report/glossary-selected-context` for `gatePass`, `sampleReady`, and `status` regressions.", + "Watch RAG thresholds: `retrievalRerankP95Ms <= 700`, `cacheEligibleHitRate >= 0.55`, `budgetDegradeRate <= 0.08`, and `datasourceIsolationViolationCount == 0`.", + "Watch SQL-generation traces for `template_not_found` and `template_service_error` fallback spikes.", + "Watch glossary rollback and semantic-release audit events, plus `rag_run_replays` lag or DLQ growth, for stuck incremental refreshes." + ] +} diff --git a/.context/compound-engineering/ce-review/20260418T172025-current-branch/diff.patch b/.context/compound-engineering/ce-review/20260418T172025-current-branch/diff.patch new file mode 100644 index 0000000..1898072 --- /dev/null +++ b/.context/compound-engineering/ce-review/20260418T172025-current-branch/diff.patch @@ -0,0 +1,31832 @@ +diff --git a/.enter/settings.local.json b/.enter/settings.local.json +index db29d8b..3f6afe8 100644 +--- a/.enter/settings.local.json ++++ b/.enter/settings.local.json +@@ -36,14 +36,28 @@ + "Bash(mkdir -p /Users/lienli/Documents/GitHub/text2sql/apps/backend/prisma/migrations/20260412065355_graph_semantic_baseline)", + "Bash(cd /Users/lienli/Documents/GitHub/text2sql/apps/backend \u0026\u0026 npx prisma migrate diff --from-migrations ./prisma/migrations --to-schema-datamodel ./prisma/schema.prisma 2\u003e\u00261)", + "Bash(cd /Users/lienli/Documents/GitHub/text2sql/apps/backend \u0026\u0026 npx prisma migrate diff --from-migrations ./prisma/migrations --to-schema ./prisma/schema.prisma 2\u003e\u00261)", + "Bash(cd /Users/lienli/Documents/GitHub/text2sql/apps/backend \u0026\u0026 npx prisma generate 2\u003e\u00261)", + "Bash(cd /Users/lienli/Documents/GitHub/text2sql/apps/backend \u0026\u0026 npx prisma validate 2\u003e\u00261)", + "Bash(cd /Users/lienli/Documents/GitHub/text2sql/apps/backend \u0026\u0026 npx prisma migrate diff --from-empty --to-schema ./prisma/schema.prisma --script 2\u003e\u00261 | grep -A 20 \"agent_audit_logs\\\\|graph_snapshots\\\\|semantic_edges\\\\|semantic_memories\" | head -80)", + "Bash(cd /Users/lienli/Documents/GitHub/text2sql/apps/backend \u0026\u0026 npx prisma migrate diff --from-empty --to-schema ./prisma/schema.prisma --script 2\u003e\u00261 | grep -E \"agent_audit|graph_snapshot|semantic_edge|semantic_memor\")", + "Bash(cd /Users/lienli/Documents/GitHub/text2sql/apps/backend \u0026\u0026 npx prisma migrate deploy)", + "Bash(ls -la /Users/lienli/Documents/GitHub/text2sql/apps/backend/.env 2\u003e/dev/null; echo \"---\"; ls -la /Users/lienli/Documents/GitHub/text2sql/apps/backend/.env.local 2\u003e/dev/null)", + "Bash(grep -i 'DATABASE_URL\\\\|POSTGRES' /Users/lienli/Documents/GitHub/text2sql/apps/backend/.env)", +- "Bash(cd /Users/lienli/Documents/GitHub/text2sql/apps/backend \u0026\u0026 DATABASE_URL=\"postgresql://admin:admin@localhost:5432/text2sql?schema=public\" npx prisma migrate deploy)" ++ "Bash(cd /Users/lienli/Documents/GitHub/text2sql/apps/backend \u0026\u0026 DATABASE_URL=\"postgresql://admin:admin@localhost:5432/text2sql?schema=public\" npx prisma migrate deploy)", ++ "Bash(file \"/Users/lienli/Documents/GitHub/text2sql/docs/plans/2026-04-17-003-feat-rag-r2-retrieval-agent-gate-plan.md\" \"/Users/lienli/Documents/GitHub/text2sql/docs/plans/2026-04-17-005-feat-rag-r4-delivery-sandbox-r5-memory-plan.md\" \"/Users/lienli/Documents/GitHub/text2sql/docs/plans/2026-04-17-006-feat-rag-r6-scale-optimization-plan.md\")", ++ "Bash(cat -n \"/Users/lienli/Documents/GitHub/text2sql/docs/plans/2026-04-17-005-feat-rag-r4-delivery-sandbox-r5-memory-plan.md\")", ++ "Bash(cat -n \"/Users/lienli/Documents/GitHub/text2sql/docs/plans/2026-04-17-006-feat-rag-r6-scale-optimization-plan.md\")", ++ "Bash(cat -n \"/Users/lienli/Documents/GitHub/text2sql/docs/plans/2026-04-17-003-feat-rag-r2-retrieval-agent-gate-plan.md\")", ++ "Bash(wc -l \"/Users/lienli/Documents/GitHub/text2sql/docs/plans/2026-04-17-003-feat-rag-r2-retrieval-agent-gate-plan.md\" \u0026\u0026 sed -n '80,92p' \"/Users/lienli/Documents/GitHub/text2sql/docs/plans/2026-04-17-003-feat-rag-r2-retrieval-agent-gate-plan.md\")", ++ "Bash(cd /Users/lienli/Documents/GitHub/text2sql \u0026\u0026 python3 -c \"\ncontent = open\\('docs/plans/2026-04-17-005-feat-rag-r4-delivery-sandbox-r5-memory-plan.md', 'r'\\).read\\(\\)\n\n# Fix graph/events reference\ncontent = content.replace\\(\n '- 事件与反馈模式参考:\\\\`apps/backend/src/modules/graph/events/*\\\\`、\\\\`apps/backend/src/modules/graph/feedback/*\\\\`',\n '- 事件与反馈模式参考:\\\\`apps/backend/src/modules/observability/*\\\\`、\\\\`apps/backend/src/modules/data/persistence/audit-log.repository.ts\\\\`(注:\\\\`graph/events\\\\` 和 \\\\`graph/feedback\\\\` 目录需在本阶段新建)'\n\\)\n\n# Fix graph/feedback pattern reference in Unit 3\ncontent = content.replace\\(\n '**Patterns to follow:**\\\\\\\\n- \\\\`apps/backend/src/modules/graph/feedback/*\\\\`',\n '**Patterns to follow:**\\\\\\\\n- \\\\`apps/backend/src/modules/data/persistence/audit-log.repository.ts\\\\`(事件/反馈模式参考)'\n\\)\n\n# Fix graph/events pattern reference in Unit 4\ncontent = content.replace\\(\n '**Patterns to follow:**\\\\\\\\n- \\\\`apps/backend/src/modules/graph/events/*\\\\`',\n '**Patterns to follow:**\\\\\\\\n- \\\\`apps/backend/src/modules/observability/*\\\\`(事件消费/审计模式参考)'\n\\)\n\nopen\\('docs/plans/2026-04-17-005-feat-rag-r4-delivery-sandbox-r5-memory-plan.md', 'w'\\).write\\(content\\)\nprint\\('done'\\)\n\")", ++ "Bash(grep -n \"graph/events\\\\|graph/feedback\\\\|audit-log\\\\|observability\" \"/Users/lienli/Documents/GitHub/text2sql/docs/plans/2026-04-17-005-feat-rag-r4-delivery-sandbox-r5-memory-plan.md\")", ++ "Bash(for f in /Users/lienli/Documents/GitHub/text2sql/docs/plans/2026-04-17-00*.md; do echo \"=== $\\(basename $f\\) ===\"; grep -c \"Boundary\" \"$f\"; done)", ++ "Bash(grep -n \"modules/graph/events\\\\|modules/graph/feedback\\\\|modules/agent/planner/\\\\*\\\\|modules/graph/\\\\*\" /Users/lienli/Documents/GitHub/text2sql/docs/plans/2026-04-17-00*.md | grep -v \"注:\" | grep -v \"graph/events.*和.*graph/feedback.*目录需\" | grep -v \"planner.*需在本阶段新建\")", ++ "Bash(grep -A 20 \"Boundary \u0026 Edge\" \"/Users/lienli/Documents/GitHub/text2sql/docs/plans/2026-04-17-002-feat-rag-r2-foundation-ingestion-index-plan.md\" | head -25)", ++ "Bash(cd /Users/lienli/Documents/GitHub/text2sql \u0026\u0026 git diff HEAD 2\u003e/dev/null | head -20000)", ++ "Bash(cd /Users/lienli/Documents/GitHub/text2sql \u0026\u0026 pnpm --filter @text2sql/backend run test 2\u003e\u00261 | tail -50)", ++ "Bash(cd /Users/lienli/Documents/GitHub/text2sql \u0026\u0026 pnpm --filter @text2sql/frontend run test 2\u003e\u00261 | tail -30)", ++ "Bash(cd /Users/lienli/Documents/GitHub/text2sql \u0026\u0026 pnpm --filter @text2sql/backend run build 2\u003e\u00261 | tail -20)" + ] + } + } +diff --git a/.github/workflows/backend-prisma-quality.yml b/.github/workflows/backend-prisma-quality.yml +index 9d0628a..96e13f4 100644 +--- a/.github/workflows/backend-prisma-quality.yml ++++ b/.github/workflows/backend-prisma-quality.yml +@@ -64,20 +64,47 @@ jobs: + + - name: Backend lint + run: pnpm --filter @text2sql/backend run lint + + - name: Backend build + run: pnpm --filter @text2sql/backend run build + + - name: Backend test + run: pnpm --filter @text2sql/backend run test + ++ - name: R6 scenario package tests ++ env: ++ R6_REPORT_DIR: /tmp/r6-evidence ++ RELEASE_CANDIDATE: r6-${{ github.sha }} ++ run: | ++ pnpm --filter @text2sql/backend run test -- \ ++ test/perf/rag-r6-mixed-load.spec.ts \ ++ test/integration/r6-evidence-pipeline.spec.ts ++ pnpm --filter @text2sql/backend exec jest --config ./test/jest-e2e.json --testRegex "e2e/r6-release-rollback-rehearsal\\.e2e-spec\\.ts$" ++ ++ - name: Collect R6 release evidence (fail-fast) ++ env: ++ R6_REPORT_DIR: /tmp/r6-evidence ++ R6_EVIDENCE_ARCHIVE_DIR: /tmp/r6-evidence-archive ++ RELEASE_CANDIDATE: r6-${{ github.sha }} ++ run: pnpm --filter @text2sql/backend exec node scripts/collect-r6-evidence.mjs ++ ++ - name: Upload R6 release evidence archive ++ if: always() ++ uses: actions/upload-artifact@v4 ++ with: ++ name: r6-release-evidence-${{ github.run_id }} ++ path: | ++ /tmp/r6-evidence ++ /tmp/r6-evidence-archive ++ if-no-files-found: warn ++ + - name: Stage1 gate report + run: pnpm --filter @text2sql/backend exec jest test/e2e/stage1-acceptance.spec.ts --runInBand + env: + STAGE1_GATE_REPORT_PATH: /tmp/stage1-gate-report.json + R1_GATE_MIN_PASS_RATE: "1" + R1_GATE_MAX_HARD_FAILURE_RATE: "0" + R1_GATE_MAX_POLICY_REJECTION_RATE: "0.2" + R1_GATE_MIN_SAMPLES: "10" + + - name: Validate stage1 gate +diff --git a/AGENTS.md b/AGENTS.md +index a14f9be..0316992 100644 +--- a/AGENTS.md ++++ b/AGENTS.md +@@ -4,20 +4,21 @@ + + 目标: + - 统一项目执行路径(先看哪里、先跑什么、门禁是什么)。 + - 明确文档分工,降低跨文档来回切换成本。 + - 固化高风险硬规则(尤其是 Prisma 迁移流程)。 + + 文档分工: + - `AGENTS.md`:入口导航 + 硬边界 + 执行门禁。 + - `README.md`:项目全貌、联调背景、接口与运行说明。 + - `docs/standards/*.md`:专项规范细则(权威来源)。 ++- `docs/solutions/`:历史问题解决与流程经验库(按类别组织,frontmatter 包含 `module`/`tags`/`problem_type`),在相关模块实现或排障时可检索参考。 + + ## 1) Monorepo 边界 + + - `apps/backend`:NestJS API、Agent 工作流、Prisma 数据层。 + - `apps/frontend`:Next.js 前端演示应用。 + - `packages/shared-types`:前后端共享类型定义。 + - `infra`:本地依赖编排(如 `infra/docker-compose.yml`)。 + - `data`:本地数据与运行期数据目录。 + + ## 2) 开发主路径 +@@ -93,36 +94,38 @@ CI 参考: + 来源:`docs/standards/frontend-react-shadcn-spec.md` + + 适用范围: + - `apps/frontend/src/app` + - `apps/frontend/src/components` + + 关键 MUST: + - React 函数组件 + shadcn-ui 体系。 + - Tailwind CSS v4,不回退 v3 模式。 + - 保持核心演示链路可用(创建会话/发送消息/SQL 预览)。 ++- 涉及 RAG 可见化改造时,必须覆盖 runId(sync/stream)一致性、`selected_context` 四态矩阵、terminal 不回退 loading、375px 与键盘可达性(`Enter/Space` + `aria-expanded`)验收。 + + 必跑门禁: + - `pnpm --filter @text2sql/frontend run lint` + - `pnpm --filter @text2sql/frontend run test` + - `pnpm --filter @text2sql/frontend run build` + + ### C. LLM Stream & Tool Calling 迁移规范 + 来源:`docs/standards/llm-stream-tool-migration-spec.md` + + 适用范围: + - 后端 LLM gateway、同步消息接口、SSE 流式接口、工具调用链路。 + + 关键 MUST: + - 同步接口保持 `AgentRunResponse` 合同。 + - 流式事件字段必须完整(`type/runId/sessionId/at/data`)。 + - 工具调用走 allowlist,失败可追踪。 ++- 若接入提示词模板运行时,必须保证 `run.trace.promptTemplate` 与 `delivery.evidence.promptTemplate` 字段语义一致,且旧 run 缺字段可兼容读取。 + + 必跑检查: + - `GET http://localhost:3002/health` 中 stream/tool-calling 相关字段应符合预期。 + + 说明: + - 以上仅为执行摘要,细节规则以 standards 原文为准。 + + ## 6) Prisma 结构变更铁律(强制) + + 涉及表/列/索引/约束/外键变更时,必须遵循: +diff --git a/README.md b/README.md +index 02ebe1d..452be47 100644 +--- a/README.md ++++ b/README.md +@@ -120,20 +120,21 @@ pnpm dev + - 移动端保留“会话”与“结果详情”入口,其中“结果详情”用于快速展开最新 SQL 详情块。 + + ## 联调检查清单(真实 LLM) + - 网关入口可访问:`http://localhost:3000/data-sources`。 + - 快速网关 smoke 可通过:`node tests/smoke/nginx-dev-gateway-smoke.mjs`。 + - 后端健康检查 `GET http://localhost:3002/health` 中 `llm.configured` 与 `llm.baseUrlConfigured` 为 `true`。 + - `GET http://localhost:3002/health` 中 `dependencies.llm.streamingEnabled` 与 `dependencies.llm.toolCallingEnabled` 为 `true`。 + - 如开启 LangSmith,`GET http://localhost:3002/health` 中 `dependencies.langsmith.ready` 为 `true`。 + - `GET http://localhost:3002/health` 中 `dependencies.sessions.sync` 可查看会话同步状态统计(healthy/pending/degraded)。 + - `GET http://localhost:3002/health` 中 `dependencies.gateMetrics.acceptance` 可查看 R1 门禁指标快照(sampleReady/gatePass)。 ++- `GET http://localhost:3002/api/v1/rag/quality/report` 中 `glossarySelectedContext` 可查看术语 selected_context 门禁(sampleVersion/relativeLift/status)。 + - 前端能经 `http://localhost:3000` 成功创建会话并发送消息,无跨域报错。 + - 前端从 `/data-sources` 选择任一可用数据源后,可自动创建绑定会话并跳转 `/chat`。 + - `GET /api/v1/sessions?datasource=` 返回的会话均属于指定数据源。 + - `POST /api/v1/sessions/:sessionId/messages` 响应中包含 `run.sql` 与 `run.explanation`。 + - 当 LLM 配置缺失或不可用时,接口返回可读错误(不会回退到规则 SQL)。 + + ## LangSmith 覆盖率校验(可选) + ```bash + ts-node apps/backend/scripts/langsmith-coverage-check.ts \ + --total 120 \ +@@ -147,20 +148,31 @@ ts-node apps/backend/scripts/langsmith-coverage-check.ts \ + + ## 核心 API + - `POST /api/v1/sessions`(`datasource` 必填) + - `GET /api/v1/sessions`(支持 `status`、`datasource` 与 `view=current|readonly-history|all`) + - `PATCH /api/v1/sessions/:sessionId`(支持 `title` 与 `debugEnabled` 局部更新) + - `DELETE /api/v1/sessions/:sessionId`(软删除,默认列表隐藏) + - `POST /api/v1/sessions/:sessionId/messages` + - `POST /api/v1/sessions/:sessionId/messages/stream`(SSE 流式) + - `GET /api/v1/sessions/:sessionId/messages`(返回 `session + messages + latestRun`) + - `GET /api/v1/runs/:runId` ++- `GET /api/v1/glossary/terms` ++- `POST /api/v1/glossary/terms`(管理员) ++- `PATCH /api/v1/glossary/terms/:termId`(管理员) ++- `POST /api/v1/glossary/terms/:termId/toggle`(管理员) ++- `GET /api/v1/glossary/anchors` ++- `POST /api/v1/glossary/anchors`(管理员) ++- `POST /api/v1/glossary/anchors/rollback`(管理员) ++- `GET /api/v1/settings/prompts` ++- `POST /api/v1/settings/prompts`(管理员) ++- `PATCH /api/v1/settings/prompts/:templateId`(管理员) ++- `DELETE /api/v1/settings/prompts/:templateId`(管理员,软删除) + - `GET /api/v1/datasources` + - `POST /api/v1/datasources` + - `POST /api/v1/datasources/upload` + - `GET /api/v1/system/users` + - `POST /api/v1/system/users` + - `PATCH /api/v1/system/users/:userId` + - `PATCH /api/v1/system/users/:userId/status` + - `POST /api/v1/system/users/:userId/reset-password` + - `DELETE /api/v1/system/users/:userId` + - `POST /api/v1/system/users/batch-delete` +@@ -194,37 +206,47 @@ ts-node apps/backend/scripts/langsmith-coverage-check.ts \ + - 开发态统一入口为 `http://localhost:3000`(网关转发到内部 `3001/3002`);以下 API 路径与字段契约不变。 + - 流式主路径:`POST /api/v1/sessions/:sessionId/messages/stream`。 + - 同步消息接口 `POST /api/v1/sessions/:sessionId/messages` 返回 `AgentRunResponse`: + - `kind`:固定为 `agent-run` + - `outcome`:`clarification | executionResult | rejected | failed` + - `run`:完整运行结果(含 `trace` 与可选 `llmRaw`) + - `agent`:聚合元信息(provider/model、是否有 SQL、是否有工具调用、是否有错误) + - SSE 事件类型:`start`、`text-delta`、`tool-call`、`tool-result`、`tool-error`、`state`、`finish`、`error`。 + - SSE 事件必填字段:`type`、`runId`、`sessionId`、`at`、`data`;其中 `data` 为结构化对象,不再混用字符串载荷。 + - 当前 Tool Calling 基础能力默认启用,首个工具为 `runReadOnlySql`(只读 SQL 执行,含输入校验与安全守卫)。 ++- SQL 运行时提示词模板命中证据通过 `run.trace.promptTemplate` 与 `run.delivery.evidence.promptTemplate` 暴露(字段:`templateId/scene/scope/version/fallbackReason`)。 + + ## 测试 + ```bash + pnpm test + pnpm test:backend + pnpm test:frontend + ``` + + ## 后端发布完成门禁(Prisma V7) + - 依赖与生成:`pnpm --filter @text2sql/backend run prisma:generate` + - 质量门禁:`pnpm --filter @text2sql/backend run lint && pnpm --filter @text2sql/backend run build && pnpm --filter @text2sql/backend run test` + - R1 离线 Gate:`pnpm --filter @text2sql/backend exec jest test/e2e/stage1-acceptance.spec.ts --runInBand` ++- 术语 selected_context 门禁:`pnpm --filter @text2sql/backend test -- glossary-selected-context-gate.spec.ts --runInBand` + - 迁移回放:`pnpm --filter @text2sql/backend run prisma:verify-empty-db` + - 启动 smoke:至少验证 `GET http://localhost:3002/health`;关键接口建议覆盖: + - 网关快速检查:`node tests/smoke/nginx-dev-gateway-smoke.mjs` + - 后端健康检查:`GET http://localhost:3002/health` + - `POST /api/v1/sessions` + - `POST /api/v1/sessions/:sessionId/messages` + - `GET /api/v1/settings/models`(管理员上下文) + - CI 可参考:`.github/workflows/backend-prisma-quality.yml` + ++## 前端术语联动(Wave A/B)验收边界 ++- Wave A:术语写入后可影响 `selected_context` 命中,链路异常时前端可见降级但不阻断主回答。 ++- Wave B:在保持“改动即生效”前提下补齐锚点创建/回滚治理能力,并在 `/settings` 提供最小可见入口。 ++- 发布证据最小集: ++ - `pnpm -C apps/backend test -- glossary-release-rollback.spec.ts --runInBand` ++ - `pnpm -C apps/backend test -- glossary-selected-context-gate.spec.ts --runInBand` ++ - `pnpm -C apps/frontend exec vitest run tests/unit/settings-page-governance-visibility.spec.tsx` ++ + ## 前端质量门禁 + ```bash + pnpm --filter @text2sql/frontend lint + pnpm --filter @text2sql/frontend test + pnpm --filter @text2sql/frontend build + ``` +diff --git a/apps/backend/prisma/migrations/20260417154612_r3_semantic_registry/migration.sql b/apps/backend/prisma/migrations/20260417154612_r3_semantic_registry/migration.sql +new file mode 100644 +index 0000000..3baa8ad +--- /dev/null ++++ b/apps/backend/prisma/migrations/20260417154612_r3_semantic_registry/migration.sql +@@ -0,0 +1,66 @@ ++-- AlterTable ++ALTER TABLE "graph_snapshots" ALTER COLUMN "status" SET DEFAULT 'active'; ++ ++-- CreateTable ++CREATE TABLE "semantic_registry_versions" ( ++ "id" TEXT NOT NULL, ++ "domain" TEXT NOT NULL, ++ "semanticVersion" INTEGER NOT NULL, ++ "status" TEXT NOT NULL DEFAULT 'active', ++ "releaseSummary" TEXT NOT NULL, ++ "auditSummary" TEXT NOT NULL, ++ "publishedByRunId" TEXT, ++ "activatedByRunId" TEXT, ++ "activatedAt" TIMESTAMP(3), ++ "riskTags" TEXT[], ++ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, ++ "updatedAt" TIMESTAMP(3) NOT NULL, ++ ++ CONSTRAINT "semantic_registry_versions_pkey" PRIMARY KEY ("id") ++); ++ ++-- CreateTable ++CREATE TABLE "semantic_registry_terms" ( ++ "id" TEXT NOT NULL, ++ "versionId" TEXT NOT NULL, ++ "domain" TEXT NOT NULL, ++ "term" TEXT NOT NULL, ++ "canonicalKey" TEXT NOT NULL, ++ "definition" TEXT NOT NULL, ++ "binding" TEXT NOT NULL, ++ "metadata" TEXT, ++ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, ++ "updatedAt" TIMESTAMP(3) NOT NULL, ++ ++ CONSTRAINT "semantic_registry_terms_pkey" PRIMARY KEY ("id") ++); ++ ++-- CreateIndex ++CREATE INDEX "semantic_registry_versions_domain_status_semanticVersion_idx" ON "semantic_registry_versions"("domain", "status", "semanticVersion"); ++ ++-- CreateIndex ++CREATE INDEX "semantic_registry_versions_publishedByRunId_idx" ON "semantic_registry_versions"("publishedByRunId"); ++ ++-- CreateIndex ++CREATE INDEX "semantic_registry_versions_activatedByRunId_idx" ON "semantic_registry_versions"("activatedByRunId"); ++ ++-- CreateIndex ++CREATE UNIQUE INDEX "semantic_registry_versions_domain_semanticVersion_key" ON "semantic_registry_versions"("domain", "semanticVersion"); ++ ++-- CreateIndex ++CREATE INDEX "semantic_registry_terms_domain_term_versionId_idx" ON "semantic_registry_terms"("domain", "term", "versionId"); ++ ++-- CreateIndex ++CREATE INDEX "semantic_registry_terms_canonicalKey_idx" ON "semantic_registry_terms"("canonicalKey"); ++ ++-- CreateIndex ++CREATE UNIQUE INDEX "semantic_registry_terms_versionId_term_key" ON "semantic_registry_terms"("versionId", "term"); ++ ++-- AddForeignKey ++ALTER TABLE "semantic_registry_versions" ADD CONSTRAINT "semantic_registry_versions_publishedByRunId_fkey" FOREIGN KEY ("publishedByRunId") REFERENCES "sql_runs"("runId") ON DELETE SET NULL ON UPDATE CASCADE; ++ ++-- AddForeignKey ++ALTER TABLE "semantic_registry_versions" ADD CONSTRAINT "semantic_registry_versions_activatedByRunId_fkey" FOREIGN KEY ("activatedByRunId") REFERENCES "sql_runs"("runId") ON DELETE SET NULL ON UPDATE CASCADE; ++ ++-- AddForeignKey ++ALTER TABLE "semantic_registry_terms" ADD CONSTRAINT "semantic_registry_terms_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "semantic_registry_versions"("id") ON DELETE CASCADE ON UPDATE CASCADE; +diff --git a/apps/backend/prisma/migrations/20260417223016_r2_rag_foundation/migration.sql b/apps/backend/prisma/migrations/20260417223016_r2_rag_foundation/migration.sql +new file mode 100644 +index 0000000..27cd6d2 +--- /dev/null ++++ b/apps/backend/prisma/migrations/20260417223016_r2_rag_foundation/migration.sql +@@ -0,0 +1,185 @@ ++-- CreateTable ++CREATE TABLE "rag_documents" ( ++ "id" TEXT NOT NULL, ++ "datasourceId" TEXT NOT NULL, ++ "domain" TEXT NOT NULL, ++ "sourceType" TEXT NOT NULL, ++ "sourceRef" TEXT, ++ "sourceVersion" TEXT NOT NULL, ++ "contentChecksum" TEXT NOT NULL, ++ "title" TEXT, ++ "content" TEXT NOT NULL, ++ "tableNames" TEXT[], ++ "columnNames" TEXT[], ++ "metadata" TEXT, ++ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, ++ "updatedAt" TIMESTAMP(3) NOT NULL, ++ ++ CONSTRAINT "rag_documents_pkey" PRIMARY KEY ("id") ++); ++ ++-- CreateTable ++CREATE TABLE "rag_chunks" ( ++ "id" TEXT NOT NULL, ++ "documentId" TEXT NOT NULL, ++ "datasourceId" TEXT NOT NULL, ++ "domain" TEXT NOT NULL, ++ "chunkProfile" TEXT NOT NULL, ++ "chunkOrder" INTEGER NOT NULL, ++ "content" TEXT NOT NULL, ++ "contentChecksum" TEXT NOT NULL, ++ "tableNames" TEXT[], ++ "columnNames" TEXT[], ++ "metadata" TEXT, ++ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, ++ "updatedAt" TIMESTAMP(3) NOT NULL, ++ ++ CONSTRAINT "rag_chunks_pkey" PRIMARY KEY ("id") ++); ++ ++-- CreateTable ++CREATE TABLE "rag_index_versions" ( ++ "id" TEXT NOT NULL, ++ "datasourceId" TEXT NOT NULL, ++ "status" TEXT NOT NULL, ++ "buildReason" TEXT, ++ "sourceVersion" TEXT NOT NULL, ++ "createdByRunId" TEXT, ++ "activatedByRunId" TEXT, ++ "activatedAt" TIMESTAMP(3), ++ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, ++ "updatedAt" TIMESTAMP(3) NOT NULL, ++ ++ CONSTRAINT "rag_index_versions_pkey" PRIMARY KEY ("id") ++); ++ ++-- CreateTable ++CREATE TABLE "rag_chunk_index_entries" ( ++ "id" TEXT NOT NULL, ++ "indexVersionId" TEXT NOT NULL, ++ "chunkId" TEXT NOT NULL, ++ "datasourceId" TEXT NOT NULL, ++ "domain" TEXT NOT NULL, ++ "lexicalContent" TEXT NOT NULL, ++ "denseVector" TEXT, ++ "metadata" TEXT, ++ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, ++ "updatedAt" TIMESTAMP(3) NOT NULL, ++ ++ CONSTRAINT "rag_chunk_index_entries_pkey" PRIMARY KEY ("id") ++); ++ ++-- CreateTable ++CREATE TABLE "rag_run_replays" ( ++ "runId" TEXT NOT NULL, ++ "replayKey" TEXT NOT NULL, ++ "datasourceId" TEXT NOT NULL, ++ "stage" TEXT NOT NULL, ++ "indexVersionId" TEXT, ++ "documentId" TEXT, ++ "chunkId" TEXT, ++ "payload" TEXT NOT NULL, ++ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, ++ ++ CONSTRAINT "rag_run_replays_pkey" PRIMARY KEY ("runId","replayKey") ++); ++ ++-- CreateIndex ++CREATE INDEX "rag_documents_datasourceId_domain_idx" ON "rag_documents"("datasourceId", "domain"); ++ ++-- CreateIndex ++CREATE INDEX "rag_documents_sourceType_sourceVersion_idx" ON "rag_documents"("sourceType", "sourceVersion"); ++ ++-- CreateIndex ++CREATE INDEX "rag_documents_tableNames_idx" ON "rag_documents"("tableNames"); ++ ++-- CreateIndex ++CREATE INDEX "rag_documents_columnNames_idx" ON "rag_documents"("columnNames"); ++ ++-- CreateIndex ++CREATE UNIQUE INDEX "rag_documents_datasourceId_sourceVersion_contentChecksum_key" ON "rag_documents"("datasourceId", "sourceVersion", "contentChecksum"); ++ ++-- CreateIndex ++CREATE INDEX "rag_chunks_datasourceId_domain_chunkProfile_idx" ON "rag_chunks"("datasourceId", "domain", "chunkProfile"); ++ ++-- CreateIndex ++CREATE INDEX "rag_chunks_tableNames_idx" ON "rag_chunks"("tableNames"); ++ ++-- CreateIndex ++CREATE INDEX "rag_chunks_columnNames_idx" ON "rag_chunks"("columnNames"); ++ ++-- CreateIndex ++CREATE UNIQUE INDEX "rag_chunks_documentId_chunkProfile_chunkOrder_key" ON "rag_chunks"("documentId", "chunkProfile", "chunkOrder"); ++ ++-- CreateIndex ++CREATE INDEX "rag_index_versions_datasourceId_status_createdAt_idx" ON "rag_index_versions"("datasourceId", "status", "createdAt"); ++ ++-- CreateIndex ++CREATE INDEX "rag_index_versions_createdByRunId_idx" ON "rag_index_versions"("createdByRunId"); ++ ++-- CreateIndex ++CREATE INDEX "rag_index_versions_activatedByRunId_idx" ON "rag_index_versions"("activatedByRunId"); ++ ++-- CreateIndex ++CREATE INDEX "rag_chunk_index_entries_datasourceId_domain_idx" ON "rag_chunk_index_entries"("datasourceId", "domain"); ++ ++-- CreateIndex ++CREATE INDEX "rag_chunk_index_entries_chunkId_idx" ON "rag_chunk_index_entries"("chunkId"); ++ ++-- CreateIndex ++CREATE UNIQUE INDEX "rag_chunk_index_entries_indexVersionId_chunkId_key" ON "rag_chunk_index_entries"("indexVersionId", "chunkId"); ++ ++-- CreateIndex ++CREATE INDEX "rag_run_replays_datasourceId_stage_createdAt_idx" ON "rag_run_replays"("datasourceId", "stage", "createdAt"); ++ ++-- CreateIndex ++CREATE INDEX "rag_run_replays_indexVersionId_idx" ON "rag_run_replays"("indexVersionId"); ++ ++-- CreateIndex ++CREATE INDEX "rag_run_replays_documentId_idx" ON "rag_run_replays"("documentId"); ++ ++-- CreateIndex ++CREATE INDEX "rag_run_replays_chunkId_idx" ON "rag_run_replays"("chunkId"); ++ ++-- AddForeignKey ++ALTER TABLE "rag_documents" ADD CONSTRAINT "rag_documents_datasourceId_fkey" FOREIGN KEY ("datasourceId") REFERENCES "datasources"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ++ ++-- AddForeignKey ++ALTER TABLE "rag_chunks" ADD CONSTRAINT "rag_chunks_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "rag_documents"("id") ON DELETE CASCADE ON UPDATE CASCADE; ++ ++-- AddForeignKey ++ALTER TABLE "rag_chunks" ADD CONSTRAINT "rag_chunks_datasourceId_fkey" FOREIGN KEY ("datasourceId") REFERENCES "datasources"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ++ ++-- AddForeignKey ++ALTER TABLE "rag_index_versions" ADD CONSTRAINT "rag_index_versions_datasourceId_fkey" FOREIGN KEY ("datasourceId") REFERENCES "datasources"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ++ ++-- AddForeignKey ++ALTER TABLE "rag_index_versions" ADD CONSTRAINT "rag_index_versions_createdByRunId_fkey" FOREIGN KEY ("createdByRunId") REFERENCES "sql_runs"("runId") ON DELETE SET NULL ON UPDATE CASCADE; ++ ++-- AddForeignKey ++ALTER TABLE "rag_index_versions" ADD CONSTRAINT "rag_index_versions_activatedByRunId_fkey" FOREIGN KEY ("activatedByRunId") REFERENCES "sql_runs"("runId") ON DELETE SET NULL ON UPDATE CASCADE; ++ ++-- AddForeignKey ++ALTER TABLE "rag_chunk_index_entries" ADD CONSTRAINT "rag_chunk_index_entries_indexVersionId_fkey" FOREIGN KEY ("indexVersionId") REFERENCES "rag_index_versions"("id") ON DELETE CASCADE ON UPDATE CASCADE; ++ ++-- AddForeignKey ++ALTER TABLE "rag_chunk_index_entries" ADD CONSTRAINT "rag_chunk_index_entries_chunkId_fkey" FOREIGN KEY ("chunkId") REFERENCES "rag_chunks"("id") ON DELETE CASCADE ON UPDATE CASCADE; ++ ++-- AddForeignKey ++ALTER TABLE "rag_chunk_index_entries" ADD CONSTRAINT "rag_chunk_index_entries_datasourceId_fkey" FOREIGN KEY ("datasourceId") REFERENCES "datasources"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ++ ++-- AddForeignKey ++ALTER TABLE "rag_run_replays" ADD CONSTRAINT "rag_run_replays_runId_fkey" FOREIGN KEY ("runId") REFERENCES "sql_runs"("runId") ON DELETE CASCADE ON UPDATE CASCADE; ++ ++-- AddForeignKey ++ALTER TABLE "rag_run_replays" ADD CONSTRAINT "rag_run_replays_datasourceId_fkey" FOREIGN KEY ("datasourceId") REFERENCES "datasources"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ++ ++-- AddForeignKey ++ALTER TABLE "rag_run_replays" ADD CONSTRAINT "rag_run_replays_indexVersionId_fkey" FOREIGN KEY ("indexVersionId") REFERENCES "rag_index_versions"("id") ON DELETE SET NULL ON UPDATE CASCADE; ++ ++-- AddForeignKey ++ALTER TABLE "rag_run_replays" ADD CONSTRAINT "rag_run_replays_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "rag_documents"("id") ON DELETE SET NULL ON UPDATE CASCADE; ++ ++-- AddForeignKey ++ALTER TABLE "rag_run_replays" ADD CONSTRAINT "rag_run_replays_chunkId_fkey" FOREIGN KEY ("chunkId") REFERENCES "rag_chunks"("id") ON DELETE SET NULL ON UPDATE CASCADE; ++ +diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma +index bc7db12..2f6931f 100644 +--- a/apps/backend/prisma/schema.prisma ++++ b/apps/backend/prisma/schema.prisma +@@ -48,20 +48,27 @@ model Datasource { + config String? + fileMeta String? + unavailableAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + sessions Session[] + workspaceBindings WorkspaceDatasourceBinding[] + workspaceTablePermissionSets WorkspaceDatasourceTablePermissionSet[] + workspaceTablePermissions WorkspaceDatasourceTablePermission[] ++ ragDocuments RagDocument[] @relation("datasource_rag_documents") ++ ragChunks RagChunk[] @relation("datasource_rag_chunks") ++ ragIndexVersions RagIndexVersion[] @relation("datasource_rag_index_versions") ++ ragChunkIndexes RagChunkIndexEntry[] @relation("datasource_rag_chunk_index_entries") ++ ragRunReplays RagRunReplay[] @relation("datasource_rag_run_replays") ++ glossaryTerms GlossaryTerm[] @relation("datasource_glossary_terms") ++ glossaryAnchors GlossaryAnchor[] @relation("datasource_glossary_anchors") + + @@index([deletedAt, status]) + @@index([type, status]) + @@map("datasources") + } + + model Message { + id String @id + sessionId String + role String +@@ -89,20 +96,26 @@ model SqlRun { + error String? + clarification String? + trace String + llmRaw String? + createdAt DateTime @default(now()) + session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) + auditLogs AgentAuditLog[] + graphSnapshots GraphSnapshot[] + semanticEdges SemanticEdge[] + semanticMemories SemanticMemory[] ++ semanticRegistryVersionsPublished SemanticRegistryVersion[] @relation("semantic_registry_published_by_run") ++ semanticRegistryVersionsActivated SemanticRegistryVersion[] @relation("semantic_registry_activated_by_run") ++ ragIndexVersionsBuilt RagIndexVersion[] @relation("rag_index_created_by_run") ++ ragIndexVersionsActivated RagIndexVersion[] @relation("rag_index_activated_by_run") ++ ragRunReplays RagRunReplay[] @relation("rag_run_replays") ++ glossaryAnchorsCreated GlossaryAnchor[] @relation("glossary_anchor_created_by_run") + + @@index([sessionId, createdAt]) + @@map("sql_runs") + } + + model GraphSnapshot { + id String @id + datasource String + graphType String + graphVersion Int @default(1) +@@ -150,20 +163,165 @@ model SemanticEdge { + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + sourceRun SqlRun? @relation(fields: [sourceRunId], references: [runId], onDelete: SetNull) + + @@unique([datasource, subjectKey, predicate, objectKey]) + @@index([datasource, predicate]) + @@index([sourceRunId]) + @@map("semantic_edges") + } + ++model SemanticRegistryVersion { ++ id String @id ++ domain String ++ semanticVersion Int ++ status String @default("active") ++ releaseSummary String ++ auditSummary String ++ publishedByRunId String? ++ activatedByRunId String? ++ activatedAt DateTime? ++ riskTags String[] ++ createdAt DateTime @default(now()) ++ updatedAt DateTime @updatedAt ++ publishedByRun SqlRun? @relation("semantic_registry_published_by_run", fields: [publishedByRunId], references: [runId], onDelete: SetNull) ++ activatedByRun SqlRun? @relation("semantic_registry_activated_by_run", fields: [activatedByRunId], references: [runId], onDelete: SetNull) ++ terms SemanticRegistryTerm[] ++ ++ @@unique([domain, semanticVersion]) ++ @@index([domain, status, semanticVersion]) ++ @@index([publishedByRunId]) ++ @@index([activatedByRunId]) ++ @@map("semantic_registry_versions") ++} ++ ++model SemanticRegistryTerm { ++ id String @id ++ versionId String ++ domain String ++ term String ++ canonicalKey String ++ definition String ++ binding String ++ metadata String? ++ createdAt DateTime @default(now()) ++ updatedAt DateTime @updatedAt ++ version SemanticRegistryVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) ++ ++ @@unique([versionId, term]) ++ @@index([domain, term, versionId]) ++ @@index([canonicalKey]) ++ @@map("semantic_registry_terms") ++} ++ ++enum PromptTemplateScene { ++ sql ++ analysis ++} ++ ++enum PromptTemplateScope { ++ global ++ workspace ++ datasource ++} ++ ++enum PromptTemplateStatus { ++ draft ++ active ++ archived ++} ++ ++model PromptTemplate { ++ id String @id ++ name String ++ scene PromptTemplateScene ++ scope PromptTemplateScope ++ scopeKey String ++ content String ++ status PromptTemplateStatus @default(active) ++ version Int @default(1) ++ createdByUserId String? ++ updatedByUserId String? ++ deletedAt DateTime? ++ createdAt DateTime @default(now()) ++ updatedAt DateTime @updatedAt ++ ++ @@unique([scene, scope, scopeKey, name]) ++ @@index([scene, status, updatedAt]) ++ @@index([scope, scopeKey, status, updatedAt]) ++ @@index([deletedAt, updatedAt]) ++ @@map("prompt_templates") ++} ++ ++model GlossaryAnchor { ++ id String @id ++ scope String @default("global") ++ scopeKey String ++ datasourceId String? ++ version Int ++ anchorType String @default("release") ++ status String @default("active") ++ summary String? ++ rollbackFromAnchorId String? ++ rollbackReason String? ++ metadata String? ++ createdByUserId String? ++ createdByRunId String? ++ createdAt DateTime @default(now()) ++ updatedAt DateTime @updatedAt ++ datasource Datasource? @relation("datasource_glossary_anchors", fields: [datasourceId], references: [id], onDelete: SetNull) ++ createdByRun SqlRun? @relation("glossary_anchor_created_by_run", fields: [createdByRunId], references: [runId], onDelete: SetNull) ++ rollbackFromAnchor GlossaryAnchor? @relation("glossary_anchor_rollbacks", fields: [rollbackFromAnchorId], references: [id], onDelete: SetNull) ++ rollbackTargets GlossaryAnchor[] @relation("glossary_anchor_rollbacks") ++ versionTerms GlossaryTerm[] @relation("glossary_terms_version_anchor") ++ rollbackTerms GlossaryTerm[] @relation("glossary_terms_rollback_anchor") ++ ++ @@unique([scopeKey, version]) ++ @@index([scope, scopeKey, status, createdAt]) ++ @@index([datasourceId, createdAt]) ++ @@index([createdByRunId]) ++ @@index([rollbackFromAnchorId]) ++ @@map("glossary_anchors") ++} ++ ++model GlossaryTerm { ++ id String @id ++ term String ++ normalizedTerm String ++ definition String ++ synonyms String[] ++ scope String @default("global") ++ scopeKey String ++ datasourceId String? ++ priority Int @default(50) ++ conflictResolution String @default("priority_then_updated_at") ++ status String @default("active") ++ version Int @default(1) ++ versionAnchorId String? ++ rollbackAnchorId String? ++ metadata String? ++ createdByUserId String? ++ updatedByUserId String? ++ createdAt DateTime @default(now()) ++ updatedAt DateTime @updatedAt ++ datasource Datasource? @relation("datasource_glossary_terms", fields: [datasourceId], references: [id], onDelete: SetNull) ++ versionAnchor GlossaryAnchor? @relation("glossary_terms_version_anchor", fields: [versionAnchorId], references: [id], onDelete: SetNull) ++ rollbackAnchor GlossaryAnchor? @relation("glossary_terms_rollback_anchor", fields: [rollbackAnchorId], references: [id], onDelete: SetNull) ++ ++ @@unique([version, scopeKey, normalizedTerm]) ++ @@index([scope, scopeKey, status, priority]) ++ @@index([datasourceId, updatedAt]) ++ @@index([versionAnchorId]) ++ @@index([rollbackAnchorId]) ++ @@map("glossary_terms") ++} ++ + model AgentAuditLog { + id String @id + runId String? + sessionId String? + phase String + severity String @default("info") + eventType String + eventCode String? + message String + metadata String? +@@ -336,10 +494,130 @@ model WorkspaceDatasourceTablePermission { + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + datasource Datasource @relation(fields: [datasourceId], references: [id], onDelete: Cascade) + + @@unique([workspaceId, datasourceId, tableName]) + @@index([workspaceId, datasourceId, createdAt]) + @@map("workspace_datasource_table_permissions") + } + ++model RagDocument { ++ id String @id ++ datasourceId String ++ domain String ++ sourceType String ++ sourceRef String? ++ sourceVersion String ++ contentChecksum String ++ title String? ++ content String ++ tableNames String[] ++ columnNames String[] ++ metadata String? ++ createdAt DateTime @default(now()) ++ updatedAt DateTime @updatedAt ++ datasource Datasource @relation("datasource_rag_documents", fields: [datasourceId], references: [id], onDelete: Restrict) ++ chunks RagChunk[] ++ replayLogs RagRunReplay[] ++ ++ @@unique([datasourceId, sourceVersion, contentChecksum]) ++ @@index([datasourceId, domain]) ++ @@index([sourceType, sourceVersion]) ++ @@index([tableNames]) ++ @@index([columnNames]) ++ @@map("rag_documents") ++} ++ ++model RagChunk { ++ id String @id ++ documentId String ++ datasourceId String ++ domain String ++ chunkProfile String ++ chunkOrder Int ++ content String ++ contentChecksum String ++ tableNames String[] ++ columnNames String[] ++ metadata String? ++ createdAt DateTime @default(now()) ++ updatedAt DateTime @updatedAt ++ document RagDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) ++ datasource Datasource @relation("datasource_rag_chunks", fields: [datasourceId], references: [id], onDelete: Restrict) ++ indexEntries RagChunkIndexEntry[] ++ replayLogs RagRunReplay[] ++ ++ @@unique([documentId, chunkProfile, chunkOrder]) ++ @@index([datasourceId, domain, chunkProfile]) ++ @@index([tableNames]) ++ @@index([columnNames]) ++ @@map("rag_chunks") ++} ++ ++model RagIndexVersion { ++ id String @id ++ datasourceId String ++ status String ++ buildReason String? ++ sourceVersion String ++ createdByRunId String? ++ activatedByRunId String? ++ activatedAt DateTime? ++ createdAt DateTime @default(now()) ++ updatedAt DateTime @updatedAt ++ datasource Datasource @relation("datasource_rag_index_versions", fields: [datasourceId], references: [id], onDelete: Restrict) ++ createdByRun SqlRun? @relation("rag_index_created_by_run", fields: [createdByRunId], references: [runId], onDelete: SetNull) ++ activatedByRun SqlRun? @relation("rag_index_activated_by_run", fields: [activatedByRunId], references: [runId], onDelete: SetNull) ++ chunkEntries RagChunkIndexEntry[] ++ replayLogs RagRunReplay[] ++ ++ @@index([datasourceId, status, createdAt]) ++ @@index([createdByRunId]) ++ @@index([activatedByRunId]) ++ @@map("rag_index_versions") ++} ++ ++model RagChunkIndexEntry { ++ id String @id ++ indexVersionId String ++ chunkId String ++ datasourceId String ++ domain String ++ lexicalContent String ++ denseVector String? ++ metadata String? ++ createdAt DateTime @default(now()) ++ updatedAt DateTime @updatedAt ++ indexVersion RagIndexVersion @relation(fields: [indexVersionId], references: [id], onDelete: Cascade) ++ chunk RagChunk @relation(fields: [chunkId], references: [id], onDelete: Cascade) ++ datasource Datasource @relation("datasource_rag_chunk_index_entries", fields: [datasourceId], references: [id], onDelete: Restrict) ++ ++ @@unique([indexVersionId, chunkId]) ++ @@index([datasourceId, domain]) ++ @@index([chunkId]) ++ @@map("rag_chunk_index_entries") ++} ++ ++model RagRunReplay { ++ runId String ++ replayKey String ++ datasourceId String ++ stage String ++ indexVersionId String? ++ documentId String? ++ chunkId String? ++ payload String ++ createdAt DateTime @default(now()) ++ run SqlRun @relation("rag_run_replays", fields: [runId], references: [runId], onDelete: Cascade) ++ datasource Datasource @relation("datasource_rag_run_replays", fields: [datasourceId], references: [id], onDelete: Restrict) ++ indexVersion RagIndexVersion? @relation(fields: [indexVersionId], references: [id], onDelete: SetNull) ++ document RagDocument? @relation(fields: [documentId], references: [id], onDelete: SetNull) ++ chunk RagChunk? @relation(fields: [chunkId], references: [id], onDelete: SetNull) ++ ++ @@id([runId, replayKey]) ++ @@index([datasourceId, stage, createdAt]) ++ @@index([indexVersionId]) ++ @@index([documentId]) ++ @@index([chunkId]) ++ @@map("rag_run_replays") ++} +diff --git a/apps/backend/scripts/collect-r6-evidence.mjs b/apps/backend/scripts/collect-r6-evidence.mjs +new file mode 100644 +index 0000000..28e1e3e +--- /dev/null ++++ b/apps/backend/scripts/collect-r6-evidence.mjs +@@ -0,0 +1,272 @@ ++#!/usr/bin/env node ++ ++import { createHash } from "node:crypto"; ++import { constants as fsConstants } from "node:fs"; ++import { ++ access, ++ copyFile, ++ mkdir, ++ readFile, ++ stat, ++ writeFile ++} from "node:fs/promises"; ++import { dirname, join, resolve } from "node:path"; ++import { fileURLToPath } from "node:url"; ++ ++const REQUIRED_EVIDENCE_FILES = [ ++ "gate-summary.json", ++ "cache-budget-validation.json", ++ "graph-fallback-chaos.json", ++ "release-checklist.md", ++ "rollback-rehearsal.md" ++]; ++ ++const REQUIRED_GATE_FIELDS = [ ++ "generatedAt", ++ "releaseCandidate", ++ "sampleReady", ++ "gatePass", ++ "gateDecision", ++ "metrics", ++ "blockReasons", ++ "freezeReasons", ++ "rollbackReasons", ++ "evidenceRefs" ++]; ++ ++const VALID_GATE_DECISIONS = new Set(["pass", "freeze", "block", "rollback"]); ++ ++await run(); ++ ++async function run() { ++ try { ++ const scriptDir = dirname(fileURLToPath(import.meta.url)); ++ const repoRoot = resolve(scriptDir, "../../.."); ++ const reportDir = resolve( ++ process.env.R6_REPORT_DIR?.trim() || join(repoRoot, "data/reports/r6") ++ ); ++ const archiveRoot = resolve( ++ process.env.R6_EVIDENCE_ARCHIVE_DIR?.trim() || join(reportDir, "archive") ++ ); ++ const evidenceRefPrefix = normalizeEvidenceRefPrefix( ++ process.env.R6_EVIDENCE_REF_PREFIX?.trim() || "data/reports/r6" ++ ); ++ const releaseCandidate = sanitizeToken( ++ process.env.RELEASE_CANDIDATE?.trim() || "r6-local" ++ ); ++ const generatedAt = resolveIsoTimestamp(process.env.R6_EVIDENCE_TIMESTAMP?.trim()); ++ const gateSummaryPath = join(reportDir, "gate-summary.json"); ++ const reasons = await collectIncompleteReasons({ ++ reportDir, ++ gateSummaryPath, ++ evidenceRefPrefix ++ }); ++ ++ if (reasons.length > 0) { ++ failAndExit({ ++ status: "incomplete", ++ incomplete: true, ++ reportDir, ++ gateSummaryPath, ++ reasons ++ }); ++ return; ++ } ++ ++ const archiveDir = join( ++ archiveRoot, ++ releaseCandidate, ++ toArchiveStamp(generatedAt) ++ ); ++ await mkdir(archiveDir, { recursive: true }); ++ ++ const artifactManifests = []; ++ for (const fileName of REQUIRED_EVIDENCE_FILES) { ++ const sourcePath = join(reportDir, fileName); ++ const archivePath = join(archiveDir, fileName); ++ await copyFile(sourcePath, archivePath); ++ const sourceStats = await stat(sourcePath); ++ artifactManifests.push({ ++ fileName, ++ sourcePath, ++ archivePath, ++ sizeBytes: sourceStats.size, ++ sha256: await sha256File(sourcePath) ++ }); ++ } ++ ++ const gateSummaryRaw = await readFile(gateSummaryPath, "utf8"); ++ const gateSummary = JSON.parse(gateSummaryRaw); ++ ++ const manifest = { ++ version: 1, ++ generatedAt, ++ releaseCandidate, ++ reportDir, ++ archiveDir, ++ incomplete: false, ++ gate: { ++ gateDecision: gateSummary.gateDecision, ++ gatePass: gateSummary.gatePass, ++ sampleReady: gateSummary.sampleReady ++ }, ++ artifacts: artifactManifests ++ }; ++ ++ const manifestPath = join(archiveDir, "release-evidence-manifest.json"); ++ await writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf8"); ++ ++ await writeFile( ++ join(reportDir, "release-evidence-latest.json"), ++ JSON.stringify( ++ { ++ generatedAt, ++ releaseCandidate, ++ manifestPath, ++ archiveDir, ++ incomplete: false ++ }, ++ null, ++ 2 ++ ), ++ "utf8" ++ ); ++ ++ process.stdout.write( ++ `${JSON.stringify( ++ { ++ status: "ok", ++ generatedAt, ++ releaseCandidate, ++ archiveDir, ++ manifestPath ++ }, ++ null, ++ 2 ++ )}\n` ++ ); ++ } catch (error) { ++ const detail = error instanceof Error ? error.message : String(error); ++ failAndExit({ ++ status: "failed", ++ incomplete: true, ++ reasons: [`collector_runtime_error:${detail}`] ++ }); ++ } ++} ++ ++async function collectIncompleteReasons(input) { ++ const reasons = []; ++ const gateSummary = await readGateSummary(input.gateSummaryPath, reasons); ++ if (!gateSummary) { ++ return reasons; ++ } ++ ++ validateGateSummary(gateSummary, input.evidenceRefPrefix, reasons); ++ await validateEvidenceFiles(input.reportDir, input.evidenceRefPrefix, reasons); ++ ++ return reasons; ++} ++ ++async function readGateSummary(gateSummaryPath, reasons) { ++ try { ++ await access(gateSummaryPath, fsConstants.R_OK); ++ } catch { ++ reasons.push("missing_evidence:data/reports/r6/gate-summary.json"); ++ return null; ++ } ++ ++ let parsed; ++ try { ++ const raw = await readFile(gateSummaryPath, "utf8"); ++ parsed = JSON.parse(raw); ++ } catch { ++ reasons.push("invalid_gate_summary_json"); ++ return null; ++ } ++ ++ if (!parsed || typeof parsed !== "object") { ++ reasons.push("invalid_gate_summary_shape"); ++ return null; ++ } ++ ++ return parsed; ++} ++ ++function validateGateSummary(gateSummary, evidenceRefPrefix, reasons) { ++ for (const field of REQUIRED_GATE_FIELDS) { ++ if (!(field in gateSummary)) { ++ reasons.push(`gate_summary_missing_field:${field}`); ++ } ++ } ++ ++ if (!VALID_GATE_DECISIONS.has(gateSummary.gateDecision)) { ++ reasons.push(`gate_summary_invalid_gateDecision:${String(gateSummary.gateDecision)}`); ++ } ++ ++ if (!Array.isArray(gateSummary.evidenceRefs)) { ++ reasons.push("gate_summary_invalid_evidenceRefs"); ++ return; ++ } ++ ++ for (const fileName of REQUIRED_EVIDENCE_FILES) { ++ const expectedRef = `${evidenceRefPrefix}/${fileName}`; ++ if (!gateSummary.evidenceRefs.includes(expectedRef)) { ++ reasons.push(`gate_summary_missing_evidence_ref:${expectedRef}`); ++ } ++ } ++} ++ ++async function validateEvidenceFiles(reportDir, evidenceRefPrefix, reasons) { ++ for (const fileName of REQUIRED_EVIDENCE_FILES) { ++ const filePath = join(reportDir, fileName); ++ const reference = `${evidenceRefPrefix}/${fileName}`; ++ try { ++ await access(filePath, fsConstants.R_OK); ++ const stats = await stat(filePath); ++ if (stats.size <= 0) { ++ reasons.push(`empty_evidence:${reference}`); ++ } ++ } catch { ++ reasons.push(`missing_evidence:${reference}`); ++ } ++ } ++} ++ ++function normalizeEvidenceRefPrefix(input) { ++ const trimmed = input.replaceAll("\\", "/").trim(); ++ return trimmed.replace(/\/+$/, ""); ++} ++ ++function resolveIsoTimestamp(value) { ++ if (!value) { ++ return new Date().toISOString(); ++ } ++ const parsed = Date.parse(value); ++ if (Number.isNaN(parsed)) { ++ return new Date().toISOString(); ++ } ++ return new Date(parsed).toISOString(); ++} ++ ++function toArchiveStamp(isoTimestamp) { ++ return isoTimestamp.replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); ++} ++ ++function sanitizeToken(input) { ++ const value = input.trim(); ++ if (!value) { ++ return "r6-local"; ++ } ++ return value.replace(/[^a-zA-Z0-9._-]+/g, "-"); ++} ++ ++async function sha256File(filePath) { ++ const payload = await readFile(filePath); ++ return createHash("sha256").update(payload).digest("hex"); ++} ++ ++function failAndExit(payload) { ++ process.stderr.write(`${JSON.stringify(payload, null, 2)}\n`); ++ process.exit(1); ++} +diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts +index 11f0359..6bcf669 100644 +--- a/apps/backend/src/app.module.ts ++++ b/apps/backend/src/app.module.ts +@@ -1,31 +1,37 @@ + import { Module } from "@nestjs/common"; + import { AppConfigModule } from "./modules/config/config.module"; + import { SystemModule } from "./modules/system/system.module"; + import { DataModule } from "./modules/data/data.module"; + import { AgentModule } from "./modules/agent/agent.module"; + import { ChatModule } from "./modules/chat/chat.module"; + import { EvalModule } from "./modules/eval/eval.module"; + import { LlmModule } from "./modules/llm/llm.module"; + import { ObservabilityModule } from "./modules/observability/observability.module"; ++import { RagModule } from "./modules/rag/rag.module"; + import { DatasourceModule } from "./modules/datasource/datasource.module"; + import { SettingsModule } from "./modules/settings/settings.module"; ++import { SemanticRegistryModule } from "./modules/semantic-registry/semantic-registry.module"; + import { UserModule } from "./modules/user/user.module"; + import { WorkspaceModule } from "./modules/workspace/workspace.module"; ++import { GlossaryModule } from "./modules/glossary/glossary.module"; + + @Module({ + imports: [ + AppConfigModule, + DatasourceModule, + ObservabilityModule, ++ RagModule, + DataModule, + LlmModule, + SettingsModule, ++ SemanticRegistryModule, + UserModule, + WorkspaceModule, ++ GlossaryModule, + AgentModule, + ChatModule, + EvalModule, + SystemModule + ] + }) + export class AppModule {} +diff --git a/apps/backend/src/modules/agent/agent.module.ts b/apps/backend/src/modules/agent/agent.module.ts +index 3780458..229525f 100644 +--- a/apps/backend/src/modules/agent/agent.module.ts ++++ b/apps/backend/src/modules/agent/agent.module.ts +@@ -1,42 +1,58 @@ + import { Module } from "@nestjs/common"; + import { AppConfigModule } from "../config/config.module"; + import { DataModule } from "../data/data.module"; + import { DatasourceModule } from "../datasource/datasource.module"; + import { LlmModule } from "../llm/llm.module"; + import { ObservabilityModule } from "../observability/observability.module"; ++import { RagModule } from "../rag/rag.module"; ++import { SemanticRegistryModule } from "../semantic-registry/semantic-registry.module"; ++import { SettingsModule } from "../settings/settings.module"; + import { BuildIntentPlanNode } from "./nodes/build-intent-plan.node"; + import { BuildPhysicalPlanNode } from "./nodes/build-physical-plan.node"; + import { BuildSemanticQueryNode } from "./nodes/build-semantic-query.node"; + import { GraphBuilderService } from "./graph/graph.builder"; + import { LangGraphRuntimeService } from "./graph/langgraph.runtime"; + import { ClarifyNode } from "./nodes/clarify.node"; + import { GenerateSqlNode } from "./nodes/generate-sql.node"; + import { SafetyCheckNode } from "./nodes/safety-check.node"; + import { ExecuteSqlNode } from "./nodes/execute-sql.node"; + import { FormatAnswerNode } from "./nodes/format-answer.node"; + import { RetrieveKnowledgeNode } from "./nodes/retrieve-knowledge.node"; + import { SqlGenerationService } from "./sql/sql-generation.service"; + import { SqlOutputExtractor } from "./sql/sql-output-extractor"; + import { SqlPromptBuilder } from "./sql/sql-prompt.builder"; + import { SqlReadonlyTool } from "./sql/tools/sql-readonly.tool"; + import { SqlSafetyGuard } from "./sql/tools/sql-safety.guard"; + import { SqlToolRegistryService } from "./sql/tools/sql-tool-registry.service"; ++import { PlannerVersionLockService } from "./planner/planner-version-lock.service"; ++import { PlannerCacheService } from "./planner/planner-cache.service"; + + @Module({ +- imports: [AppConfigModule, DataModule, DatasourceModule, LlmModule, ObservabilityModule], ++ imports: [ ++ AppConfigModule, ++ DataModule, ++ DatasourceModule, ++ LlmModule, ++ SettingsModule, ++ ObservabilityModule, ++ RagModule, ++ SemanticRegistryModule ++ ], + providers: [ + GraphBuilderService, + LangGraphRuntimeService, + ClarifyNode, + RetrieveKnowledgeNode, + BuildIntentPlanNode, ++ PlannerVersionLockService, ++ PlannerCacheService, + BuildSemanticQueryNode, + BuildPhysicalPlanNode, + GenerateSqlNode, + SafetyCheckNode, + ExecuteSqlNode, + FormatAnswerNode, + SqlPromptBuilder, + SqlOutputExtractor, + SqlGenerationService, + SqlSafetyGuard, +diff --git a/apps/backend/src/modules/agent/graph/graph.builder.ts b/apps/backend/src/modules/agent/graph/graph.builder.ts +index 07fd9e6..a6ba796 100644 +--- a/apps/backend/src/modules/agent/graph/graph.builder.ts ++++ b/apps/backend/src/modules/agent/graph/graph.builder.ts +@@ -89,21 +89,23 @@ export class GraphBuilderService { + } + const finalizedRun = this.toRun( + runtimeState, + this.resolveTerminalStatus(runtimeState) + ); + this.langsmithTrace.endRoot(rootTrace, { + status: finalizedRun.status, + provider: finalizedRun.provider, + outputs: this.compact({ + rowCount: finalizedRun.rows?.length, +- hasError: Boolean(finalizedRun.error) ++ hasError: Boolean(finalizedRun.error), ++ retrievalStatus: runtimeState.retrievalBundle?.status, ++ selectedContextCount: runtimeState.retrievalBundle?.selected_context?.length ?? 0 + }), + metadata: this.compact({ + requestId: traceContext.requestId, + route: traceContext.route, + source: traceContext.source, + jobId: traceContext.jobId, + caseId: traceContext.caseId + }) + }); + rootEnded = true; +diff --git a/apps/backend/src/modules/agent/graph/langgraph.runtime.ts b/apps/backend/src/modules/agent/graph/langgraph.runtime.ts +index 21113e1..a89fb6f 100644 +--- a/apps/backend/src/modules/agent/graph/langgraph.runtime.ts ++++ b/apps/backend/src/modules/agent/graph/langgraph.runtime.ts +@@ -30,20 +30,21 @@ const LangGraphStateAnnotation = Annotation.Root({ + datasourceId: Annotation(), + datasourceType: Annotation(), + modelCatalogId: Annotation(), + accessContext: Annotation(), + planningScaffoldEnabled: Annotation(), + traceContext: Annotation(), + provider: Annotation(), + model: Annotation(), + llmRaw: Annotation(), + retrievedKnowledge: Annotation(), ++ retrievalBundle: Annotation(), + intentPlan: Annotation(), + semanticQueryPlan: Annotation(), + physicalPlan: Annotation(), + planningStatus: Annotation(), + planningWarnings: Annotation(), + safetyDecision: Annotation(), + sql: Annotation(), + explanation: Annotation(), + rows: Annotation> | undefined>(), + columns: Annotation(), +@@ -222,40 +223,50 @@ export const createLangGraphRuntime = (deps: LangGraphNodeDependencies) => { + } + }); + await emitStep(config, latestStep(trace)); + return { + ...trace, + planningStatus: "legacy" + }; + } + + try { +- const knowledge = deps.retrieveKnowledgeNode.run(state.question); ++ const knowledge = await deps.retrieveKnowledgeNode.run({ ++ question: state.question, ++ datasourceId: state.datasourceId, ++ runId: state.runId, ++ modelCatalogId: state.modelCatalogId ++ }); + const endedAt = new Date().toISOString(); + const outputs = { + status: knowledge.status, +- snippets: knowledge.snippets ++ snippets: knowledge.snippets, ++ degradeReasons: knowledge.retrievalBundle?.degrade_reasons ?? [], ++ candidateCount: knowledge.retrievalBundle?.candidates.length ?? 0, ++ selectedContextCount: ++ knowledge.retrievalBundle?.selected_context?.length ?? 0 + }; + const trace = appendStep(state, { + step: { + node: "retrieve-knowledge", + status: "success", + detail: knowledge.summary, + ...withTiming(startedAt, endedAt), + outputSummary: summarize(outputs) + }, + outputs + }); + await emitStep(config, latestStep(trace)); + return { + ...trace, + retrievedKnowledge: knowledge, ++ retrievalBundle: knowledge.retrievalBundle, + planningStatus: knowledge.status === "ready" ? "ready" : "degraded", + planningWarnings: + knowledge.status === "ready" + ? state.planningWarnings + : appendPlanningWarning(state, knowledge.summary) + }; + } catch (error) { + const message = toErrorMessage(error); + const endedAt = new Date().toISOString(); + const trace = appendStep(state, { +@@ -409,25 +420,34 @@ export const createLangGraphRuntime = (deps: LangGraphNodeDependencies) => { + }); + await emitStep(config, latestStep(trace)); + return { + ...trace, + planningStatus: "degraded", + planningWarnings: appendPlanningWarning(state, reason) + }; + } + + try { +- const semanticQueryPlan = deps.buildSemanticQueryNode.run(state.intentPlan); ++ const semanticQueryPlan = await deps.buildSemanticQueryNode.run({ ++ intentPlan: state.intentPlan, ++ question: state.question, ++ retrievalBundle: state.retrievalBundle ++ }); + const endedAt = new Date().toISOString(); + const outputs = { + status: semanticQueryPlan.status, +- semanticHints: semanticQueryPlan.semanticHints ++ semanticHints: semanticQueryPlan.semanticHints, ++ semanticVersion: semanticQueryPlan.semanticVersion, ++ lockStatus: semanticQueryPlan.lockStatus, ++ fallbackApplied: semanticQueryPlan.fallbackApplied, ++ degradeReason: semanticQueryPlan.degradeReason, ++ riskTags: semanticQueryPlan.riskTags + }; + const trace = appendStep(state, { + step: { + node: "build-semantic-query", + status: "success", + detail: semanticQueryPlan.summary, + ...withTiming(startedAt, endedAt), + outputSummary: summarize(outputs) + }, + outputs +@@ -504,25 +524,35 @@ export const createLangGraphRuntime = (deps: LangGraphNodeDependencies) => { + }); + await emitStep(config, latestStep(trace)); + return { + ...trace, + planningStatus: "degraded", + planningWarnings: appendPlanningWarning(state, reason) + }; + } + + try { +- const physicalPlan = deps.buildPhysicalPlanNode.run(state.semanticQueryPlan); ++ const physicalPlan = await deps.buildPhysicalPlanNode.run({ ++ semanticPlan: state.semanticQueryPlan, ++ question: state.question, ++ datasourceId: state.datasourceId ++ }); + const endedAt = new Date().toISOString(); + const outputs = { + status: physicalPlan.status, +- strategy: physicalPlan.strategy ++ strategy: physicalPlan.strategy, ++ semanticVersion: physicalPlan.semanticVersion, ++ lockStatus: physicalPlan.lockStatus, ++ fallbackApplied: physicalPlan.fallbackApplied, ++ cacheStatus: physicalPlan.cacheStatus, ++ cacheKey: physicalPlan.cacheKey, ++ cacheReason: physicalPlan.cacheReason + }; + const trace = appendStep(state, { + step: { + node: "build-physical-plan", + status: "success", + detail: physicalPlan.summary, + ...withTiming(startedAt, endedAt), + outputSummary: summarize(outputs) + }, + outputs +@@ -567,65 +597,82 @@ export const createLangGraphRuntime = (deps: LangGraphNodeDependencies) => { + const callbacks = getCallbacks(config); + try { + const generated = await deps.generateSqlNode.run( + state.question, + state.datasourceType, + state.modelCatalogId, + callbacks.streamMode + ? { + stream: true, + tools: callbacks.tools, +- onEvent: callbacks.onLlmEvent ++ onEvent: callbacks.onLlmEvent, ++ selectedContext: state.retrievalBundle?.selected_context, ++ datasourceId: state.datasourceId, ++ workspaceId: state.accessContext?.workspaceId ++ } ++ : { ++ selectedContext: state.retrievalBundle?.selected_context, ++ datasourceId: state.datasourceId, ++ workspaceId: state.accessContext?.workspaceId + } +- : undefined + ); + const endedAt = new Date().toISOString(); + const inputs = { + question: state.question, + datasourceType: state.datasourceType, ++ selectedContextCount: state.retrievalBundle?.selected_context?.length ?? 0, ++ retrievalStatus: state.retrievalBundle?.status, ++ retrievalDegradeReasons: state.retrievalBundle?.degrade_reasons, ++ retrievalRiskTags: state.retrievalBundle?.risk_tags ?? [], + modelCatalogId: state.modelCatalogId, ++ datasourceId: state.datasourceId, ++ workspaceId: state.accessContext?.workspaceId, + planningScaffoldEnabled: state.planningScaffoldEnabled, + planningStatus: state.planningStatus, + planningWarnings: state.planningWarnings, + retrieveSummary: state.retrievedKnowledge?.summary, + intent: state.intentPlan?.intent, + semanticHints: state.semanticQueryPlan?.semanticHints, +- physicalStrategy: state.physicalPlan?.strategy, +- systemPrompt: generated.prompt.systemPrompt, +- userPrompt: generated.prompt.userPrompt ++ physicalStrategy: state.physicalPlan?.strategy + }; + const outputs = { + provider: generated.provider, + model: generated.model, + modelCatalogId: generated.modelCatalogId, + sql: generated.sql, +- rawText: generated.rawText ++ rawText: generated.rawText, ++ promptTemplate: generated.promptTemplate + }; + const trace = appendStep(state, { + step: { + node: "generate-sql", + status: "success", + detail: generated.provider, + ...withTiming(startedAt, endedAt), + inputSummary: summarize(inputs), + outputSummary: summarize(outputs) + }, + runType: "llm", + inputs, + outputs + }); + await emitStep(config, latestStep(trace)); + return { + ...trace, + trace: { + ...trace.trace, +- provider: generated.provider ++ provider: generated.provider, ++ ...(generated.promptTemplate ++ ? { ++ promptTemplate: generated.promptTemplate ++ } ++ : {}) + }, + provider: generated.provider, + model: generated.model, + llmRaw: { + provider: generated.provider, + model: generated.model, + rawText: generated.rawText, + createdAt: endedAt + }, + sql: generated.sql, +@@ -685,21 +732,22 @@ export const createLangGraphRuntime = (deps: LangGraphNodeDependencies) => { + return { + ...trace, + error: reason, + terminalStatus: "failed" + }; + } + + const safety = await deps.safetyNode.run({ + sql: state.sql, + datasourceId: state.datasourceId, +- accessContext: state.accessContext ++ accessContext: state.accessContext, ++ riskTags: state.retrievalBundle?.risk_tags + }); + const endedAt = new Date().toISOString(); + const inputs = { + sql: state.sql, + datasourceId: state.datasourceId, + workspaceId: state.accessContext?.workspaceId + }; + if (!safety.allowed) { + const trace = appendStep(state, { + step: { +diff --git a/apps/backend/src/modules/agent/graph/langgraph.state.ts b/apps/backend/src/modules/agent/graph/langgraph.state.ts +index 5ba65f1..9425765 100644 +--- a/apps/backend/src/modules/agent/graph/langgraph.state.ts ++++ b/apps/backend/src/modules/agent/graph/langgraph.state.ts +@@ -4,35 +4,37 @@ import type { + ExecutionTraceStep, + SqlRun + } from "@text2sql/shared-types"; + import type { GraphInput, GraphTraceContext } from "./agent.types"; + import type { SqlTableAccessContext } from "../../data/query/sql-table-access-guard.service"; + import type { RetrievedKnowledge } from "../nodes/retrieve-knowledge.node"; + import type { IntentPlan } from "../nodes/build-intent-plan.node"; + import type { SemanticQueryPlan } from "../nodes/build-semantic-query.node"; + import type { PhysicalPlan } from "../nodes/build-physical-plan.node"; + import type { SqlSafetyDecision } from "../sql/tools/sql-safety.guard"; ++import type { RagRetrievalBundle } from "../../rag/retrieval/rag-retrieval.types"; + + export interface LangGraphSpanEvent { + step: ExecutionTraceStep; + runType?: "chain" | "llm" | "tool"; + inputs?: Record; + outputs?: Record; + metadata?: Record; + error?: string; + } + + export interface LangGraphState extends GraphInput { + provider: string; + model?: string; + llmRaw?: SqlRun["llmRaw"]; + retrievedKnowledge?: RetrievedKnowledge; ++ retrievalBundle?: RagRetrievalBundle; + intentPlan?: IntentPlan; + semanticQueryPlan?: SemanticQueryPlan; + physicalPlan?: PhysicalPlan; + planningStatus?: "legacy" | "ready" | "degraded"; + planningWarnings?: string[]; + safetyDecision?: SqlSafetyDecision; + sql?: string; + explanation?: string; + rows?: Array>; + columns?: string[]; +diff --git a/apps/backend/src/modules/agent/nodes/build-intent-plan.node.ts b/apps/backend/src/modules/agent/nodes/build-intent-plan.node.ts +index ea591f8..570253b 100644 +--- a/apps/backend/src/modules/agent/nodes/build-intent-plan.node.ts ++++ b/apps/backend/src/modules/agent/nodes/build-intent-plan.node.ts +@@ -5,44 +5,51 @@ export interface IntentPlan { + status: "ready" | "degraded"; + intent: "aggregate" | "detail" | "compare" | "unknown"; + constraints: string[]; + summary: string; + } + + @Injectable() + export class BuildIntentPlanNode { + run(question: string, knowledge: RetrievedKnowledge): IntentPlan { + const normalized = question.toLowerCase(); ++ const selectedContextCount = knowledge.retrievalBundle?.selected_context?.length ?? 0; + if (knowledge.status === "degraded") { + return { + status: "degraded", + intent: "unknown", +- constraints: [], ++ constraints: selectedContextCount > 0 ? ["fallback_with_partial_context"] : [], + summary: "检索上下文不可用,意图规划降级。" + }; + } + + if (/统计|总数|分布|汇总/.test(normalized)) { + return { + status: "ready", + intent: "aggregate", +- constraints: ["prefer_group_by"], ++ constraints: [ ++ "prefer_group_by", ++ ...(selectedContextCount > 0 ? ["must_use_selected_context"] : []) ++ ], + summary: "意图识别为聚合统计。" + }; + } + if (/对比|比较|同比|环比/.test(normalized)) { + return { + status: "ready", + intent: "compare", +- constraints: ["require_two_dimensions"], ++ constraints: [ ++ "require_two_dimensions", ++ ...(selectedContextCount > 0 ? ["must_use_selected_context"] : []) ++ ], + summary: "意图识别为对比分析。" + }; + } + + return { + status: "ready", + intent: "detail", +- constraints: [], ++ constraints: selectedContextCount > 0 ? ["must_use_selected_context"] : [], + summary: "意图识别为明细查询。" + }; + } + } +diff --git a/apps/backend/src/modules/agent/nodes/build-physical-plan.node.ts b/apps/backend/src/modules/agent/nodes/build-physical-plan.node.ts +index b2d4cd8..15200ee 100644 +--- a/apps/backend/src/modules/agent/nodes/build-physical-plan.node.ts ++++ b/apps/backend/src/modules/agent/nodes/build-physical-plan.node.ts +@@ -1,27 +1,93 @@ + import { Injectable } from "@nestjs/common"; + import type { SemanticQueryPlan } from "./build-semantic-query.node"; ++import { PlannerCacheService, type PlannerCacheStatus } from "../planner/planner-cache.service"; + + export interface PhysicalPlan { + status: "ready" | "degraded"; + strategy: "direct_sql" | "fallback_sql"; ++ semanticVersion?: number; ++ lockStatus: "locked" | "fallback" | "degraded"; ++ fallbackApplied: boolean; ++ cacheStatus: PlannerCacheStatus; ++ cacheKey?: string; ++ cacheReason?: string; + summary: string; + } + + @Injectable() + export class BuildPhysicalPlanNode { +- run(semanticPlan: SemanticQueryPlan): PhysicalPlan { +- if (semanticPlan.status === "degraded") { ++ constructor(private readonly plannerCache: PlannerCacheService) {} ++ ++ async run(input: { ++ semanticPlan: SemanticQueryPlan; ++ question: string; ++ datasourceId: string; ++ }): Promise { ++ const { semanticPlan } = input; ++ if (semanticPlan.status === "degraded" && !semanticPlan.semanticVersion) { + return { + status: "degraded", + strategy: "fallback_sql", +- summary: "语义阶段降级,改用兼容执行策略。" ++ semanticVersion: undefined, ++ lockStatus: semanticPlan.lockStatus, ++ fallbackApplied: semanticPlan.fallbackApplied, ++ cacheStatus: "miss", ++ cacheReason: "semantic_version_missing", ++ summary: "语义阶段降级且无可用版本,改用兼容执行策略。" ++ }; ++ } ++ ++ const cacheLookup = this.plannerCache.lookup({ ++ datasourceId: input.datasourceId, ++ question: input.question, ++ semanticVersion: semanticPlan.semanticVersion ++ }); ++ if (cacheLookup.status === "hit" && cacheLookup.entry) { ++ return { ++ status: semanticPlan.status, ++ strategy: cacheLookup.entry.strategy, ++ semanticVersion: semanticPlan.semanticVersion, ++ lockStatus: semanticPlan.lockStatus, ++ fallbackApplied: semanticPlan.fallbackApplied, ++ cacheStatus: "hit", ++ cacheKey: cacheLookup.cacheKey, ++ summary: `${cacheLookup.entry.summary}(planner cache hit)` + }; + } + ++ const strategy = ++ semanticPlan.status === "ready" && semanticPlan.lockStatus === "locked" ++ ? "direct_sql" ++ : "fallback_sql"; ++ const summary = ++ strategy === "direct_sql" ++ ? "已生成最小物理执行计划。" ++ : "语义阶段降级,改用兼容执行策略。"; ++ ++ if (semanticPlan.semanticVersion) { ++ this.plannerCache.invalidateByDatasourceAndSemanticVersion( ++ input.datasourceId, ++ semanticPlan.semanticVersion ++ ); ++ this.plannerCache.store({ ++ datasourceId: input.datasourceId, ++ question: input.question, ++ semanticVersion: semanticPlan.semanticVersion, ++ strategy, ++ summary ++ }); ++ } ++ + return { +- status: "ready", +- strategy: "direct_sql", +- summary: "已生成最小物理执行计划。" ++ status: strategy === "direct_sql" ? "ready" : "degraded", ++ strategy, ++ semanticVersion: semanticPlan.semanticVersion, ++ lockStatus: semanticPlan.lockStatus, ++ fallbackApplied: semanticPlan.fallbackApplied, ++ cacheStatus: cacheLookup.status, ++ cacheKey: cacheLookup.cacheKey, ++ cacheReason: cacheLookup.reason, ++ summary + }; + } + } +diff --git a/apps/backend/src/modules/agent/nodes/build-semantic-query.node.ts b/apps/backend/src/modules/agent/nodes/build-semantic-query.node.ts +index 41bde71..d3fea80 100644 +--- a/apps/backend/src/modules/agent/nodes/build-semantic-query.node.ts ++++ b/apps/backend/src/modules/agent/nodes/build-semantic-query.node.ts +@@ -1,34 +1,92 @@ + import { Injectable } from "@nestjs/common"; + import type { IntentPlan } from "./build-intent-plan.node"; ++import type { RagRetrievalBundle } from "../../rag/retrieval/rag-retrieval.types"; ++import { PlannerVersionLockService } from "../planner/planner-version-lock.service"; + + export interface SemanticQueryPlan { + status: "ready" | "degraded"; + semanticHints: string[]; ++ semanticVersion?: number; ++ lockStatus: "locked" | "fallback" | "degraded"; ++ fallbackApplied: boolean; ++ degradeReason?: string; ++ riskTags: string[]; + summary: string; + } + + @Injectable() + export class BuildSemanticQueryNode { +- run(intentPlan: IntentPlan): SemanticQueryPlan { ++ constructor(private readonly plannerVersionLock: PlannerVersionLockService) {} ++ ++ async run(input: { ++ intentPlan: IntentPlan; ++ question: string; ++ retrievalBundle?: RagRetrievalBundle; ++ requestedSemanticVersion?: number; ++ }): Promise { ++ const { intentPlan } = input; + if (intentPlan.status === "degraded") { + return { + status: "degraded", + semanticHints: [], ++ lockStatus: "degraded", ++ fallbackApplied: false, ++ riskTags: [], + summary: "意图规划不可用,语义检索降级。" + }; + } + ++ const versionLock = await this.plannerVersionLock.resolve({ ++ question: input.question, ++ retrievalBundle: input.retrievalBundle, ++ requestedSemanticVersion: input.requestedSemanticVersion ++ }); ++ + const semanticHints = + intentPlan.intent === "aggregate" + ? ["use_metric_aliases", "prefer_dimension_filters"] + : intentPlan.intent === "compare" + ? ["normalize_time_window", "keep_metric_consistency"] + : ["prefer_direct_lookup"]; ++ if (intentPlan.constraints.includes("must_use_selected_context")) { ++ semanticHints.push("must_consume_selected_context"); ++ } ++ ++ if (versionLock.lockStatus === "degraded") { ++ return { ++ status: "degraded", ++ semanticHints, ++ semanticVersion: versionLock.semanticVersion, ++ lockStatus: "degraded", ++ fallbackApplied: false, ++ degradeReason: versionLock.degradeReason, ++ riskTags: versionLock.riskTags, ++ summary: `语义版本锁降级:${versionLock.degradeReason ?? "unknown"}` ++ }; ++ } ++ ++ if (versionLock.lockStatus === "fallback") { ++ return { ++ status: "degraded", ++ semanticHints, ++ semanticVersion: versionLock.semanticVersion, ++ lockStatus: "fallback", ++ fallbackApplied: true, ++ degradeReason: versionLock.degradeReason, ++ riskTags: versionLock.riskTags, ++ summary: ++ "语义版本不存在,已回退到最近稳定版本并标记降级路径。" ++ }; ++ } + + return { + status: "ready", + semanticHints, +- summary: "已生成语义检索提示。" ++ semanticVersion: versionLock.semanticVersion, ++ lockStatus: "locked", ++ fallbackApplied: false, ++ riskTags: versionLock.riskTags, ++ summary: "已生成语义检索提示并完成版本锁定。" + }; + } + } +diff --git a/apps/backend/src/modules/agent/nodes/generate-sql.node.ts b/apps/backend/src/modules/agent/nodes/generate-sql.node.ts +index ec7c6e1..6b64239 100644 +--- a/apps/backend/src/modules/agent/nodes/generate-sql.node.ts ++++ b/apps/backend/src/modules/agent/nodes/generate-sql.node.ts +@@ -1,52 +1,63 @@ + import { Injectable } from "@nestjs/common"; +-import type { DatasourceType } from "@text2sql/shared-types"; ++import type { DatasourceType, PromptTemplateTraceEvidence } from "@text2sql/shared-types"; + import type { + LlmGatewayStreamEvent, + LlmGatewayToolDefinition + } from "../../llm/llm-gateway.interface"; + import { SqlGenerationService } from "../sql/sql-generation.service"; ++import type { RagRetrievalChunkPayload } from "../../rag/retrieval/rag-retrieval.types"; + + @Injectable() + export class GenerateSqlNode { + constructor(private readonly sqlGeneration: SqlGenerationService) {} + + async run( + question: string, + datasourceType?: DatasourceType, + modelCatalogId?: string, + options?: { + stream?: boolean; + tools?: Record; + onEvent?: (event: LlmGatewayStreamEvent) => Promise | void; ++ selectedContext?: RagRetrievalChunkPayload[]; ++ datasourceId?: string; ++ workspaceId?: string; + } + ): Promise<{ + provider: string; + model: string; + modelCatalogId?: string; + sql: string; + explanation: string; + rawText: string; + prompt: { + systemPrompt: string; + userPrompt: string; + }; ++ promptTemplate?: PromptTemplateTraceEvidence; + }> { + if (options?.stream) { + return this.sqlGeneration.stream( + question, + { ++ datasourceId: options.datasourceId, ++ workspaceId: options.workspaceId, + datasourceType, +- modelCatalogId ++ modelCatalogId, ++ selectedContext: options.selectedContext + }, + { + tools: options.tools, + onEvent: options.onEvent + } + ); + } + return this.sqlGeneration.generate(question, { ++ datasourceId: options?.datasourceId, ++ workspaceId: options?.workspaceId, + datasourceType, +- modelCatalogId ++ modelCatalogId, ++ selectedContext: options?.selectedContext + }); + } + } +diff --git a/apps/backend/src/modules/agent/nodes/retrieve-knowledge.node.ts b/apps/backend/src/modules/agent/nodes/retrieve-knowledge.node.ts +index ba2b08c..47e1fd4 100644 +--- a/apps/backend/src/modules/agent/nodes/retrieve-knowledge.node.ts ++++ b/apps/backend/src/modules/agent/nodes/retrieve-knowledge.node.ts +@@ -1,27 +1,100 @@ + import { Injectable } from "@nestjs/common"; ++import { RagRetrievalService } from "../../rag/retrieval/rag-retrieval.service"; ++import { RagRerankService } from "../../rag/rerank/rag-rerank.service"; ++import type { RagRetrievalBundle } from "../../rag/retrieval/rag-retrieval.types"; + + export interface RetrievedKnowledge { + status: "ready" | "degraded"; + snippets: string[]; + summary: string; ++ retrievalBundle?: RagRetrievalBundle; + } + + @Injectable() + export class RetrieveKnowledgeNode { +- run(question: string): RetrievedKnowledge { ++ constructor( ++ private readonly retrievalService: RagRetrievalService, ++ private readonly rerankService: RagRerankService ++ ) {} ++ ++ async run(input: { ++ question: string; ++ datasourceId: string; ++ runId: string; ++ modelCatalogId?: string; ++ }): Promise { ++ const question = input.question.trim(); ++ const datasourceId = input.datasourceId.trim(); ++ const runId = input.runId.trim(); ++ + const normalized = question.trim(); + if (!normalized) { + return { + status: "degraded", + snippets: [], +- summary: "检索输入为空,已降级到最小执行路径。" ++ summary: "检索输入为空,已降级到最小执行路径。", ++ retrievalBundle: { ++ query: question, ++ run_id: runId, ++ datasource_id: datasourceId, ++ status: "degraded", ++ degrade_reasons: ["empty_question"], ++ lane_results: { ++ lexical: { ++ lane: "lexical", ++ status: "degraded", ++ timeout_ms: 0, ++ elapsed_ms: 0, ++ degrade_reason: "empty_question", ++ hits: [] ++ }, ++ dense: { ++ lane: "dense", ++ status: "degraded", ++ timeout_ms: 0, ++ elapsed_ms: 0, ++ degrade_reason: "empty_question", ++ hits: [] ++ }, ++ graph: { ++ lane: "graph", ++ status: "degraded", ++ timeout_ms: 0, ++ elapsed_ms: 0, ++ degrade_reason: "empty_question", ++ hits: [] ++ } ++ }, ++ candidates: [], ++ reranked: [], ++ selected_context: [], ++ risk_tags: ["rag_zero_recall"] ++ } + }; + } + ++ const retrieved = await this.retrievalService.retrieve({ ++ query: normalized, ++ datasourceId, ++ runId ++ }); ++ const reranked = await this.rerankService.rerank({ ++ retrievalBundle: retrieved.retrieval_bundle, ++ modelCatalogId: input.modelCatalogId ++ }); ++ const bundle = reranked.retrieval_bundle; ++ const snippets = (bundle.selected_context ?? bundle.candidates.map((item) => item.chunk)) ++ .slice(0, 3) ++ .map((item) => item.content.slice(0, 200)); ++ + return { +- status: "ready", +- snippets: [normalized.slice(0, 120)], +- summary: "已生成最小检索上下文。" ++ status: bundle.status, ++ snippets, ++ summary: ++ bundle.status === "ready" ++ ? `检索与重排完成,候选=${bundle.candidates.length},上下文=${bundle.selected_context?.length ?? 0}。` ++ : `检索链路降级执行,原因=${bundle.degrade_reasons.join(", ") || "unknown"}。`, ++ retrievalBundle: bundle + }; + } + } +diff --git a/apps/backend/src/modules/agent/nodes/safety-check.node.ts b/apps/backend/src/modules/agent/nodes/safety-check.node.ts +index 62f4c04..0c893f5 100644 +--- a/apps/backend/src/modules/agent/nodes/safety-check.node.ts ++++ b/apps/backend/src/modules/agent/nodes/safety-check.node.ts +@@ -13,22 +13,23 @@ import { + export class SafetyCheckNode { + constructor( + private readonly tableAccessGuard: SqlTableAccessGuardService = new SqlTableAccessGuardService(), + private readonly safetyGuard?: SqlSafetyGuard + ) {} + + async run(input: { + sql: string; + datasourceId: string; + accessContext?: SqlTableAccessContext; ++ riskTags?: string[]; + }): Promise { +- const readonlyDecision = this.evaluateReadonly(input.sql); ++ const readonlyDecision = this.evaluateReadonly(input.sql, input.riskTags); + if (!readonlyDecision.allowed) { + return readonlyDecision; + } + + if (input.accessContext?.allowedTables?.length) { + try { + await this.tableAccessGuard.assertTableAccess({ + sql: input.sql, + datasourceId: input.datasourceId, + accessContext: input.accessContext, +@@ -46,23 +47,23 @@ export class SafetyCheckNode { + : error instanceof Error + ? error.message + : "表级权限校验失败。" + }; + } + } + + return readonlyDecision; + } + +- private evaluateReadonly(sql: string): SqlSafetyDecision { ++ private evaluateReadonly(sql: string, riskTags?: string[]): SqlSafetyDecision { + if (this.safetyGuard) { +- return this.safetyGuard.evaluate(sql); ++ return this.safetyGuard.evaluate(sql, riskTags); + } + + try { + this.tableAccessGuard.assertReadOnlySql(sql); + } catch (error) { + return { + allowed: false, + mode: "hard-block", + riskLevel: "high", + riskTags: ["readonly_violation"], +diff --git a/apps/backend/src/modules/agent/planner/planner-cache.service.ts b/apps/backend/src/modules/agent/planner/planner-cache.service.ts +new file mode 100644 +index 0000000..9547811 +--- /dev/null ++++ b/apps/backend/src/modules/agent/planner/planner-cache.service.ts +@@ -0,0 +1,133 @@ ++import { createHash } from "node:crypto"; ++import { Injectable } from "@nestjs/common"; ++ ++export type PlannerCacheStatus = "hit" | "miss" | "stale"; ++ ++export interface PlannerCacheEntry { ++ cacheKey: string; ++ datasourceId: string; ++ queryHash: string; ++ semanticVersion: number; ++ strategy: "direct_sql" | "fallback_sql"; ++ summary: string; ++ createdAt: string; ++ expiresAt: string; ++} ++ ++export interface PlannerCacheLookupResult { ++ status: PlannerCacheStatus; ++ cacheKey: string; ++ reason?: string; ++ entry?: PlannerCacheEntry; ++} ++ ++const DEFAULT_TTL_SECONDS = 15 * 60; ++ ++@Injectable() ++export class PlannerCacheService { ++ private readonly entries = new Map(); ++ ++ lookup(input: { ++ datasourceId: string; ++ question: string; ++ semanticVersion?: number; ++ }): PlannerCacheLookupResult { ++ if (!input.semanticVersion) { ++ return { ++ status: "miss", ++ cacheKey: this.buildCacheKey(input.datasourceId, input.question, 0), ++ reason: "semantic_version_missing" ++ }; ++ } ++ const cacheKey = this.buildCacheKey( ++ input.datasourceId, ++ input.question, ++ input.semanticVersion ++ ); ++ const entry = this.entries.get(cacheKey); ++ if (!entry) { ++ return { ++ status: "miss", ++ cacheKey, ++ reason: "cache_not_found" ++ }; ++ } ++ if (Date.parse(entry.expiresAt) <= Date.now()) { ++ this.entries.delete(cacheKey); ++ return { ++ status: "stale", ++ cacheKey, ++ reason: "cache_expired" ++ }; ++ } ++ return { ++ status: "hit", ++ cacheKey, ++ entry: { ...entry } ++ }; ++ } ++ ++ store(input: { ++ datasourceId: string; ++ question: string; ++ semanticVersion: number; ++ strategy: "direct_sql" | "fallback_sql"; ++ summary: string; ++ ttlSeconds?: number; ++ }): PlannerCacheEntry { ++ const now = new Date(); ++ const ttlSeconds = ++ typeof input.ttlSeconds === "number" && input.ttlSeconds > 0 ++ ? Math.floor(input.ttlSeconds) ++ : DEFAULT_TTL_SECONDS; ++ const queryHash = this.hashQuery(input.question); ++ const cacheKey = this.buildCacheKey( ++ input.datasourceId, ++ input.question, ++ input.semanticVersion ++ ); ++ const entry: PlannerCacheEntry = { ++ cacheKey, ++ datasourceId: input.datasourceId.trim(), ++ queryHash, ++ semanticVersion: input.semanticVersion, ++ strategy: input.strategy, ++ summary: input.summary, ++ createdAt: now.toISOString(), ++ expiresAt: new Date(now.getTime() + ttlSeconds * 1000).toISOString() ++ }; ++ this.entries.set(cacheKey, entry); ++ return { ...entry }; ++ } ++ ++ invalidateByDatasourceAndSemanticVersion( ++ datasourceId: string, ++ semanticVersion: number ++ ): number { ++ let removed = 0; ++ for (const [cacheKey, entry] of this.entries.entries()) { ++ if (entry.datasourceId !== datasourceId || entry.semanticVersion === semanticVersion) { ++ continue; ++ } ++ this.entries.delete(cacheKey); ++ removed += 1; ++ } ++ return removed; ++ } ++ ++ private buildCacheKey( ++ datasourceId: string, ++ question: string, ++ semanticVersion: number ++ ): string { ++ const normalizedDatasource = datasourceId.trim().toLowerCase(); ++ const queryHash = this.hashQuery(question); ++ return `${normalizedDatasource}:${queryHash}:v${semanticVersion}`; ++ } ++ ++ private hashQuery(question: string): string { ++ return createHash("sha256") ++ .update(question.trim().toLowerCase()) ++ .digest("hex"); ++ } ++} +diff --git a/apps/backend/src/modules/agent/planner/planner-version-lock.service.ts b/apps/backend/src/modules/agent/planner/planner-version-lock.service.ts +new file mode 100644 +index 0000000..d3dc8f5 +--- /dev/null ++++ b/apps/backend/src/modules/agent/planner/planner-version-lock.service.ts +@@ -0,0 +1,185 @@ ++import { Injectable } from "@nestjs/common"; ++import type { RagRetrievalBundle } from "../../rag/retrieval/rag-retrieval.types"; ++import { SemanticRegistryService } from "../../semantic-registry/semantic-registry.service"; ++import { ++ SEMANTIC_REGISTRY_DEGRADED_RISK_TAG, ++ SEMANTIC_TERM_NOT_FOUND_REASON, ++ SEMANTIC_VERSION_NOT_FOUND_REASON ++} from "../../semantic-registry/semantic-registry.service"; ++ ++export type PlannerLockStatus = "locked" | "fallback" | "degraded"; ++ ++export interface PlannerVersionLockInput { ++ question: string; ++ retrievalBundle?: RagRetrievalBundle; ++ requestedSemanticVersion?: number; ++} ++ ++export interface PlannerVersionLockResult { ++ lockStatus: PlannerLockStatus; ++ semanticVersion?: number; ++ requestedSemanticVersion?: number; ++ fallbackApplied: boolean; ++ degradeReason?: string; ++ riskTags: string[]; ++ domain: string; ++ term: string; ++} ++ ++@Injectable() ++export class PlannerVersionLockService { ++ constructor(private readonly semanticRegistry: SemanticRegistryService) {} ++ ++ async resolve(input: PlannerVersionLockInput): Promise { ++ const { domain, term, datasourceId } = this.resolveDomainAndTerm(input); ++ const requestedSemanticVersion = ++ typeof input.requestedSemanticVersion === "number" && ++ Number.isInteger(input.requestedSemanticVersion) && ++ input.requestedSemanticVersion > 0 ++ ? input.requestedSemanticVersion ++ : undefined; ++ ++ if (requestedSemanticVersion) { ++ const requested = await this.semanticRegistry.resolveTerm({ ++ domain, ++ term, ++ semanticVersion: requestedSemanticVersion, ++ datasourceId ++ }); ++ if (requested.status === "ready") { ++ return { ++ lockStatus: "locked", ++ semanticVersion: requested.semantic_version, ++ requestedSemanticVersion, ++ fallbackApplied: false, ++ riskTags: requested.risk_tags, ++ domain, ++ term ++ }; ++ } ++ ++ if (requested.degrade_reason === SEMANTIC_VERSION_NOT_FOUND_REASON) { ++ const fallback = await this.semanticRegistry.resolveTerm({ ++ domain, ++ term, ++ datasourceId ++ }); ++ if (fallback.status === "ready") { ++ return { ++ lockStatus: "fallback", ++ semanticVersion: fallback.semantic_version, ++ requestedSemanticVersion, ++ fallbackApplied: true, ++ degradeReason: requested.degrade_reason, ++ riskTags: this.mergeRiskTags( ++ [SEMANTIC_REGISTRY_DEGRADED_RISK_TAG], ++ requested.risk_tags, ++ fallback.risk_tags ++ ), ++ domain, ++ term ++ }; ++ } ++ } ++ ++ return { ++ lockStatus: "degraded", ++ semanticVersion: requested.semantic_version, ++ requestedSemanticVersion, ++ fallbackApplied: false, ++ degradeReason: requested.degrade_reason ?? SEMANTIC_VERSION_NOT_FOUND_REASON, ++ riskTags: this.mergeRiskTags([SEMANTIC_REGISTRY_DEGRADED_RISK_TAG], requested.risk_tags), ++ domain, ++ term ++ }; ++ } ++ ++ const resolved = await this.semanticRegistry.resolveTerm({ ++ domain, ++ term, ++ datasourceId ++ }); ++ if (resolved.status === "ready") { ++ return { ++ lockStatus: "locked", ++ semanticVersion: resolved.semantic_version, ++ requestedSemanticVersion: undefined, ++ fallbackApplied: false, ++ riskTags: resolved.risk_tags, ++ domain, ++ term ++ }; ++ } ++ ++ return { ++ lockStatus: "degraded", ++ semanticVersion: resolved.semantic_version, ++ requestedSemanticVersion: undefined, ++ fallbackApplied: false, ++ degradeReason: resolved.degrade_reason ?? SEMANTIC_TERM_NOT_FOUND_REASON, ++ riskTags: this.mergeRiskTags([SEMANTIC_REGISTRY_DEGRADED_RISK_TAG], resolved.risk_tags), ++ domain, ++ term ++ }; ++ } ++ ++ private resolveDomainAndTerm(input: PlannerVersionLockInput): { ++ domain: string; ++ term: string; ++ datasourceId?: string; ++ } { ++ const bundle = input.retrievalBundle; ++ const fromSkillContext = bundle?.skill_context?.context.at(0); ++ const fromSelectedContext = bundle?.selected_context?.at(0); ++ const sourceMetadata = fromSelectedContext?.metadata.sourceMetadata; ++ const domain = ++ fromSkillContext?.domain ?? ++ fromSelectedContext?.metadata.domain ?? ++ bundle?.candidates.at(0)?.chunk.metadata.domain ?? ++ "semantic_term"; ++ const datasourceId = ++ bundle?.datasource_id ?? ++ fromSelectedContext?.metadata.datasourceId ?? ++ (typeof sourceMetadata?.datasourceId === "string" ++ ? sourceMetadata.datasourceId ++ : undefined); ++ const semanticHint = ++ typeof sourceMetadata?.glossaryTerm === "string" ++ ? sourceMetadata.glossaryTerm ++ : typeof sourceMetadata?.term === "string" ++ ? sourceMetadata.term ++ : undefined; ++ ++ const term = ++ fromSkillContext?.term ?? ++ semanticHint ?? ++ this.extractFirstToken(input.question) ?? ++ "default"; ++ ++ return { ++ domain: domain.trim().toLowerCase(), ++ term: term.trim().toLowerCase(), ++ datasourceId: datasourceId?.trim() || undefined ++ }; ++ } ++ ++ private extractFirstToken(question: string): string | undefined { ++ const tokens = question ++ .toLowerCase() ++ .match(/[a-z0-9_\p{L}\p{N}]+/gu); ++ return tokens?.at(0); ++ } ++ ++ private mergeRiskTags(...groups: Array): string[] { ++ const merged = new Set(); ++ for (const group of groups) { ++ for (const riskTag of group ?? []) { ++ const normalized = riskTag.trim(); ++ if (normalized) { ++ merged.add(normalized); ++ } ++ } ++ } ++ return [...merged]; ++ } ++} +diff --git a/apps/backend/src/modules/agent/sql/sql-generation.service.ts b/apps/backend/src/modules/agent/sql/sql-generation.service.ts +index 326ff02..6d053ae 100644 +--- a/apps/backend/src/modules/agent/sql/sql-generation.service.ts ++++ b/apps/backend/src/modules/agent/sql/sql-generation.service.ts +@@ -1,75 +1,129 @@ + import { Injectable } from "@nestjs/common"; +-import type { DatasourceType } from "@text2sql/shared-types"; ++import type { DatasourceType, PromptTemplateTraceEvidence } from "@text2sql/shared-types"; + import type { + LlmGatewayPrompt, + LlmGatewayStreamEvent, + LlmGatewayToolDefinition + } from "../../llm/llm-gateway.interface"; + import { ProviderRouterService } from "../../llm/provider-router.service"; + import { SqlOutputExtractor } from "./sql-output-extractor"; + import { SqlPromptBuilder } from "./sql-prompt.builder"; ++import type { RagRetrievalChunkPayload } from "../../rag/retrieval/rag-retrieval.types"; ++import { PromptTemplateService } from "../../settings/prompt-template.service"; + + export interface SqlDraft { + provider: string; + model: string; + modelCatalogId?: string; + sql: string; + explanation: string; + rawText: string; + prompt: LlmGatewayPrompt; ++ promptTemplate?: PromptTemplateTraceEvidence; + } + + @Injectable() + export class SqlGenerationService { + constructor( + private readonly promptBuilder: SqlPromptBuilder, + private readonly extractor: SqlOutputExtractor, +- private readonly providerRouter: ProviderRouterService ++ private readonly providerRouter: ProviderRouterService, ++ private readonly promptTemplateService: PromptTemplateService + ) {} + + async generate( + question: string, + selection?: { ++ datasourceId?: string; ++ workspaceId?: string; + datasourceType?: DatasourceType; + modelCatalogId?: string; ++ selectedContext?: RagRetrievalChunkPayload[]; + } + ): Promise { +- const prompt = this.promptBuilder.build(question, selection?.datasourceType); ++ const templateResolution = await this.resolvePromptTemplate(selection); ++ const prompt = this.promptBuilder.build( ++ question, ++ selection?.datasourceType, ++ selection?.selectedContext, ++ { ++ templateOverlay: templateResolution.templateOverlay ++ } ++ ); + const completion = await this.providerRouter.generate(prompt, selection); + const extracted = this.extractor.extract(completion.rawText); + return { + provider: completion.provider, + model: completion.model, + modelCatalogId: completion.modelCatalogId, + sql: extracted.sql, + explanation: extracted.explanation || completion.rawText, + rawText: completion.rawText, +- prompt ++ prompt, ++ promptTemplate: templateResolution.evidence + }; + } + + async stream( + question: string, + selection?: { ++ datasourceId?: string; ++ workspaceId?: string; + datasourceType?: DatasourceType; + modelCatalogId?: string; ++ selectedContext?: RagRetrievalChunkPayload[]; + }, + options?: { + tools?: Record; + onEvent?: (event: LlmGatewayStreamEvent) => Promise | void; + } + ): Promise { +- const prompt = this.promptBuilder.build(question, selection?.datasourceType); ++ const templateResolution = await this.resolvePromptTemplate(selection); ++ const prompt = this.promptBuilder.build( ++ question, ++ selection?.datasourceType, ++ selection?.selectedContext, ++ { ++ templateOverlay: templateResolution.templateOverlay ++ } ++ ); + const completion = await this.providerRouter.stream(prompt, selection, options); + const extracted = this.extractor.extract(completion.rawText); + return { + provider: completion.provider, + model: completion.model, + modelCatalogId: completion.modelCatalogId, + sql: extracted.sql, + explanation: extracted.explanation || completion.rawText, + rawText: completion.rawText, +- prompt ++ prompt, ++ promptTemplate: templateResolution.evidence + }; + } ++ ++ private async resolvePromptTemplate(selection?: { ++ datasourceId?: string; ++ workspaceId?: string; ++ }): Promise<{ ++ templateOverlay?: string; ++ evidence: PromptTemplateTraceEvidence; ++ }> { ++ try { ++ const resolved = await this.promptTemplateService.resolveSqlTemplateRuntime({ ++ datasourceId: selection?.datasourceId, ++ workspaceId: selection?.workspaceId ++ }); ++ return { ++ templateOverlay: resolved.template?.content, ++ evidence: resolved.evidence ++ }; ++ } catch { ++ return { ++ evidence: { ++ scene: "sql", ++ fallbackReason: "template_service_error" ++ } ++ }; ++ } ++ } + } +diff --git a/apps/backend/src/modules/agent/sql/sql-prompt.builder.ts b/apps/backend/src/modules/agent/sql/sql-prompt.builder.ts +index de9cf9b..10c4ace 100644 +--- a/apps/backend/src/modules/agent/sql/sql-prompt.builder.ts ++++ b/apps/backend/src/modules/agent/sql/sql-prompt.builder.ts +@@ -1,36 +1,72 @@ + import { Injectable } from "@nestjs/common"; + import type { DatasourceType } from "@text2sql/shared-types"; + import type { LlmGatewayPrompt } from "../../llm/llm-gateway.interface"; ++import type { RagRetrievalChunkPayload } from "../../rag/retrieval/rag-retrieval.types"; + + const DIALECT_HINT: Record = { + sqlite: "SQLite", + mysql: "MySQL", + postgresql: "PostgreSQL", + excel: "SQLite-compatible", + csv: "SQLite-compatible" + }; + + @Injectable() + export class SqlPromptBuilder { +- build(question: string, datasourceType: DatasourceType = "sqlite"): LlmGatewayPrompt { ++ build( ++ question: string, ++ datasourceType: DatasourceType = "sqlite", ++ selectedContext?: RagRetrievalChunkPayload[], ++ options?: { ++ templateOverlay?: string; ++ } ++ ): LlmGatewayPrompt { + const dialect = DIALECT_HINT[datasourceType] ?? "SQLite"; + const tableHint = + datasourceType === "csv" || datasourceType === "excel" + ? "For file datasources, the default imported table name is usually `uploaded_data`." + : ""; ++ const contextBlock = this.buildContextBlock(selectedContext); ++ const overlayBlock = this.buildTemplateOverlay(options?.templateOverlay); + return { + systemPrompt: [ + `You are a senior SQL analyst for a ${dialect} datasource.`, + "Only produce read-only SQL queries.", + "Prefer SELECT or WITH ... SELECT statements.", + "Never generate INSERT/UPDATE/DELETE/DDL.", + tableHint, ++ overlayBlock, + "Respond in free text with explanation plus SQL in a markdown code block." + ].join(" "), + userPrompt: [ + `Question: ${question}`, ++ contextBlock, + "Return one best SQL query and a short explanation." +- ].join("\n") ++ ] ++ .filter((line) => line.trim().length > 0) ++ .join("\n") + }; + } ++ ++ private buildTemplateOverlay(templateOverlay?: string): string { ++ const normalized = templateOverlay?.trim(); ++ if (!normalized) { ++ return ""; ++ } ++ return `Runtime template overlay (higher priority guidance): ${normalized}`; ++ } ++ ++ private buildContextBlock(selectedContext?: RagRetrievalChunkPayload[]): string { ++ if (!selectedContext || selectedContext.length === 0) { ++ return ""; ++ } ++ const lines = selectedContext.slice(0, 5).map((item, index) => { ++ const domain = item.metadata.domain; ++ const chunkId = item.chunk_id; ++ const compact = item.content.replace(/\s+/g, " ").trim(); ++ const excerpt = compact.length > 260 ? `${compact.slice(0, 260)}...` : compact; ++ return `${index + 1}. [${domain}] ${chunkId}: ${excerpt}`; ++ }); ++ return ["Retrieved context (trusted evidence):", ...lines].join("\n"); ++ } + } +diff --git a/apps/backend/src/modules/agent/sql/tools/sql-safety.guard.ts b/apps/backend/src/modules/agent/sql/tools/sql-safety.guard.ts +index 4552e08..b877dcf 100644 +--- a/apps/backend/src/modules/agent/sql/tools/sql-safety.guard.ts ++++ b/apps/backend/src/modules/agent/sql/tools/sql-safety.guard.ts +@@ -22,21 +22,21 @@ export class SqlSafetyGuard { + "delete", + "drop", + "alter", + "truncate", + "create", + "replace", + "attach", + "pragma" + ]; + +- evaluate(sql: string): SqlSafetyDecision { ++ evaluate(sql: string, additionalRiskTags: string[] = []): SqlSafetyDecision { + const normalized = sql.trim().toLowerCase(); + if (!normalized.startsWith("select") && !normalized.startsWith("with")) { + return { + allowed: false, + mode: "hard-block", + riskLevel: "high", + riskTags: ["non_readonly_statement"], + reason: "Tool 仅允许执行只读 SQL(SELECT / WITH ... SELECT)。" + }; + } +@@ -73,20 +73,26 @@ export class SqlSafetyGuard { + } + if (/\bselect\s+\*/i.test(normalized)) { + riskTags.push("wide_projection"); + } + if (!/\blimit\s+\d+\b/i.test(normalized)) { + riskTags.push("unbounded_result"); + } + if (normalized.length > this.config.sqlSafetySoftWarnMaxLength) { + riskTags.push("long_sql"); + } ++ for (const tag of additionalRiskTags) { ++ const normalizedTag = tag.trim(); ++ if (normalizedTag && !riskTags.includes(normalizedTag)) { ++ riskTags.push(normalizedTag); ++ } ++ } + + if (riskTags.length > 0) { + return { + allowed: true, + mode: "soft-warn", + riskLevel: "medium", + riskTags + }; + } + +diff --git a/apps/backend/src/modules/chat/chat.controller.ts b/apps/backend/src/modules/chat/chat.controller.ts +index 90b4b2c..b0264a2 100644 +--- a/apps/backend/src/modules/chat/chat.controller.ts ++++ b/apps/backend/src/modules/chat/chat.controller.ts +@@ -132,20 +132,21 @@ export class ChatController { + try { + const run = await this.chatService.sendMessage( + sessionId, + body.message, + req.requestId + ); + return ok(req.requestId, { + kind: "agent-run", + outcome: run.status, + run, ++ delivery: run.delivery, + agent: { + provider: run.provider, + model: run.model, + hasSql: Boolean(run.sql), + hasToolCalls: Boolean(run.trace.toolCalls?.length), + hasError: Boolean(run.error) + } + }); + } catch (error) { + return this.toError(req.requestId, error, res); +diff --git a/apps/backend/src/modules/chat/chat.module.ts b/apps/backend/src/modules/chat/chat.module.ts +index b66959c..0b5a9f3 100644 +--- a/apps/backend/src/modules/chat/chat.module.ts ++++ b/apps/backend/src/modules/chat/chat.module.ts +@@ -1,16 +1,28 @@ + import { Module } from "@nestjs/common"; + import { AgentModule } from "../agent/agent.module"; + import { DataModule } from "../data/data.module"; + import { DatasourceModule } from "../datasource/datasource.module"; + import { LlmModule } from "../llm/llm.module"; + import { ObservabilityModule } from "../observability/observability.module"; ++import { RagModule } from "../rag/rag.module"; ++import { DeliveryContractMapper } from "../delivery/delivery-contract.mapper"; ++import { SandboxRuntimeService } from "../delivery/sandbox/sandbox-runtime.service"; ++import { MemoryModule } from "../memory/memory.module"; + import { ChatController } from "./chat.controller"; + import { ChatService } from "./chat.service"; + + @Module({ +- imports: [AgentModule, DataModule, DatasourceModule, LlmModule, ObservabilityModule], ++ imports: [ ++ AgentModule, ++ DataModule, ++ DatasourceModule, ++ LlmModule, ++ ObservabilityModule, ++ RagModule, ++ MemoryModule ++ ], + controllers: [ChatController], +- providers: [ChatService], ++ providers: [ChatService, DeliveryContractMapper, SandboxRuntimeService], + exports: [ChatService] + }) + export class ChatModule {} +diff --git a/apps/backend/src/modules/chat/chat.service.ts b/apps/backend/src/modules/chat/chat.service.ts +index c32a0d6..891cc35 100644 +--- a/apps/backend/src/modules/chat/chat.service.ts ++++ b/apps/backend/src/modules/chat/chat.service.ts +@@ -1,58 +1,68 @@ + import { Injectable } from "@nestjs/common"; + import type { + ChatStreamEvent, + ChatSessionView, + ChatMessage, + Datasource, ++ PromptTemplateTraceEvidenceCompat, + ReasoningStage, + Session, + SessionSyncStatus, + SqlRun + } from "@text2sql/shared-types"; + import { v4 as uuidv4 } from "uuid"; + import { DomainError } from "../../common/domain-error"; + import { GraphBuilderService } from "../agent/graph/graph.builder"; + import { SqlToolRegistryService } from "../agent/sql/tools/sql-tool-registry.service"; ++import { ++ DeliveryContractMapper, ++ type DeliveryReplayRecordInput ++} from "../delivery/delivery-contract.mapper"; + import { + type AccessContext + } from "../auth/datasource-access-policy.service"; + import { PolicyEvaluatorService } from "../auth/policy-evaluator.service"; + import { RedisBufferService } from "../data/cache/redis-buffer.service"; + import { ChatRepository } from "../data/persistence/chat.repository"; + import { WorkspaceDatasourcePolicyRepository } from "../data/persistence/workspace-datasource-policy.repository"; + import { DatasourceRegistryService } from "../datasource/datasource-registry.service"; + import { ProviderCatalogService } from "../llm/provider-catalog.service"; + import { ProviderRouterService } from "../llm/provider-router.service"; + import { TraceService } from "../observability/trace.service"; ++import { RagReplayRepository } from "../rag/observability/rag-replay.repository"; + import { DatasourceService } from "../datasource/datasource.service"; ++import { MemoryPromotionService } from "../memory/memory-promotion.service"; + import type { SessionListView } from "./dto/list-sessions.dto"; + + const MODEL_PROBE_PROMPT = { + systemPrompt: "You are a health check assistant. Reply with exactly OK.", + userPrompt: "Reply with OK." + }; + + @Injectable() + export class ChatService { + constructor( + private readonly graphBuilder: GraphBuilderService, + private readonly sqlToolRegistry: SqlToolRegistryService, + private readonly redisBuffer: RedisBufferService, + private readonly repository: ChatRepository, + private readonly datasourceService: DatasourceService, + private readonly policyEvaluatorService: PolicyEvaluatorService, + private readonly workspaceDatasourcePolicyRepository: WorkspaceDatasourcePolicyRepository, + private readonly datasourceRegistry: DatasourceRegistryService, + private readonly providerCatalog: ProviderCatalogService, + private readonly providerRouter: ProviderRouterService, +- private readonly traceService: TraceService ++ private readonly traceService: TraceService, ++ private readonly ragReplayRepository: RagReplayRepository, ++ private readonly deliveryContractMapper: DeliveryContractMapper, ++ private readonly memoryPromotionService: MemoryPromotionService + ) {} + + async createSession( + datasource: string, + modelCatalogId?: string, + options?: { + workspaceId?: string; + createdByUserId?: string; + actor?: { + id?: string; +@@ -392,26 +402,28 @@ export class ChatService { + modelCatalogId: session.modelCatalogId ?? undefined, + accessContext: sqlAccessContext, + traceContext: { + source: "chat", + route: "/api/v1/sessions/:sessionId/messages", + requestId + } + }); + + const finalRun = this.applyRejectedFallback(run, session.datasource); ++ const runWithDelivery = await this.attachDeliveryContract(finalRun); + await this.persistAssistantAndRun( + sessionId, +- finalRun, ++ runWithDelivery, + userPersistResult.primaryPersisted + ); +- return finalRun; ++ await this.triggerMemoryPromotion(session, runWithDelivery, requestId); ++ return runWithDelivery; + } + + async streamMessage( + sessionId: string, + message: string, + requestId: string | undefined, + onEvent: (event: ChatStreamEvent) => Promise | void + ): Promise { + const session = await this.repository.getSessionById(sessionId); + if (!session) { +@@ -547,37 +559,41 @@ export class ChatService { + outputSummary: step.outputSummary, + errorSummary: step.errorSummary + }); + } + } + ); + + const finalRun = this.applyRejectedFallback(run, session.datasource); + finalRun.trace.streamStatus = finalRun.error ? "failed" : "completed"; + finalRun.trace.toolCalls = toolCalls; +- if (finalRun.error) { ++ const runWithDelivery = await this.attachDeliveryContract(finalRun); ++ if (runWithDelivery.error) { + await emit("error", { +- code: finalRun.status === "rejected" ? "SQL_READONLY_REJECTED" : undefined, +- message: finalRun.error, ++ code: ++ runWithDelivery.status === "rejected" ? "SQL_READONLY_REJECTED" : undefined, ++ message: runWithDelivery.error, + details: null + }); + } + await emit("finish", { +- status: finalRun.status, +- rowCount: finalRun.rows?.length ?? 0 ++ status: runWithDelivery.status, ++ rowCount: runWithDelivery.rows?.length ?? 0, ++ delivery: runWithDelivery.delivery + }); + await this.persistAssistantAndRun( + sessionId, +- finalRun, ++ runWithDelivery, + userPersistResult.primaryPersisted + ); +- return finalRun; ++ await this.triggerMemoryPromotion(session, runWithDelivery, requestId); ++ return runWithDelivery; + } catch (error) { + const messageText = error instanceof Error ? error.message : String(error); + const run: SqlRun = { + runId, + sessionId, + question: message, + status: "failed", + provider: session.modelProvider ?? "unknown", + model: session.modelName ?? undefined, + error: messageText, +@@ -592,22 +608,28 @@ export class ChatService { + llmRaw: null, + createdAt: new Date().toISOString() + }; + const domainError = + error instanceof DomainError ? error : undefined; + await emit("error", { + code: domainError?.code, + message: messageText, + details: (domainError?.details as Record | undefined) ?? null + }); +- await this.persistAssistantAndRun(sessionId, run, userPersistResult.primaryPersisted); +- return run; ++ const runWithDelivery = await this.attachDeliveryContract(run); ++ await this.persistAssistantAndRun( ++ sessionId, ++ runWithDelivery, ++ userPersistResult.primaryPersisted ++ ); ++ await this.triggerMemoryPromotion(session, runWithDelivery, requestId); ++ return runWithDelivery; + } + } + + async listMessages( + sessionId: string, + page = 1, + pageSize = 0 + ): Promise { + const session = await this.repository.getSessionById(sessionId); + if (!session) { +@@ -628,38 +650,41 @@ export class ChatService { + async getSessionView( + sessionId: string, + page = 1, + pageSize = 0 + ): Promise { + const session = await this.repository.getSessionById(sessionId); + if (!session) { + throw new DomainError("SESSION_NOT_FOUND", "会话不存在", 404, { sessionId }); + } + const messages = await this.listMessages(sessionId, page, pageSize); +- const latestRun = await this.repository.getLatestRunBySessionId(sessionId); ++ const latestRunRaw = await this.repository.getLatestRunBySessionId(sessionId); ++ const latestRun = latestRunRaw ++ ? await this.attachDeliveryContract(latestRunRaw) ++ : undefined; + return { + session: await this.mergeDatasourceMetadata(session), + messages, + latestRun + }; + } + + async getRunById(runId: string): Promise { + const run = await this.repository.getRunById(runId); + if (!run) { + throw new DomainError("RUN_NOT_FOUND", "运行记录不存在", 404, { runId }); + } + const session = await this.repository.getSessionById(run.sessionId); + if (!session) { + throw new DomainError("RUN_NOT_FOUND", "运行记录不存在", 404, { runId }); + } +- return run; ++ return this.attachDeliveryContract(run); + } + + private async assertSessionWritableByPolicy(session: Session): Promise { + const workspaceId = session.workspaceId?.trim(); + if (!workspaceId) { + return; + } + const stillBound = + await this.workspaceDatasourcePolicyRepository.isDatasourceBound( + workspaceId, +@@ -870,11 +895,202 @@ export class ChatService { + } + if (run.answer) { + return run; + } + return { + ...run, + answer: + "请求触发了只读安全策略,本次未执行 SQL。你可以改为查询统计口径或时间范围,我会继续协助。" + }; + } ++ ++ private async attachDeliveryContract(run: SqlRun): Promise { ++ try { ++ const replayRecords = await this.loadReplayRecords(run.runId); ++ const delivery = this.deliveryContractMapper.map({ ++ run, ++ replayRecords ++ }); ++ return this.withPromptTemplateEvidence({ ++ ...run, ++ delivery ++ }); ++ } catch { ++ return this.withPromptTemplateEvidence({ ++ ...run, ++ delivery: this.deliveryContractMapper.buildFallback( ++ run, ++ "delivery_mapper_failed" ++ ) ++ }); ++ } ++ } ++ ++ private withPromptTemplateEvidence(run: SqlRun): SqlRun { ++ const traceWithCompat = run.trace as SqlRun["trace"] & { ++ prompt_template?: unknown; ++ prompt_template_evidence?: unknown; ++ templateEvidence?: unknown; ++ }; ++ const tracePromptTemplate = this.normalizePromptTemplateTraceEvidence( ++ traceWithCompat.promptTemplate ?? ++ traceWithCompat.prompt_template ?? ++ traceWithCompat.prompt_template_evidence ?? ++ traceWithCompat.templateEvidence ++ ); ++ const evidencePromptTemplate = this.normalizePromptTemplateTraceEvidence( ++ run.delivery?.evidence?.promptTemplate ++ ); ++ const resolvedPromptTemplate = evidencePromptTemplate ?? tracePromptTemplate; ++ ++ const normalizedTrace = resolvedPromptTemplate ++ ? { ++ ...run.trace, ++ promptTemplate: resolvedPromptTemplate ++ } ++ : run.trace; ++ ++ if (!run.delivery) { ++ return normalizedTrace === run.trace ? run : { ...run, trace: normalizedTrace }; ++ } ++ ++ const nextEvidence = ++ run.delivery.evidence || resolvedPromptTemplate ++ ? { ++ runId: run.delivery.evidence?.runId ?? run.runId, ++ ...run.delivery.evidence, ++ ...(resolvedPromptTemplate ++ ? { ++ promptTemplate: resolvedPromptTemplate ++ } ++ : {}) ++ } ++ : run.delivery.evidence; ++ ++ return { ++ ...run, ++ trace: normalizedTrace, ++ delivery: { ++ ...run.delivery, ++ ...(nextEvidence ? { evidence: nextEvidence } : {}) ++ } ++ }; ++ } ++ ++ private normalizePromptTemplateTraceEvidence( ++ value: unknown ++ ): SqlRun["trace"]["promptTemplate"] | undefined { ++ if (!this.isRecord(value)) { ++ return undefined; ++ } ++ const candidate = value as PromptTemplateTraceEvidenceCompat; ++ const templateId = this.readNonEmptyString(candidate.templateId ?? candidate.template_id); ++ const scope = this.normalizePromptTemplateScope( ++ candidate.scope ?? ++ candidate.scope_type ?? ++ candidate.template_scope ?? ++ (this.isRecord(value) ? (value.scopeType as unknown) : undefined) ++ ); ++ const version = this.readPositiveInteger( ++ candidate.version ?? ++ candidate.template_version ?? ++ (this.isRecord(value) ? (value.templateVersion as unknown) : undefined) ++ ); ++ const fallbackReason = this.readNonEmptyString( ++ candidate.fallbackReason ?? ++ candidate.fallback_reason ?? ++ (this.isRecord(value) ? (value.fallback_reason_code as unknown) : undefined) ++ ); ++ const scene = this.normalizePromptTemplateScene( ++ candidate.scene ?? candidate.scene_name ?? candidate.template_scene ++ ); ++ ++ if (!templateId && !scope && version === undefined && !fallbackReason && !scene) { ++ return undefined; ++ } ++ ++ return { ++ ...(templateId ? { templateId } : {}), ++ ...(scene ? { scene } : {}), ++ ...(scope ? { scope } : {}), ++ ...(version !== undefined ? { version } : {}), ++ ...(fallbackReason ? { fallbackReason } : {}) ++ }; ++ } ++ ++ private normalizePromptTemplateScope( ++ raw: unknown ++ ): "global" | "workspace" | "datasource" | undefined { ++ const normalized = this.readNonEmptyString(raw)?.toLowerCase(); ++ if ( ++ normalized === "global" || ++ normalized === "workspace" || ++ normalized === "datasource" ++ ) { ++ return normalized; ++ } ++ return undefined; ++ } ++ ++ private normalizePromptTemplateScene(raw: unknown): "sql" | "analysis" | undefined { ++ const normalized = this.readNonEmptyString(raw)?.toLowerCase(); ++ if (normalized === "sql" || normalized === "analysis") { ++ return normalized; ++ } ++ if (normalized === "sql_generation") { ++ return "sql"; ++ } ++ return undefined; ++ } ++ ++ private readPositiveInteger(raw: unknown): number | undefined { ++ if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) { ++ return Math.floor(raw); ++ } ++ if (typeof raw === "string" && raw.trim().length > 0) { ++ const parsed = Number(raw); ++ if (Number.isFinite(parsed) && parsed > 0) { ++ return Math.floor(parsed); ++ } ++ } ++ return undefined; ++ } ++ ++ private readNonEmptyString(raw: unknown): string | undefined { ++ if (typeof raw !== "string") { ++ return undefined; ++ } ++ const trimmed = raw.trim(); ++ return trimmed.length > 0 ? trimmed : undefined; ++ } ++ ++ private isRecord(value: unknown): value is Record { ++ return typeof value === "object" && value !== null; ++ } ++ ++ private async loadReplayRecords(runId: string): Promise { ++ const records = await this.ragReplayRepository.listByRunId(runId); ++ return records.map((item) => ({ ++ replayKey: item.replayKey, ++ stage: item.stage, ++ indexVersionId: item.indexVersionId, ++ payload: item.payload, ++ createdAt: item.createdAt ++ })); ++ } ++ ++ private async triggerMemoryPromotion( ++ session: Session, ++ run: SqlRun, ++ requestId?: string ++ ): Promise { ++ try { ++ await this.memoryPromotionService.promoteFromRun({ ++ run, ++ datasourceId: session.datasource, ++ requestId ++ }); ++ } catch { ++ // memory promotion is additive and must not interrupt chat completion. ++ } ++ } + } +diff --git a/apps/backend/src/modules/data/persistence/chat.repository.ts b/apps/backend/src/modules/data/persistence/chat.repository.ts +index af649d9..d986c43 100644 +--- a/apps/backend/src/modules/data/persistence/chat.repository.ts ++++ b/apps/backend/src/modules/data/persistence/chat.repository.ts +@@ -1,14 +1,15 @@ + import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; + import type { + ChatMessage, + EvaluationReport, ++ PromptTemplateTraceEvidenceCompat, + Session, + SessionSyncStatus, + SqlRun + } from "@text2sql/shared-types"; + import { AppConfigService } from "../../config/app-config.service"; + + type PrismaClientLike = { + session: { + create: (args: Record) => Promise; + findUnique: (args: Record) => Promise; +@@ -471,60 +472,68 @@ export class ChatRepository implements OnModuleInit, OnModuleDestroy { + role: row.role, + content: row.content, + metadata: row.metadata + ? (JSON.parse(row.metadata) as Record) + : undefined, + createdAt: row.createdAt.toISOString() + })); + } + + async persistRun(run: SqlRun): Promise { +- this.runs.set(run.runId, run); ++ const normalizedRun: SqlRun = { ++ ...run, ++ trace: this.normalizeTrace(run.trace, run.runId, run.provider) ++ }; ++ this.runs.set(normalizedRun.runId, normalizedRun); + if (!this.isPrimaryPersistenceConfigured() || !this.prisma) { + return; + } + await this.tryPrismaWrite(async () => { + await this.prisma?.sqlRun.upsert({ +- where: { runId: run.runId }, ++ where: { runId: normalizedRun.runId }, + update: { +- status: run.status, +- provider: run.provider, +- model: run.model ?? null, +- question: run.question, +- sql: run.sql ?? null, +- explanation: run.explanation ?? null, +- answer: run.answer ?? null, +- columns: run.columns ? JSON.stringify(run.columns) : null, +- rows: run.rows ? JSON.stringify(run.rows) : null, +- error: run.error ?? null, +- clarification: run.clarification ? JSON.stringify(run.clarification) : null, +- trace: JSON.stringify(run.trace), +- llmRaw: run.llmRaw ? JSON.stringify(run.llmRaw) : null, +- createdAt: new Date(run.createdAt) ++ status: normalizedRun.status, ++ provider: normalizedRun.provider, ++ model: normalizedRun.model ?? null, ++ question: normalizedRun.question, ++ sql: normalizedRun.sql ?? null, ++ explanation: normalizedRun.explanation ?? null, ++ answer: normalizedRun.answer ?? null, ++ columns: normalizedRun.columns ? JSON.stringify(normalizedRun.columns) : null, ++ rows: normalizedRun.rows ? JSON.stringify(normalizedRun.rows) : null, ++ error: normalizedRun.error ?? null, ++ clarification: normalizedRun.clarification ++ ? JSON.stringify(normalizedRun.clarification) ++ : null, ++ trace: JSON.stringify(normalizedRun.trace), ++ llmRaw: normalizedRun.llmRaw ? JSON.stringify(normalizedRun.llmRaw) : null, ++ createdAt: new Date(normalizedRun.createdAt) + }, + create: { +- runId: run.runId, +- sessionId: run.sessionId, +- status: run.status, +- provider: run.provider, +- model: run.model ?? null, +- question: run.question, +- sql: run.sql ?? null, +- explanation: run.explanation ?? null, +- answer: run.answer ?? null, +- columns: run.columns ? JSON.stringify(run.columns) : null, +- rows: run.rows ? JSON.stringify(run.rows) : null, +- error: run.error ?? null, +- clarification: run.clarification ? JSON.stringify(run.clarification) : null, +- trace: JSON.stringify(run.trace), +- llmRaw: run.llmRaw ? JSON.stringify(run.llmRaw) : null, +- createdAt: new Date(run.createdAt) ++ runId: normalizedRun.runId, ++ sessionId: normalizedRun.sessionId, ++ status: normalizedRun.status, ++ provider: normalizedRun.provider, ++ model: normalizedRun.model ?? null, ++ question: normalizedRun.question, ++ sql: normalizedRun.sql ?? null, ++ explanation: normalizedRun.explanation ?? null, ++ answer: normalizedRun.answer ?? null, ++ columns: normalizedRun.columns ? JSON.stringify(normalizedRun.columns) : null, ++ rows: normalizedRun.rows ? JSON.stringify(normalizedRun.rows) : null, ++ error: normalizedRun.error ?? null, ++ clarification: normalizedRun.clarification ++ ? JSON.stringify(normalizedRun.clarification) ++ : null, ++ trace: JSON.stringify(normalizedRun.trace), ++ llmRaw: normalizedRun.llmRaw ? JSON.stringify(normalizedRun.llmRaw) : null, ++ createdAt: new Date(normalizedRun.createdAt) + } + }); + }); + } + + async getRunById(runId: string): Promise { + const memory = this.runs.get(runId); + if (memory) { + return memory; + } +@@ -737,45 +746,150 @@ export class ChatRepository implements OnModuleInit, OnModuleDestroy { + llmRaw: this.parseJsonSafely(row.llmRaw) ?? null, + createdAt: row.createdAt.toISOString() + }; + } + + private normalizeTrace( + trace: SqlRun["trace"], + runId: string, + provider: string + ): SqlRun["trace"] { ++ const traceWithPlannerMetadata = trace as SqlRun["trace"] & { ++ plannerCache?: Record; ++ plannerReplay?: Record; ++ prompt_template?: unknown; ++ prompt_template_evidence?: unknown; ++ templateEvidence?: unknown; ++ }; ++ const { ++ promptTemplate: canonicalPromptTemplate, ++ prompt_template: legacySnakePromptTemplate, ++ prompt_template_evidence: legacyEvidencePromptTemplate, ++ templateEvidence: legacyTemplateEvidence, ++ ...traceWithoutPromptTemplateAliases ++ } = traceWithPlannerMetadata; ++ const normalizedPromptTemplate = this.normalizePromptTemplateTraceEvidence( ++ canonicalPromptTemplate ?? ++ legacySnakePromptTemplate ?? ++ legacyEvidencePromptTemplate ?? ++ legacyTemplateEvidence ++ ); + const normalizedSteps = (trace.steps ?? []).map((step, index) => { + const sequence = step.sequence ?? index + 1; + return { + ...step, + sequence, + stepId: step.stepId ?? `${runId}:${step.node}:${sequence}`, + lifecycle: + step.lifecycle ?? + (step.status === "failed" + ? "failed" + : step.status === "skipped" + ? "skipped" + : "completed") + }; + }); + + return { +- ...trace, ++ ...traceWithoutPromptTemplateAliases, + runId: trace.runId ?? runId, + provider: trace.provider ?? provider, + retryCount: trace.retryCount ?? 0, +- steps: normalizedSteps ++ steps: normalizedSteps, ++ ...(normalizedPromptTemplate ++ ? { promptTemplate: normalizedPromptTemplate } ++ : {}) ++ }; ++ } ++ ++ private normalizePromptTemplateTraceEvidence( ++ value: unknown ++ ): SqlRun["trace"]["promptTemplate"] | undefined { ++ if (!this.isRecord(value)) { ++ return undefined; ++ } ++ const candidate = value as PromptTemplateTraceEvidenceCompat; ++ const templateId = this.readNonEmptyString(candidate.templateId ?? candidate.template_id); ++ const scope = this.normalizePromptTemplateScope( ++ candidate.scope ?? candidate.scope_type ?? candidate.template_scope ++ ); ++ const version = this.readPositiveInteger(candidate.version ?? candidate.template_version); ++ const fallbackReason = this.readNonEmptyString( ++ candidate.fallbackReason ?? candidate.fallback_reason ++ ); ++ const scene = this.normalizePromptTemplateScene( ++ candidate.scene ?? candidate.scene_name ?? candidate.template_scene ++ ); ++ ++ if (!templateId && !scope && version === undefined && !fallbackReason && !scene) { ++ return undefined; ++ } ++ ++ return { ++ ...(templateId ? { templateId } : {}), ++ ...(scene ? { scene } : {}), ++ ...(scope ? { scope } : {}), ++ ...(version !== undefined ? { version } : {}), ++ ...(fallbackReason ? { fallbackReason } : {}) + }; + } + ++ private normalizePromptTemplateScope( ++ raw: unknown ++ ): "global" | "workspace" | "datasource" | undefined { ++ const normalized = this.readNonEmptyString(raw)?.toLowerCase(); ++ if ( ++ normalized === "global" || ++ normalized === "workspace" || ++ normalized === "datasource" ++ ) { ++ return normalized; ++ } ++ return undefined; ++ } ++ ++ private normalizePromptTemplateScene(raw: unknown): "sql" | "analysis" | undefined { ++ const normalized = this.readNonEmptyString(raw)?.toLowerCase(); ++ if (normalized === "sql" || normalized === "analysis") { ++ return normalized; ++ } ++ if (normalized === "sql_generation") { ++ return "sql"; ++ } ++ return undefined; ++ } ++ ++ private readPositiveInteger(raw: unknown): number | undefined { ++ if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) { ++ return Math.floor(raw); ++ } ++ if (typeof raw === "string" && raw.trim().length > 0) { ++ const parsed = Number(raw); ++ if (Number.isFinite(parsed) && parsed > 0) { ++ return Math.floor(parsed); ++ } ++ } ++ return undefined; ++ } ++ ++ private readNonEmptyString(raw: unknown): string | undefined { ++ if (typeof raw !== "string") { ++ return undefined; ++ } ++ const trimmed = raw.trim(); ++ return trimmed.length > 0 ? trimmed : undefined; ++ } ++ ++ private isRecord(value: unknown): value is Record { ++ return typeof value === "object" && value !== null; ++ } ++ + private toSyncStatus(value: string): SessionSyncStatus { + if (value === "pending" || value === "degraded") { + return value; + } + return "healthy"; + } + + private filterAndSortSessions( + sessions: Session[], + includeDeleted: boolean, +diff --git a/apps/backend/src/modules/delivery/delivery-contract.mapper.ts b/apps/backend/src/modules/delivery/delivery-contract.mapper.ts +new file mode 100644 +index 0000000..024cfa4 +--- /dev/null ++++ b/apps/backend/src/modules/delivery/delivery-contract.mapper.ts +@@ -0,0 +1,570 @@ ++import { Injectable } from "@nestjs/common"; ++import type { ++ DeliveryContract, ++ DeliveryEvidenceReplayLog, ++ SqlRun ++} from "@text2sql/shared-types"; ++import { ++ DELIVERY_SANDBOX_REPLAY_KEY, ++ SandboxRuntimeService, ++ type SandboxPostProcessOperation, ++ type SandboxPostProcessRequest ++} from "./sandbox/sandbox-runtime.service"; ++ ++export interface DeliveryReplayRecordInput { ++ replayKey: string; ++ stage: string; ++ indexVersionId?: string; ++ payload?: string; ++ createdAt: string; ++} ++ ++export interface DeliveryContractMapperInput { ++ run: SqlRun; ++ replayRecords?: DeliveryReplayRecordInput[]; ++} ++ ++interface RerankFinalSnapshot { ++ status: "ready" | "degraded"; ++ degradeReasons: string[]; ++ selectedContextCount?: number; ++ riskTags: string[]; ++} ++ ++interface RetrievalFusedSnapshot { ++ status: "ready" | "degraded"; ++ candidates: Array<{ ++ chunkId: string; ++ sourceLane?: string; ++ domain?: string; ++ }>; ++ skillContextSummary?: { ++ skillCount: number; ++ contextCount: number; ++ degradeReason?: string; ++ }; ++} ++ ++interface SemanticSnapshot { ++ semanticVersion?: number; ++ semanticLockStatus?: "locked" | "fallback" | "degraded"; ++ semanticDegradeReason?: string; ++} ++ ++interface SandboxPostProcessOutcome { ++ artifact?: DeliveryContract["artifact"]; ++ riskTags: string[]; ++} ++ ++@Injectable() ++export class DeliveryContractMapper { ++ constructor(private readonly sandboxRuntime: SandboxRuntimeService) {} ++ ++ map(input: DeliveryContractMapperInput): DeliveryContract { ++ const answer = this.buildAnswer(input.run); ++ const replayLogs = this.toReplayLogs(input.replayRecords); ++ const replayIndex = this.indexReplayRecords(input.replayRecords); ++ ++ const finalSnapshot = this.readRerankFinalSnapshot(replayIndex.rerankFinal); ++ const fusedSnapshot = this.readRetrievalFusedSnapshot(replayIndex.retrievalFused); ++ const semanticSnapshot = this.readSemanticSnapshot(input.run); ++ const invalidInput = replayIndex.invalidPayload; ++ const artifact = this.buildArtifact(input.run); ++ const sandboxOutcome = this.applySandboxPostProcess({ ++ artifact, ++ sandboxPayload: replayIndex.sandboxPostprocess, ++ sandboxPayloadInvalid: replayIndex.sandboxPayloadInvalid ++ }); ++ ++ const evidenceRiskTags = this.unique([ ++ ...finalSnapshot.riskTags, ++ ...(invalidInput ? ["delivery_input_invalid"] : []), ++ ...sandboxOutcome.riskTags ++ ]); ++ ++ const selectedContextCount = ++ finalSnapshot.selectedContextCount ?? ++ (fusedSnapshot.candidates.length > 0 ++ ? Math.min(3, fusedSnapshot.candidates.length) ++ : undefined); ++ ++ const snippets = fusedSnapshot.candidates ++ .slice(0, 3) ++ .map((candidate) => this.formatSnippet(candidate)) ++ .filter((item): item is string => Boolean(item)); ++ ++ const hasIndexSnapshot = replayLogs.some((item) => Boolean(item.indexVersionId)); ++ const evidenceStale = Boolean( ++ selectedContextCount && selectedContextCount > 0 && !hasIndexSnapshot ++ ); ++ ++ const hasReplayData = Boolean(replayIndex.rerankFinal || replayIndex.retrievalFused); ++ const derivedRetrievalStatus = hasReplayData ++ ? (finalSnapshot.status ?? fusedSnapshot.status) ++ : (input.run.error ? "degraded" : undefined); ++ ++ const evidence = { ++ runId: input.run.runId, ++ retrievalStatus: derivedRetrievalStatus, ++ degradeReasons: finalSnapshot.degradeReasons, ++ selectedContext: ++ selectedContextCount !== undefined ++ ? { ++ count: selectedContextCount, ++ snippets: snippets.length > 0 ? snippets : undefined ++ } ++ : undefined, ++ retrievalLogs: replayLogs.length > 0 ? replayLogs : undefined, ++ riskTags: evidenceRiskTags.length > 0 ? evidenceRiskTags : undefined, ++ semanticVersion: semanticSnapshot.semanticVersion, ++ semanticLockStatus: semanticSnapshot.semanticLockStatus, ++ semanticDegradeReason: semanticSnapshot.semanticDegradeReason, ++ skillContextSummary: fusedSnapshot.skillContextSummary, ++ evidenceStale: evidenceStale || undefined ++ } satisfies DeliveryContract["evidence"]; ++ ++ return { ++ answer, ++ evidence, ++ artifact: sandboxOutcome.artifact ?? artifact ++ }; ++ } ++ ++ buildFallback(run: SqlRun, riskTag: string): DeliveryContract { ++ const artifact = this.buildArtifact(run); ++ return { ++ answer: this.buildAnswer(run), ++ evidence: { ++ runId: run.runId, ++ riskTags: this.unique([riskTag]) ++ }, ++ artifact ++ }; ++ } ++ ++ private buildAnswer(run: SqlRun): DeliveryContract["answer"] { ++ return { ++ text: run.answer ?? run.error ?? "系统未返回结果。", ++ status: run.status, ++ provider: run.provider, ++ model: run.model ++ }; ++ } ++ ++ private buildArtifact(run: SqlRun): DeliveryContract["artifact"] | undefined { ++ const hasArtifact = Boolean( ++ run.sql || (run.columns?.length ?? 0) > 0 || (run.rows?.length ?? 0) > 0 || run.error ++ ); ++ if (!hasArtifact) { ++ return undefined; ++ } ++ return { ++ sql: run.sql, ++ columns: run.columns, ++ rowCount: run.rows?.length ?? 0, ++ rowsPreview: run.rows?.slice(0, 3), ++ hasError: Boolean(run.error) ++ }; ++ } ++ ++ private toReplayLogs(records: DeliveryReplayRecordInput[] | undefined): DeliveryEvidenceReplayLog[] { ++ if (!records || records.length === 0) { ++ return []; ++ } ++ return records ++ .map((item) => ({ ++ replayKey: item.replayKey, ++ stage: item.stage, ++ indexVersionId: item.indexVersionId, ++ createdAt: item.createdAt ++ })) ++ .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); ++ } ++ ++ private indexReplayRecords(records: DeliveryReplayRecordInput[] | undefined): { ++ rerankFinal?: Record; ++ retrievalFused?: Record; ++ sandboxPostprocess?: Record; ++ invalidPayload: boolean; ++ sandboxPayloadInvalid: boolean; ++ } { ++ if (!records || records.length === 0) { ++ return { ++ invalidPayload: false, ++ sandboxPayloadInvalid: false ++ }; ++ } ++ ++ let invalidPayload = false; ++ let sandboxPayloadInvalid = false; ++ let rerankFinal: Record | undefined; ++ let retrievalFused: Record | undefined; ++ let sandboxPostprocess: Record | undefined; ++ ++ for (const item of records) { ++ if (!item.payload) { ++ continue; ++ } ++ const parsed = this.parsePayload(item.payload); ++ if (!parsed) { ++ invalidPayload = true; ++ if (item.replayKey === DELIVERY_SANDBOX_REPLAY_KEY) { ++ sandboxPayloadInvalid = true; ++ } ++ continue; ++ } ++ if (item.replayKey === "rerank:final") { ++ rerankFinal = parsed; ++ } ++ if (item.replayKey === "retrieval:fused") { ++ retrievalFused = parsed; ++ } ++ if (item.replayKey === DELIVERY_SANDBOX_REPLAY_KEY) { ++ sandboxPostprocess = parsed; ++ } ++ } ++ ++ return { ++ rerankFinal, ++ retrievalFused, ++ sandboxPostprocess, ++ invalidPayload, ++ sandboxPayloadInvalid ++ }; ++ } ++ ++ private applySandboxPostProcess(input: { ++ artifact: DeliveryContract["artifact"]; ++ sandboxPayload: Record | undefined; ++ sandboxPayloadInvalid: boolean; ++ }): SandboxPostProcessOutcome { ++ if (!input.artifact) { ++ return { ++ artifact: undefined, ++ riskTags: input.sandboxPayloadInvalid ? ["sandbox_failed", "sandbox_payload_invalid"] : [] ++ }; ++ } ++ ++ if (input.sandboxPayloadInvalid) { ++ return { ++ artifact: input.artifact, ++ riskTags: ["sandbox_failed", "sandbox_payload_invalid"] ++ }; ++ } ++ ++ if (!input.sandboxPayload) { ++ return { ++ artifact: input.artifact, ++ riskTags: [] ++ }; ++ } ++ ++ const request = this.readSandboxRequest(input.sandboxPayload); ++ if (!request) { ++ return { ++ artifact: input.artifact, ++ riskTags: ["sandbox_failed", "sandbox_payload_invalid"] ++ }; ++ } ++ ++ try { ++ const result = this.sandboxRuntime.executeArtifactPostProcess({ ++ artifact: input.artifact, ++ request ++ }); ++ if (!result.ok) { ++ return { ++ artifact: input.artifact, ++ riskTags: this.unique(["sandbox_failed", ...result.riskTags]) ++ }; ++ } ++ return { ++ artifact: result.artifact, ++ riskTags: [] ++ }; ++ } catch { ++ return { ++ artifact: input.artifact, ++ riskTags: ["sandbox_failed", "sandbox_runtime_failed"] ++ }; ++ } ++ } ++ ++ private readRerankFinalSnapshot( ++ payload: Record | undefined ++ ): RerankFinalSnapshot { ++ if (!payload) { ++ return { ++ status: "ready", ++ degradeReasons: [], ++ riskTags: [] ++ }; ++ } ++ ++ const statusRaw = payload.status; ++ const status = statusRaw === "degraded" ? "degraded" : "ready"; ++ const degradeReasons = this.readStringArray(payload.degradeReasons); ++ const riskTags = this.readStringArray(payload.riskTags); ++ const selectedContextCountRaw = payload.selectedContextCount; ++ const selectedContextCount = ++ typeof selectedContextCountRaw === "number" && ++ Number.isFinite(selectedContextCountRaw) && ++ selectedContextCountRaw >= 0 ++ ? Math.floor(selectedContextCountRaw) ++ : undefined; ++ ++ return { ++ status, ++ degradeReasons, ++ selectedContextCount, ++ riskTags ++ }; ++ } ++ ++ private readRetrievalFusedSnapshot( ++ payload: Record | undefined ++ ): RetrievalFusedSnapshot { ++ if (!payload) { ++ return { ++ status: "ready", ++ candidates: [] ++ }; ++ } ++ ++ const statusRaw = payload.status; ++ const status = statusRaw === "degraded" ? "degraded" : "ready"; ++ const candidatesRaw = Array.isArray(payload.candidates) ? payload.candidates : []; ++ const candidates = candidatesRaw ++ .map((item) => { ++ if (!this.isRecord(item)) { ++ return undefined; ++ } ++ const chunkId = this.readString(item.chunkId); ++ if (!chunkId) { ++ return undefined; ++ } ++ return { ++ chunkId, ++ sourceLane: this.readString(item.sourceLane), ++ domain: this.readString(item.domain) ++ }; ++ }) ++ .filter((item): item is NonNullable => Boolean(item)); ++ const skillContextSummary = this.readSkillContextSummary(payload.skillContext); ++ ++ return { ++ status, ++ candidates, ++ skillContextSummary ++ }; ++ } ++ ++ private readSemanticSnapshot(run: SqlRun): SemanticSnapshot { ++ const semanticSteps = [...(run.trace.steps ?? [])] ++ .reverse() ++ .filter( ++ (step) => ++ step.node === "build-semantic-query" || step.node === "build-physical-plan" ++ ); ++ ++ for (const step of semanticSteps) { ++ const output = this.parseSummaryObject(step.outputSummary); ++ if (!output) { ++ continue; ++ } ++ const semanticVersionRaw = output.semanticVersion; ++ const semanticVersion = ++ typeof semanticVersionRaw === "number" && ++ Number.isFinite(semanticVersionRaw) && ++ semanticVersionRaw > 0 ++ ? Math.floor(semanticVersionRaw) ++ : undefined; ++ const lockStatus = this.readString(output.lockStatus); ++ const semanticLockStatus = ++ lockStatus === "locked" || lockStatus === "fallback" || lockStatus === "degraded" ++ ? lockStatus ++ : undefined; ++ const semanticDegradeReason = this.readString(output.degradeReason); ++ ++ if (semanticVersion || semanticLockStatus || semanticDegradeReason) { ++ return { ++ semanticVersion, ++ semanticLockStatus, ++ semanticDegradeReason ++ }; ++ } ++ } ++ ++ return {}; ++ } ++ ++ private readSkillContextSummary( ++ value: unknown ++ ): RetrievalFusedSnapshot["skillContextSummary"] { ++ if (!this.isRecord(value)) { ++ return undefined; ++ } ++ const skills = Array.isArray(value.skills) ? value.skills : []; ++ const context = Array.isArray(value.context) ? value.context : []; ++ const degradeReason = this.readString(value.degrade_reason); ++ if (skills.length === 0 && context.length === 0 && !degradeReason) { ++ return undefined; ++ } ++ return { ++ skillCount: skills.length, ++ contextCount: context.length, ++ degradeReason ++ }; ++ } ++ ++ private formatSnippet(candidate: { ++ chunkId: string; ++ sourceLane?: string; ++ domain?: string; ++ }): string | undefined { ++ const parts = [ ++ `chunk:${candidate.chunkId}`, ++ candidate.sourceLane ? `lane:${candidate.sourceLane}` : undefined, ++ candidate.domain ? `domain:${candidate.domain}` : undefined ++ ].filter((item): item is string => Boolean(item)); ++ if (parts.length === 0) { ++ return undefined; ++ } ++ return parts.join(" "); ++ } ++ ++ private parsePayload(payload: string): Record | undefined { ++ try { ++ const parsed = JSON.parse(payload); ++ if (!this.isRecord(parsed)) { ++ return undefined; ++ } ++ return parsed; ++ } catch { ++ return undefined; ++ } ++ } ++ ++ private parseSummaryObject(summary: string | undefined): Record | undefined { ++ if (!summary) { ++ return undefined; ++ } ++ const trimmed = summary.trim(); ++ if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { ++ return undefined; ++ } ++ return this.parsePayload(trimmed); ++ } ++ ++ private isRecord(value: unknown): value is Record { ++ return typeof value === "object" && value !== null && !Array.isArray(value); ++ } ++ ++ private readString(value: unknown): string | undefined { ++ if (typeof value !== "string") { ++ return undefined; ++ } ++ const normalized = value.trim(); ++ return normalized ? normalized : undefined; ++ } ++ ++ private readStringArray(value: unknown): string[] { ++ if (!Array.isArray(value)) { ++ return []; ++ } ++ return this.unique( ++ value ++ .map((item) => this.readString(item)) ++ .filter((item): item is string => Boolean(item)) ++ ); ++ } ++ ++ private unique(values: string[]): string[] { ++ return Array.from(new Set(values.filter((item) => item.trim().length > 0))); ++ } ++ ++ private readSandboxRequest(payload: Record): SandboxPostProcessRequest | undefined { ++ const operationsRaw = Array.isArray(payload.operations) ? payload.operations : undefined; ++ if (!operationsRaw) { ++ return undefined; ++ } ++ ++ const operations: SandboxPostProcessOperation[] = []; ++ for (const item of operationsRaw) { ++ const parsed = this.parseSandboxOperation(item); ++ if (!parsed) { ++ return undefined; ++ } ++ operations.push(parsed); ++ } ++ ++ return { ++ policyVersion: this.readString(payload.policyVersion), ++ operations ++ }; ++ } ++ ++ private parseSandboxOperation(value: unknown): SandboxPostProcessOperation | undefined { ++ if (!this.isRecord(value)) { ++ return undefined; ++ } ++ const type = this.readString(value.type); ++ if (!type) { ++ return undefined; ++ } ++ ++ if (type === "rows_preview_limit") { ++ const maxRowsRaw = value.maxRows; ++ if (typeof maxRowsRaw !== "number" || !Number.isFinite(maxRowsRaw)) { ++ return undefined; ++ } ++ return { ++ type, ++ maxRows: maxRowsRaw ++ }; ++ } ++ ++ if (type === "network_request") { ++ const host = this.readString(value.host); ++ if (!host) { ++ return undefined; ++ } ++ const portRaw = value.port; ++ const port = ++ typeof portRaw === "number" && Number.isFinite(portRaw) ? Math.floor(portRaw) : undefined; ++ return { ++ type, ++ host, ++ protocol: this.readString(value.protocol), ++ port ++ }; ++ } ++ ++ if (type === "file_write") { ++ const path = this.readString(value.path); ++ if (!path) { ++ return undefined; ++ } ++ return { ++ type, ++ path ++ }; ++ } ++ ++ if (type === "process_spawn") { ++ const command = this.readString(value.command); ++ if (!command) { ++ return undefined; ++ } ++ return { ++ type, ++ command ++ }; ++ } ++ ++ return { ++ type: "unknown", ++ rawType: type ++ }; ++ } ++} +diff --git a/apps/backend/src/modules/delivery/sandbox/sandbox-policy.ts b/apps/backend/src/modules/delivery/sandbox/sandbox-policy.ts +new file mode 100644 +index 0000000..a937b56 +--- /dev/null ++++ b/apps/backend/src/modules/delivery/sandbox/sandbox-policy.ts +@@ -0,0 +1,172 @@ ++import { resolve, sep } from "node:path"; ++ ++export const DELIVERY_SANDBOX_POLICY_VERSION = "2026-04-18.r29.v1"; ++ ++export interface DeliverySandboxNetworkRule { ++ host: string; ++ protocols?: Array<"http" | "https">; ++ ports?: number[]; ++} ++ ++export interface DeliverySandboxPolicy { ++ policyId: "delivery.postprocess.sandbox"; ++ version: typeof DELIVERY_SANDBOX_POLICY_VERSION; ++ defaultAction: "deny"; ++ outboundNetwork: { ++ denyByDefault: true; ++ allowlist: DeliverySandboxNetworkRule[]; ++ }; ++ filesystemWrite: { ++ denyByDefault: true; ++ allowlist: string[]; ++ }; ++ processSpawn: { ++ denyByDefault: true; ++ allowlist: string[]; ++ }; ++} ++ ++export interface DeliverySandboxPolicyOverrides { ++ outboundNetworkAllowlist?: DeliverySandboxNetworkRule[]; ++ filesystemWriteAllowlist?: string[]; ++ processSpawnAllowlist?: string[]; ++} ++ ++export const DEFAULT_DELIVERY_SANDBOX_POLICY: DeliverySandboxPolicy = { ++ policyId: "delivery.postprocess.sandbox", ++ version: DELIVERY_SANDBOX_POLICY_VERSION, ++ defaultAction: "deny", ++ outboundNetwork: { ++ denyByDefault: true as const, ++ allowlist: [] ++ }, ++ filesystemWrite: { ++ denyByDefault: true as const, ++ allowlist: [] ++ }, ++ processSpawn: { ++ denyByDefault: true as const, ++ allowlist: [] ++ } ++}; ++ ++export function createDeliverySandboxPolicy( ++ overrides: DeliverySandboxPolicyOverrides = {} ++): DeliverySandboxPolicy { ++ return { ++ ...DEFAULT_DELIVERY_SANDBOX_POLICY, ++ outboundNetwork: { ++ denyByDefault: true as const, ++ allowlist: overrides.outboundNetworkAllowlist ?? [] ++ }, ++ filesystemWrite: { ++ denyByDefault: true as const, ++ allowlist: overrides.filesystemWriteAllowlist ?? [] ++ }, ++ processSpawn: { ++ denyByDefault: true as const, ++ allowlist: overrides.processSpawnAllowlist ?? [] ++ } ++ }; ++} ++ ++export function isSandboxNetworkAllowed( ++ policy: DeliverySandboxPolicy, ++ target: { ++ host: string; ++ protocol?: string; ++ port?: number; ++ } ++): boolean { ++ if (!policy.outboundNetwork.denyByDefault) { ++ return true; ++ } ++ if (policy.outboundNetwork.allowlist.length === 0) { ++ return false; ++ } ++ ++ const host = normalizeString(target.host)?.toLowerCase(); ++ if (!host) { ++ return false; ++ } ++ const protocol = normalizeString(target.protocol)?.toLowerCase(); ++ ++ for (const rule of policy.outboundNetwork.allowlist) { ++ const ruleHost = normalizeString(rule.host)?.toLowerCase(); ++ if (!ruleHost) { ++ continue; ++ } ++ const hostMatch = host === ruleHost || host.endsWith(`.${ruleHost}`); ++ if (!hostMatch) { ++ continue; ++ } ++ ++ if (rule.protocols && rule.protocols.length > 0) { ++ if (!protocol || !rule.protocols.includes(protocol as "http" | "https")) { ++ continue; ++ } ++ } ++ ++ if (rule.ports && rule.ports.length > 0) { ++ if (typeof target.port !== "number" || !rule.ports.includes(target.port)) { ++ continue; ++ } ++ } ++ return true; ++ } ++ ++ return false; ++} ++ ++export function isSandboxFilesystemWriteAllowed( ++ policy: DeliverySandboxPolicy, ++ filePath: string ++): boolean { ++ if (!policy.filesystemWrite.denyByDefault) { ++ return true; ++ } ++ if (policy.filesystemWrite.allowlist.length === 0) { ++ return false; ++ } ++ ++ const normalizedPath = resolve(filePath); ++ for (const allowRoot of policy.filesystemWrite.allowlist) { ++ const normalizedRoot = resolve(allowRoot); ++ if ( ++ normalizedPath === normalizedRoot || ++ normalizedPath.startsWith(`${normalizedRoot}${sep}`) ++ ) { ++ return true; ++ } ++ } ++ ++ return false; ++} ++ ++export function isSandboxProcessSpawnAllowed( ++ policy: DeliverySandboxPolicy, ++ command: string ++): boolean { ++ if (!policy.processSpawn.denyByDefault) { ++ return true; ++ } ++ const normalizedCommand = normalizeString(command)?.toLowerCase(); ++ if (!normalizedCommand) { ++ return false; ++ } ++ if (policy.processSpawn.allowlist.length === 0) { ++ return false; ++ } ++ ++ return policy.processSpawn.allowlist.some( ++ (item) => normalizeString(item)?.toLowerCase() === normalizedCommand ++ ); ++} ++ ++function normalizeString(value: unknown): string | undefined { ++ if (typeof value !== "string") { ++ return undefined; ++ } ++ const normalized = value.trim(); ++ return normalized.length > 0 ? normalized : undefined; ++} +diff --git a/apps/backend/src/modules/delivery/sandbox/sandbox-runtime.service.ts b/apps/backend/src/modules/delivery/sandbox/sandbox-runtime.service.ts +new file mode 100644 +index 0000000..93d3ba7 +--- /dev/null ++++ b/apps/backend/src/modules/delivery/sandbox/sandbox-runtime.service.ts +@@ -0,0 +1,151 @@ ++import { Inject, Injectable, Optional } from "@nestjs/common"; ++import type { DeliveryArtifactLayer } from "@text2sql/shared-types"; ++import { ++ DEFAULT_DELIVERY_SANDBOX_POLICY, ++ type DeliverySandboxPolicy, ++ isSandboxFilesystemWriteAllowed, ++ isSandboxNetworkAllowed, ++ isSandboxProcessSpawnAllowed ++} from "./sandbox-policy"; ++ ++export const DELIVERY_SANDBOX_REPLAY_KEY = "delivery:sandbox_postprocess"; ++export const DELIVERY_SANDBOX_POLICY_TOKEN = "DELIVERY_SANDBOX_POLICY_TOKEN"; ++ ++export type SandboxPostProcessOperation = ++ | { ++ type: "rows_preview_limit"; ++ maxRows: number; ++ } ++ | { ++ type: "network_request"; ++ host: string; ++ protocol?: string; ++ port?: number; ++ } ++ | { ++ type: "file_write"; ++ path: string; ++ } ++ | { ++ type: "process_spawn"; ++ command: string; ++ } ++ | { ++ type: "unknown"; ++ rawType?: string; ++ }; ++ ++export interface SandboxPostProcessRequest { ++ policyVersion?: string; ++ operations: SandboxPostProcessOperation[]; ++} ++ ++export type SandboxExecutionResult = ++ | { ++ ok: true; ++ artifact: DeliveryArtifactLayer; ++ policyVersion: string; ++ } ++ | { ++ ok: false; ++ policyVersion: string; ++ riskTags: string[]; ++ reason: string; ++ }; ++ ++@Injectable() ++export class SandboxRuntimeService { ++ private readonly policy: DeliverySandboxPolicy; ++ ++ constructor( ++ @Optional() ++ @Inject(DELIVERY_SANDBOX_POLICY_TOKEN) ++ policy?: DeliverySandboxPolicy ++ ) { ++ this.policy = policy ?? DEFAULT_DELIVERY_SANDBOX_POLICY; ++ } ++ ++ executeArtifactPostProcess(input: { ++ artifact: DeliveryArtifactLayer; ++ request: SandboxPostProcessRequest; ++ }): SandboxExecutionResult { ++ if ( ++ input.request.policyVersion && ++ input.request.policyVersion !== this.policy.version ++ ) { ++ return this.fail("sandbox_policy_version_mismatch", "Sandbox policy version mismatch."); ++ } ++ ++ const cloned = this.cloneArtifact(input.artifact); ++ for (const operation of input.request.operations) { ++ if (operation.type === "rows_preview_limit") { ++ if (!Number.isFinite(operation.maxRows) || operation.maxRows < 0) { ++ return this.fail( ++ "sandbox_invalid_operation", ++ "rows_preview_limit maxRows must be a non-negative number." ++ ); ++ } ++ cloned.rowsPreview = cloned.rowsPreview?.slice(0, Math.floor(operation.maxRows)); ++ continue; ++ } ++ ++ if (operation.type === "network_request") { ++ const allowed = isSandboxNetworkAllowed(this.policy, { ++ host: operation.host, ++ protocol: operation.protocol, ++ port: operation.port ++ }); ++ if (!allowed) { ++ return this.fail("sandbox_network_denied", "Outbound network access blocked by policy."); ++ } ++ continue; ++ } ++ ++ if (operation.type === "file_write") { ++ const allowed = isSandboxFilesystemWriteAllowed(this.policy, operation.path); ++ if (!allowed) { ++ return this.fail("sandbox_filesystem_denied", "Filesystem write blocked by policy."); ++ } ++ continue; ++ } ++ ++ if (operation.type === "process_spawn") { ++ const allowed = isSandboxProcessSpawnAllowed(this.policy, operation.command); ++ if (!allowed) { ++ return this.fail("sandbox_process_denied", "Process spawn blocked by policy."); ++ } ++ continue; ++ } ++ ++ return this.fail( ++ "sandbox_unknown_operation", ++ `Sandbox operation "${operation.rawType ?? "unknown"}" is not supported.` ++ ); ++ } ++ ++ return { ++ ok: true, ++ artifact: cloned, ++ policyVersion: this.policy.version ++ }; ++ } ++ ++ private fail(tag: string, reason: string): SandboxExecutionResult { ++ return { ++ ok: false, ++ policyVersion: this.policy.version, ++ riskTags: [tag], ++ reason ++ }; ++ } ++ ++ private cloneArtifact(artifact: DeliveryArtifactLayer): DeliveryArtifactLayer { ++ return { ++ ...artifact, ++ columns: artifact.columns ? [...artifact.columns] : undefined, ++ rowsPreview: artifact.rowsPreview ++ ? artifact.rowsPreview.map((row) => ({ ...row })) ++ : undefined ++ }; ++ } ++} +diff --git a/apps/backend/src/modules/graph/adapter/graph-acceleration-circuit-breaker.ts b/apps/backend/src/modules/graph/adapter/graph-acceleration-circuit-breaker.ts +new file mode 100644 +index 0000000..45f6766 +--- /dev/null ++++ b/apps/backend/src/modules/graph/adapter/graph-acceleration-circuit-breaker.ts +@@ -0,0 +1,204 @@ ++import { Injectable } from "@nestjs/common"; ++ ++type GraphAccelerationBreakerReason = ++ | "consecutive_failures" ++ | "timeout_rate" ++ | "latency_degradation"; ++ ++type GraphAccelerationFailureKind = "error" | "timeout"; ++ ++interface BreakerSample { ++ timeout: boolean; ++ latencyDegraded: boolean; ++} ++ ++export interface GraphAccelerationBreakerFallback { ++ reason: string; ++ source: "forced_postgres_only" | "breaker_open" | "adapter_error"; ++ openedAt?: string; ++ openForMs?: number; ++ recoveryCondition: string; ++ impactScope: "retrieval_graph_lane"; ++} ++ ++@Injectable() ++export class GraphAccelerationCircuitBreaker { ++ private readonly failureThreshold = this.readPositiveInt( ++ process.env.GRAPH_ACCELERATION_BREAKER_FAILURE_THRESHOLD, ++ 3 ++ ); ++ private readonly openMs = this.readPositiveInt( ++ process.env.GRAPH_ACCELERATION_BREAKER_OPEN_MS, ++ 60_000 ++ ); ++ private readonly timeoutRateThreshold = this.readRatio( ++ process.env.GRAPH_ACCELERATION_TIMEOUT_RATE_THRESHOLD, ++ 0.5 ++ ); ++ private readonly timeoutRateWindowSize = this.readPositiveInt( ++ process.env.GRAPH_ACCELERATION_TIMEOUT_RATE_WINDOW, ++ 12 ++ ); ++ private readonly timeoutRateMinSamples = this.readPositiveInt( ++ process.env.GRAPH_ACCELERATION_TIMEOUT_RATE_MIN_SAMPLES, ++ 4 ++ ); ++ private readonly latencyDegradeThresholdMs = this.readPositiveInt( ++ process.env.GRAPH_ACCELERATION_LATENCY_DEGRADE_THRESHOLD_MS, ++ 450 ++ ); ++ private readonly latencyDegradeMinSamples = this.readPositiveInt( ++ process.env.GRAPH_ACCELERATION_LATENCY_DEGRADE_MIN_SAMPLES, ++ 3 ++ ); ++ private readonly forcedPostgresOnly = ++ process.env.GRAPH_ACCELERATION_FORCE_POSTGRES_ONLY === "true"; ++ ++ private consecutiveFailures = 0; ++ private readonly recentSamples: BreakerSample[] = []; ++ private openUntilMs = 0; ++ private openedAtIso?: string; ++ private openReason?: GraphAccelerationBreakerReason; ++ ++ guard(): { allowed: true } | { allowed: false; fallback: GraphAccelerationBreakerFallback } { ++ if (this.forcedPostgresOnly) { ++ return { ++ allowed: false, ++ fallback: { ++ reason: "graph_acceleration_forced_postgres_only", ++ source: "forced_postgres_only", ++ recoveryCondition: "set GRAPH_ACCELERATION_FORCE_POSTGRES_ONLY=false", ++ impactScope: "retrieval_graph_lane" ++ } ++ }; ++ } ++ ++ const now = Date.now(); ++ if (this.openUntilMs > now) { ++ const openForMs = Math.max(0, this.openUntilMs - now); ++ return { ++ allowed: false, ++ fallback: { ++ reason: `graph_acceleration_breaker_open_${this.openReason ?? "unknown"}`, ++ source: "breaker_open", ++ openedAt: this.openedAtIso, ++ openForMs, ++ recoveryCondition: ++ "wait breaker cooldown window and ensure error/timeout/latency signals recover", ++ impactScope: "retrieval_graph_lane" ++ } ++ }; ++ } ++ ++ if (this.openUntilMs > 0 && this.openUntilMs <= now) { ++ this.resetOpenState(); ++ } ++ ++ return { allowed: true }; ++ } ++ ++ recordSuccess(): void { ++ this.consecutiveFailures = 0; ++ this.pushSample({ ++ timeout: false, ++ latencyDegraded: false ++ }); ++ } ++ ++ recordFailure(kind: GraphAccelerationFailureKind): GraphAccelerationBreakerFallback | undefined { ++ this.consecutiveFailures += 1; ++ this.pushSample({ ++ timeout: kind === "timeout", ++ latencyDegraded: false ++ }); ++ ++ if (this.consecutiveFailures >= this.failureThreshold) { ++ return this.open("consecutive_failures"); ++ } ++ ++ if (this.shouldOpenByTimeoutRate()) { ++ return this.open("timeout_rate"); ++ } ++ ++ return undefined; ++ } ++ ++ recordLatency(elapsedMs: number): GraphAccelerationBreakerFallback | undefined { ++ const degraded = elapsedMs > this.latencyDegradeThresholdMs; ++ this.pushSample({ ++ timeout: false, ++ latencyDegraded: degraded ++ }); ++ if (!degraded) { ++ return undefined; ++ } ++ if (this.shouldOpenByLatencyDegradation()) { ++ return this.open("latency_degradation"); ++ } ++ return undefined; ++ } ++ ++ private shouldOpenByLatencyDegradation(): boolean { ++ if (this.recentSamples.length < this.latencyDegradeMinSamples) { ++ return false; ++ } ++ return this.recentSamples.every((sample) => sample.latencyDegraded); ++ } ++ ++ private shouldOpenByTimeoutRate(): boolean { ++ if (this.recentSamples.length < this.timeoutRateMinSamples) { ++ return false; ++ } ++ const timeoutCount = this.recentSamples.filter((sample) => sample.timeout).length; ++ const timeoutRate = timeoutCount / this.recentSamples.length; ++ return timeoutRate >= this.timeoutRateThreshold; ++ } ++ ++ private open(reason: GraphAccelerationBreakerReason): GraphAccelerationBreakerFallback { ++ const now = Date.now(); ++ this.openUntilMs = now + this.openMs; ++ this.openedAtIso = new Date(now).toISOString(); ++ this.openReason = reason; ++ this.consecutiveFailures = 0; ++ return { ++ reason: `graph_acceleration_breaker_open_${reason}`, ++ source: "breaker_open", ++ openedAt: this.openedAtIso, ++ openForMs: this.openMs, ++ recoveryCondition: ++ "wait breaker cooldown window and ensure error/timeout/latency signals recover", ++ impactScope: "retrieval_graph_lane" ++ }; ++ } ++ ++ private resetOpenState(): void { ++ this.openUntilMs = 0; ++ this.openedAtIso = undefined; ++ this.openReason = undefined; ++ this.recentSamples.length = 0; ++ this.consecutiveFailures = 0; ++ } ++ ++ private pushSample(sample: BreakerSample): void { ++ this.recentSamples.push(sample); ++ if (this.recentSamples.length > this.timeoutRateWindowSize) { ++ this.recentSamples.shift(); ++ } ++ } ++ ++ private readPositiveInt(raw: string | undefined, fallback: number): number { ++ const parsed = Number(raw); ++ if (!Number.isFinite(parsed) || parsed <= 0) { ++ return fallback; ++ } ++ return Math.floor(parsed); ++ } ++ ++ private readRatio(raw: string | undefined, fallback: number): number { ++ const parsed = Number(raw); ++ if (!Number.isFinite(parsed)) { ++ return fallback; ++ } ++ return Math.max(0, Math.min(1, parsed)); ++ } ++} +diff --git a/apps/backend/src/modules/graph/adapter/graph-acceleration.adapter.ts b/apps/backend/src/modules/graph/adapter/graph-acceleration.adapter.ts +new file mode 100644 +index 0000000..cc07193 +--- /dev/null ++++ b/apps/backend/src/modules/graph/adapter/graph-acceleration.adapter.ts +@@ -0,0 +1,159 @@ ++import { Injectable } from "@nestjs/common"; ++import type { ++ RagRetrievalEntryContext, ++ RagRetrievalLaneHit ++} from "../../rag/retrieval/rag-retrieval.types"; ++ ++export class GraphAccelerationError extends Error {} ++ ++export class GraphAccelerationTimeoutError extends GraphAccelerationError {} ++ ++export class GraphAccelerationUnsupportedOperatorError extends GraphAccelerationError {} ++ ++export interface GraphAccelerationRunInput { ++ query: string; ++ contexts: RagRetrievalEntryContext[]; ++ limit: number; ++} ++ ++@Injectable() ++export class GraphAccelerationAdapter { ++ async execute(input: GraphAccelerationRunInput): Promise { ++ this.applyForcedFailureMode(); ++ const delayMs = this.readPositiveInt(process.env.GRAPH_ACCELERATION_SIMULATED_DELAY_MS, 0); ++ if (delayMs > 0) { ++ await this.sleep(delayMs); ++ } ++ ++ const normalizedQuery = input.query.toLowerCase(); ++ if (normalizedQuery.includes("graph:unsupported")) { ++ throw new GraphAccelerationUnsupportedOperatorError( ++ "graph acceleration operator unsupported" ++ ); ++ } ++ ++ const tokens = this.extractTokens(input.query); ++ const hits: RagRetrievalLaneHit[] = []; ++ for (const context of input.contexts) { ++ const evidence: string[] = ["graph:accelerated"]; ++ let score = 0; ++ ++ if (context.entry.domain === "semantic_term") { ++ score += 0.7; ++ evidence.push("domain:semantic_term"); ++ } ++ if (context.entry.domain === "schema") { ++ score += 0.2; ++ evidence.push("domain:schema"); ++ } ++ ++ for (const tableName of context.parsedMetadata.tableNames) { ++ const normalizedTable = tableName.toLowerCase(); ++ if (tokens.some((token) => normalizedTable.includes(token) || token.includes(normalizedTable))) { ++ score += 0.85; ++ evidence.push(`table:${tableName}`); ++ } ++ } ++ for (const columnName of context.parsedMetadata.columnNames) { ++ const normalizedColumn = columnName.toLowerCase(); ++ if ( ++ tokens.some((token) => normalizedColumn.includes(token) || token.includes(normalizedColumn)) ++ ) { ++ score += 0.55; ++ evidence.push(`column:${columnName}`); ++ } ++ } ++ ++ if (/\b(join|relationship|foreign|关联|外键)\b/i.test(input.query)) { ++ score += 0.35; ++ evidence.push("graph:relationship_hint"); ++ } ++ ++ if (score <= 0) { ++ continue; ++ } ++ ++ hits.push({ ++ lane: "graph", ++ chunk_id: context.entry.chunkId, ++ score: Number(score.toFixed(12)), ++ evidence: this.unique(evidence), ++ chunk: { ++ chunk_id: context.entry.chunkId, ++ content: context.entry.lexicalContent, ++ metadata: { ++ datasourceId: context.entry.datasourceId, ++ indexVersionId: context.indexVersionId, ++ chunkId: context.entry.chunkId, ++ domain: context.entry.domain, ++ chunkProfile: ++ typeof context.parsedMetadata.chunkProfile === "string" ++ ? context.parsedMetadata.chunkProfile ++ : undefined, ++ startOffset: ++ typeof context.parsedMetadata.startOffset === "number" ++ ? context.parsedMetadata.startOffset ++ : undefined, ++ endOffset: ++ typeof context.parsedMetadata.endOffset === "number" ++ ? context.parsedMetadata.endOffset ++ : undefined, ++ tableNames: context.parsedMetadata.tableNames, ++ columnNames: context.parsedMetadata.columnNames, ++ sourceMetadata: context.parsedMetadata.sourceMetadata ++ } ++ } ++ }); ++ } ++ return this.sortHits(hits).slice(0, input.limit); ++ } ++ ++ private applyForcedFailureMode(): void { ++ const mode = (process.env.GRAPH_ACCELERATION_FORCE_FAILURE ?? "").trim().toLowerCase(); ++ if (!mode) { ++ return; ++ } ++ if (mode === "timeout") { ++ throw new GraphAccelerationTimeoutError("forced graph acceleration timeout"); ++ } ++ if (mode === "unsupported") { ++ throw new GraphAccelerationUnsupportedOperatorError("forced unsupported graph operator"); ++ } ++ throw new GraphAccelerationError("forced graph acceleration error"); ++ } ++ ++ private sortHits(hits: RagRetrievalLaneHit[]): RagRetrievalLaneHit[] { ++ return [...hits].sort((left, right) => { ++ if (right.score !== left.score) { ++ return right.score - left.score; ++ } ++ return left.chunk_id.localeCompare(right.chunk_id); ++ }); ++ } ++ ++ private extractTokens(query: string): string[] { ++ const tokens = query ++ .toLowerCase() ++ .match(/[a-z0-9_\p{L}\p{N}]+/gu); ++ if (!tokens) { ++ return []; ++ } ++ return this.unique(tokens.map((item) => item.trim()).filter((item) => item.length > 0)); ++ } ++ ++ private unique(values: string[]): string[] { ++ return Array.from(new Set(values)); ++ } ++ ++ private readPositiveInt(raw: string | undefined, fallback: number): number { ++ const parsed = Number(raw); ++ if (!Number.isFinite(parsed) || parsed <= 0) { ++ return fallback; ++ } ++ return Math.floor(parsed); ++ } ++ ++ private sleep(ms: number): Promise { ++ return new Promise((resolve) => setTimeout(resolve, ms)); ++ } ++} +diff --git a/apps/backend/src/modules/graph/graph.service.ts b/apps/backend/src/modules/graph/graph.service.ts +new file mode 100644 +index 0000000..b8e9aa7 +--- /dev/null ++++ b/apps/backend/src/modules/graph/graph.service.ts +@@ -0,0 +1,126 @@ ++import { Injectable, Logger } from "@nestjs/common"; ++import { ++ GraphAccelerationAdapter, ++ GraphAccelerationError, ++ GraphAccelerationTimeoutError, ++ GraphAccelerationUnsupportedOperatorError ++} from "./adapter/graph-acceleration.adapter"; ++import { ++ GraphAccelerationCircuitBreaker, ++ type GraphAccelerationBreakerFallback ++} from "./adapter/graph-acceleration-circuit-breaker"; ++import type { ++ RagRetrievalEntryContext, ++ RagRetrievalLaneHit ++} from "../rag/retrieval/rag-retrieval.types"; ++ ++export interface GraphLaneExecutionInput { ++ query: string; ++ contexts: RagRetrievalEntryContext[]; ++ limit: number; ++ fallbackRunner: () => RagRetrievalLaneHit[]; ++} ++ ++export interface GraphLaneExecutionResult { ++ hits: RagRetrievalLaneHit[]; ++ degradeReason?: string; ++} ++ ++@Injectable() ++export class GraphService { ++ private readonly logger = new Logger(GraphService.name); ++ ++ constructor( ++ private readonly accelerationAdapter: GraphAccelerationAdapter, ++ private readonly breaker: GraphAccelerationCircuitBreaker ++ ) {} ++ ++ async runLane(input: GraphLaneExecutionInput): Promise { ++ if (!this.isAccelerationEnabled()) { ++ return { ++ hits: input.fallbackRunner().slice(0, input.limit) ++ }; ++ } ++ ++ const breakerGuard = this.breaker.guard(); ++ if (!breakerGuard.allowed) { ++ return this.fallback(input, breakerGuard.fallback); ++ } ++ ++ try { ++ const startedAt = Date.now(); ++ const hits = await this.accelerationAdapter.execute({ ++ query: input.query, ++ contexts: input.contexts, ++ limit: input.limit ++ }); ++ const elapsedMs = Date.now() - startedAt; ++ const latencyFallback = this.breaker.recordLatency(elapsedMs); ++ if (latencyFallback) { ++ this.breaker.recordSuccess(); ++ return this.fallback(input, latencyFallback); ++ } ++ this.breaker.recordSuccess(); ++ return { ++ hits ++ }; ++ } catch (error) { ++ const fallbackReason = this.toFallbackReason(error); ++ const openFallback = this.breaker.recordFailure( ++ error instanceof GraphAccelerationTimeoutError ? "timeout" : "error" ++ ); ++ return this.fallback(input, { ++ reason: openFallback?.reason ?? fallbackReason, ++ source: openFallback ? "breaker_open" : "adapter_error", ++ openedAt: openFallback?.openedAt, ++ openForMs: openFallback?.openForMs, ++ recoveryCondition: ++ openFallback?.recoveryCondition ?? ++ "fix graph acceleration errors and wait breaker cooldown if opened", ++ impactScope: "retrieval_graph_lane" ++ }); ++ } ++ } ++ ++ private fallback( ++ input: GraphLaneExecutionInput, ++ fallback: GraphAccelerationBreakerFallback ++ ): GraphLaneExecutionResult { ++ const hits = input.fallbackRunner().slice(0, input.limit); ++ this.logger.warn( ++ JSON.stringify({ ++ event: "graph_acceleration_fallback", ++ at: new Date().toISOString(), ++ reason: fallback.reason, ++ source: fallback.source, ++ openedAt: fallback.openedAt, ++ openForMs: fallback.openForMs, ++ recoveryCondition: fallback.recoveryCondition, ++ impactScope: fallback.impactScope ++ }) ++ ); ++ return { ++ hits, ++ degradeReason: fallback.reason ++ }; ++ } ++ ++ private isAccelerationEnabled(): boolean { ++ return process.env.GRAPH_ACCELERATION_ENABLED === "true"; ++ } ++ ++ private toFallbackReason(error: unknown): string { ++ if (error instanceof GraphAccelerationTimeoutError) { ++ return "graph_acceleration_timeout_fallback"; ++ } ++ if (error instanceof GraphAccelerationUnsupportedOperatorError) { ++ return "graph_acceleration_unsupported_operator_fallback"; ++ } ++ if (error instanceof GraphAccelerationError) { ++ return "graph_acceleration_error_fallback"; ++ } ++ return `graph_acceleration_unknown_fallback:${ ++ error instanceof Error ? error.message : String(error) ++ }`; ++ } ++} +diff --git a/apps/backend/src/modules/llm/provider-router.service.ts b/apps/backend/src/modules/llm/provider-router.service.ts +index 434d853..75bdf23 100644 +--- a/apps/backend/src/modules/llm/provider-router.service.ts ++++ b/apps/backend/src/modules/llm/provider-router.service.ts +@@ -10,20 +10,35 @@ import type { + import { ProviderCatalogService } from "./provider-catalog.service"; + + export interface LlmDraft { + provider: string; + model: string; + modelCatalogId?: string; + rawText: string; + prompt: LlmGatewayPrompt; + } + ++export interface RerankCandidateInput { ++ candidateId: string; ++ content: string; ++ domain: string; ++ sourceLane: string; ++ evidence: string[]; ++ baseScore: number; ++} ++ ++export interface RerankCandidateResult { ++ candidateId: string; ++ score: number; ++ reason: string; ++} ++ + @Injectable() + export class ProviderRouterService { + constructor( + private readonly config: AppConfigService, + private readonly providerCatalog: ProviderCatalogService, + private readonly llmGateway: LlmGatewayService + ) {} + + async generate( + prompt: LlmGatewayPrompt, +@@ -56,20 +71,73 @@ export class ProviderRouterService { + const completion = await this.llmGateway.stream(prompt, resolved.runtime, options); + return { + provider: completion.provider, + model: completion.model, + modelCatalogId: resolved.modelCatalogId, + rawText: completion.rawText, + prompt + }; + } + ++ async rerankCandidates(input: { ++ query: string; ++ candidates: RerankCandidateInput[]; ++ modelCatalogId?: string; ++ }): Promise { ++ if (input.candidates.length === 0) { ++ return []; ++ } ++ ++ if (this.config.llmMockMode) { ++ return this.rerankInMockMode(input.query, input.candidates); ++ } ++ ++ const prompt: LlmGatewayPrompt = { ++ systemPrompt: ++ "You are a retrieval reranker. Return strict JSON only.", ++ userPrompt: JSON.stringify( ++ { ++ task: "rerank_candidates", ++ query: input.query, ++ outputSchema: { ++ reranked: [ ++ { ++ candidateId: "string", ++ score: "number between 0 and 1", ++ reason: "short reason" ++ } ++ ] ++ }, ++ candidates: input.candidates.map((candidate) => ({ ++ candidateId: candidate.candidateId, ++ domain: candidate.domain, ++ sourceLane: candidate.sourceLane, ++ baseScore: candidate.baseScore, ++ evidence: candidate.evidence.slice(0, 3), ++ contentPreview: candidate.content.slice(0, 400) ++ })) ++ }, ++ null, ++ 2 ++ ) ++ }; ++ ++ const completion = await this.generate(prompt, { ++ modelCatalogId: input.modelCatalogId ++ }); ++ const parsed = this.parseRerankResponse(completion.rawText); ++ if (parsed.length === 0) { ++ return this.rerankInMockMode(input.query, input.candidates); ++ } ++ return parsed; ++ } ++ + private async resolveRuntime( + modelCatalogId?: string + ): Promise<{ + modelCatalogId?: string; + runtime: LlmGatewayRuntimeConfig; + }> { + if (modelCatalogId) { + const resolved = await this.providerCatalog.resolveRuntimeByModelId(modelCatalogId); + return { + modelCatalogId: resolved.model.id, +@@ -103,11 +171,113 @@ export class ProviderRouterService { + runtime: { + provider: this.config.llmProvider, + model: this.config.llmModel, + baseUrl: this.config.llmBaseUrl, + apiKey: this.config.llmApiKey, + timeoutMs: this.config.llmTimeoutMs + } + }; + } + } ++ ++ private rerankInMockMode( ++ query: string, ++ candidates: RerankCandidateInput[] ++ ): RerankCandidateResult[] { ++ const tokens = this.extractTokens(query); ++ return candidates.map((candidate) => { ++ const content = candidate.content.toLowerCase(); ++ const tokenMatch = tokens.filter((token) => content.includes(token)).length; ++ const domainBoost = ++ candidate.domain === "semantic_term" ++ ? 0.12 ++ : candidate.domain === "schema" ++ ? 0.08 ++ : 0.05; ++ const score = Math.max( ++ 0, ++ Math.min(1, Number((candidate.baseScore * 0.6 + tokenMatch * 0.12 + domainBoost).toFixed(6))) ++ ); ++ return { ++ candidateId: candidate.candidateId, ++ score, ++ reason: `mock rerank tokenMatch=${tokenMatch} domain=${candidate.domain}` ++ }; ++ }); ++ } ++ ++ private parseRerankResponse(rawText: string): RerankCandidateResult[] { ++ const normalized = rawText.trim(); ++ if (!normalized) { ++ return []; ++ } ++ const jsonCandidate = this.extractJsonBlock(normalized); ++ if (!jsonCandidate) { ++ return []; ++ } ++ try { ++ const parsed = JSON.parse(jsonCandidate) as unknown; ++ const rows = Array.isArray(parsed) ++ ? parsed ++ : this.isRecord(parsed) && Array.isArray(parsed.reranked) ++ ? parsed.reranked ++ : []; ++ return rows ++ .map((row) => this.toRerankResult(row)) ++ .filter((row): row is RerankCandidateResult => Boolean(row)); ++ } catch { ++ return []; ++ } ++ } ++ ++ private toRerankResult(row: unknown): RerankCandidateResult | undefined { ++ if (!this.isRecord(row)) { ++ return undefined; ++ } ++ const candidateId = ++ typeof row.candidateId === "string" ? row.candidateId.trim() : ""; ++ const score = Number(row.score); ++ const reason = typeof row.reason === "string" ? row.reason.trim() : ""; ++ if (!candidateId || !Number.isFinite(score)) { ++ return undefined; ++ } ++ return { ++ candidateId, ++ score: Math.max(0, Math.min(1, score)), ++ reason: reason || "model_rerank" ++ }; ++ } ++ ++ private extractJsonBlock(text: string): string | undefined { ++ const fencedMatch = text.match(/```json\s*([\s\S]*?)```/i); ++ if (fencedMatch?.[1]) { ++ return fencedMatch[1].trim(); ++ } ++ if (text.startsWith("{") || text.startsWith("[")) { ++ return text; ++ } ++ const firstBrace = text.indexOf("{"); ++ const firstBracket = text.indexOf("["); ++ const startIndexCandidates = [firstBrace, firstBracket].filter( ++ (index) => index >= 0 ++ ); ++ if (startIndexCandidates.length === 0) { ++ return undefined; ++ } ++ const startIndex = Math.min(...startIndexCandidates); ++ return text.slice(startIndex).trim(); ++ } ++ ++ private extractTokens(value: string): string[] { ++ const tokens = value ++ .toLowerCase() ++ .match(/[a-z0-9_\p{L}\p{N}]+/gu); ++ if (!tokens) { ++ return []; ++ } ++ return Array.from(new Set(tokens.filter((token) => token.trim().length > 0))); ++ } ++ ++ private isRecord(value: unknown): value is Record { ++ return typeof value === "object" && value !== null && !Array.isArray(value); ++ } + } +diff --git a/apps/backend/src/modules/memory/dto/apply-memory-feedback.dto.ts b/apps/backend/src/modules/memory/dto/apply-memory-feedback.dto.ts +new file mode 100644 +index 0000000..e2e69af +--- /dev/null ++++ b/apps/backend/src/modules/memory/dto/apply-memory-feedback.dto.ts +@@ -0,0 +1,22 @@ ++import { IsIn, IsOptional, IsString, MaxLength } from "class-validator"; ++import type { RagMemoryStatus } from "@text2sql/shared-types"; ++ ++const MEMORY_STATUS_VALUES: ReadonlyArray = [ ++ "candidate", ++ "verified", ++ "production" ++]; ++ ++export class ApplyMemoryFeedbackDto { ++ @IsString() ++ runId!: string; ++ ++ @IsIn(MEMORY_STATUS_VALUES) ++ targetStatus!: RagMemoryStatus; ++ ++ @IsOptional() ++ @IsString() ++ @MaxLength(500) ++ note?: string; ++} ++ +diff --git a/apps/backend/src/modules/memory/memory-promotion-policy.ts b/apps/backend/src/modules/memory/memory-promotion-policy.ts +new file mode 100644 +index 0000000..80f7af0 +--- /dev/null ++++ b/apps/backend/src/modules/memory/memory-promotion-policy.ts +@@ -0,0 +1,252 @@ ++import { createHash } from "node:crypto"; ++import { Inject, Injectable, Optional } from "@nestjs/common"; ++import type { SqlRun } from "@text2sql/shared-types"; ++ ++export type MemoryPromotionStatus = "candidate" | "verified" | "production"; ++ ++export interface MemoryEvidenceStats { ++ sampleCount: number; ++ successCount: number; ++ riskFlagCount: number; ++ successRate: number; ++} ++ ++export interface MemoryPromotionRecord { ++ candidateId: string; ++ status: MemoryPromotionStatus; ++ evidence: MemoryEvidenceStats; ++ version: number; ++ lastRunId: string | null; ++ lastTransitionKey: string | null; ++ updatedAt: string; ++} ++ ++export interface MemoryPromotionPolicyOptions { ++ minSamplesForVerified: number; ++ minSuccessesForVerified: number; ++ maxRiskFlagsForVerified: number; ++ minSamplesForProduction: number; ++ minSuccessRateForProduction: number; ++ maxRiskFlagsForProduction: number; ++} ++ ++export interface MemoryPromotionEvaluationInput { ++ record: MemoryPromotionRecord; ++ run: SqlRun; ++ riskTags: string[]; ++} ++ ++export interface MemoryPromotionEvaluationResult { ++ nextEvidence: MemoryEvidenceStats; ++ rejectionReasons: string[]; ++ transition?: { ++ from: MemoryPromotionStatus; ++ to: MemoryPromotionStatus; ++ transitionKey: string; ++ }; ++} ++ ++const DEFAULT_POLICY_OPTIONS: Readonly = { ++ minSamplesForVerified: 1, ++ minSuccessesForVerified: 1, ++ maxRiskFlagsForVerified: 0, ++ minSamplesForProduction: 3, ++ minSuccessRateForProduction: 0.8, ++ maxRiskFlagsForProduction: 0 ++}; ++ ++export const MEMORY_PROMOTION_POLICY_OPTIONS = "MEMORY_PROMOTION_POLICY_OPTIONS"; ++ ++@Injectable() ++export class MemoryPromotionPolicy { ++ private readonly options: MemoryPromotionPolicyOptions; ++ ++ constructor( ++ @Optional() ++ @Inject(MEMORY_PROMOTION_POLICY_OPTIONS) ++ options?: Partial ++ ) { ++ this.options = { ++ ...DEFAULT_POLICY_OPTIONS, ++ ...options ++ }; ++ } ++ ++ createInitialRecord(candidateId: string, at: string): MemoryPromotionRecord { ++ return { ++ candidateId, ++ status: "candidate", ++ evidence: { ++ sampleCount: 0, ++ successCount: 0, ++ riskFlagCount: 0, ++ successRate: 0 ++ }, ++ version: 0, ++ lastRunId: null, ++ lastTransitionKey: null, ++ updatedAt: at ++ }; ++ } ++ ++ buildCandidateId(input: { ++ datasourceId: string; ++ sessionId: string; ++ question: string; ++ sql?: string; ++ }): string { ++ const material = [ ++ input.datasourceId.trim().toLowerCase(), ++ input.sessionId.trim().toLowerCase(), ++ this.normalizeCandidateContent(input.sql, input.question) ++ ].join("::"); ++ const digest = createHash("sha256").update(material).digest("hex").slice(0, 24); ++ return `memory-${digest}`; ++ } ++ ++ buildTriggerIdempotencyKey(input: { ++ candidateId: string; ++ runId: string; ++ }): string { ++ return `${input.candidateId}:trigger:${input.runId.trim()}`; ++ } ++ ++ buildTransitionKey(input: { ++ candidateId: string; ++ from: MemoryPromotionStatus; ++ to: MemoryPromotionStatus; ++ }): string { ++ this.assertTransitionAllowed(input.from, input.to); ++ return `${input.candidateId}:${input.from}->${input.to}`; ++ } ++ ++ evaluate(input: MemoryPromotionEvaluationInput): MemoryPromotionEvaluationResult { ++ const nextEvidence = this.nextEvidence(input.record.evidence, input.run, input.riskTags); ++ const rejectionReasons = this.resolveRejectionReasons(input.record.status, nextEvidence); ++ if (input.record.status === "production") { ++ return { ++ nextEvidence, ++ rejectionReasons: ["already_production"] ++ }; ++ } ++ if (rejectionReasons.length > 0) { ++ return { ++ nextEvidence, ++ rejectionReasons ++ }; ++ } ++ ++ const targetStatus = this.resolveTargetStatus(input.record.status); ++ const transitionKey = this.buildTransitionKey({ ++ candidateId: input.record.candidateId, ++ from: input.record.status, ++ to: targetStatus ++ }); ++ return { ++ nextEvidence, ++ rejectionReasons: [], ++ transition: { ++ from: input.record.status, ++ to: targetStatus, ++ transitionKey ++ } ++ }; ++ } ++ ++ private resolveTargetStatus( ++ currentStatus: MemoryPromotionStatus ++ ): MemoryPromotionStatus { ++ if (currentStatus === "candidate") { ++ return "verified"; ++ } ++ return "production"; ++ } ++ ++ private resolveRejectionReasons( ++ currentStatus: MemoryPromotionStatus, ++ evidence: MemoryEvidenceStats ++ ): string[] { ++ if (currentStatus === "candidate") { ++ return this.resolveCandidateRejections(evidence); ++ } ++ if (currentStatus === "verified") { ++ return this.resolveVerifiedRejections(evidence); ++ } ++ return []; ++ } ++ ++ private resolveCandidateRejections(evidence: MemoryEvidenceStats): string[] { ++ const reasons: string[] = []; ++ if (evidence.sampleCount < this.options.minSamplesForVerified) { ++ reasons.push("insufficient_samples_for_verified"); ++ } ++ if (evidence.successCount < this.options.minSuccessesForVerified) { ++ reasons.push("insufficient_successes_for_verified"); ++ } ++ if (evidence.riskFlagCount > this.options.maxRiskFlagsForVerified) { ++ reasons.push("risk_threshold_exceeded_for_verified"); ++ } ++ return reasons; ++ } ++ ++ private resolveVerifiedRejections(evidence: MemoryEvidenceStats): string[] { ++ const reasons: string[] = []; ++ if (evidence.sampleCount < this.options.minSamplesForProduction) { ++ reasons.push("insufficient_samples_for_production"); ++ } ++ if (evidence.successRate < this.options.minSuccessRateForProduction) { ++ reasons.push("insufficient_success_rate_for_production"); ++ } ++ if (evidence.riskFlagCount > this.options.maxRiskFlagsForProduction) { ++ reasons.push("risk_threshold_exceeded_for_production"); ++ } ++ return reasons; ++ } ++ ++ private nextEvidence( ++ previous: MemoryEvidenceStats, ++ run: SqlRun, ++ riskTags: string[] ++ ): MemoryEvidenceStats { ++ const sampleCount = previous.sampleCount + 1; ++ const successCount = ++ previous.successCount + (this.isSuccessfulRun(run) ? 1 : 0); ++ const riskFlagCount = previous.riskFlagCount + (riskTags.length > 0 ? 1 : 0); ++ return { ++ sampleCount, ++ successCount, ++ riskFlagCount, ++ successRate: sampleCount === 0 ? 0 : successCount / sampleCount ++ }; ++ } ++ ++ private isSuccessfulRun(run: SqlRun): boolean { ++ return ( ++ run.status === "executionResult" && ++ !run.error && ++ Boolean(run.sql?.trim()) ++ ); ++ } ++ ++ private normalizeCandidateContent(sql: string | undefined, question: string): string { ++ const normalizedSql = sql?.trim().toLowerCase().replace(/\s+/g, " "); ++ if (normalizedSql) { ++ return `sql:${normalizedSql}`; ++ } ++ return `question:${question.trim().toLowerCase().replace(/\s+/g, " ")}`; ++ } ++ ++ private assertTransitionAllowed( ++ from: MemoryPromotionStatus, ++ to: MemoryPromotionStatus ++ ): void { ++ const allowed: Record = { ++ candidate: ["verified"], ++ verified: ["production"], ++ production: [] ++ }; ++ if (!allowed[from].includes(to)) { ++ throw new Error(`非法记忆状态迁移: ${from} -> ${to}`); ++ } ++ } ++} +diff --git a/apps/backend/src/modules/memory/memory-promotion.service.ts b/apps/backend/src/modules/memory/memory-promotion.service.ts +new file mode 100644 +index 0000000..ddf8916 +--- /dev/null ++++ b/apps/backend/src/modules/memory/memory-promotion.service.ts +@@ -0,0 +1,532 @@ ++import { Injectable, Logger } from "@nestjs/common"; ++import type { RagMemoryFeedbackResponse, RagMemoryStatus, SqlRun } from "@text2sql/shared-types"; ++import { DomainError } from "../../common/domain-error"; ++import { ChatRepository } from "../data/persistence/chat.repository"; ++import { AuditLogRepository } from "../data/persistence/audit-log.repository"; ++import { RagReplayRepository } from "../rag/observability/rag-replay.repository"; ++import { ++ type MemoryPromotionRecord, ++ type MemoryPromotionStatus, ++ MemoryPromotionPolicy ++} from "./memory-promotion-policy"; ++ ++type PromotionState = ++ | "promoted" ++ | "held" ++ | "duplicate" ++ | "rolled_back"; ++ ++export interface MemoryCompensationEntry { ++ compensationKey: string; ++ candidateId: string; ++ runId: string; ++ attempts: number; ++ state: "pending_retry" | "manual_review"; ++ lastError: string; ++ updatedAt: string; ++} ++ ++export interface MemoryPromotionResult { ++ candidateId: string; ++ state: PromotionState; ++ beforeStatus: MemoryPromotionStatus; ++ afterStatus: MemoryPromotionStatus; ++ transitionKey?: string; ++ rejectionReasons: string[]; ++ idempotencyKey: string; ++ compensation?: MemoryCompensationEntry; ++} ++ ++const MAX_COMPENSATION_ATTEMPTS = 3; ++ ++@Injectable() ++export class MemoryPromotionService { ++ private readonly logger = new Logger(MemoryPromotionService.name); ++ private readonly records = new Map(); ++ private readonly processedTriggerKeys = new Set(); ++ private readonly processedTransitionKeys = new Set(); ++ private readonly compensations = new Map(); ++ ++ constructor( ++ private readonly policy: MemoryPromotionPolicy, ++ private readonly chatRepository: ChatRepository, ++ private readonly auditLogRepository: AuditLogRepository, ++ private readonly ragReplayRepository: RagReplayRepository ++ ) {} ++ ++ async promoteFromRun(input: { ++ run: SqlRun; ++ datasourceId: string; ++ requestId?: string; ++ }): Promise { ++ const now = new Date().toISOString(); ++ const candidateId = this.policy.buildCandidateId({ ++ datasourceId: input.datasourceId, ++ sessionId: input.run.sessionId, ++ question: input.run.question, ++ sql: input.run.sql ++ }); ++ const idempotencyKey = this.policy.buildTriggerIdempotencyKey({ ++ candidateId, ++ runId: input.run.runId ++ }); ++ const previous = this.getOrCreateRecord(candidateId, now); ++ if (this.processedTriggerKeys.has(idempotencyKey)) { ++ return { ++ candidateId, ++ state: "duplicate", ++ beforeStatus: previous.status, ++ afterStatus: previous.status, ++ rejectionReasons: ["duplicate_trigger_ignored"], ++ idempotencyKey ++ }; ++ } ++ ++ const riskTags = this.extractRiskTags(input.run); ++ const evaluation = this.policy.evaluate({ ++ record: previous, ++ run: input.run, ++ riskTags ++ }); ++ const beforeStatus = previous.status; ++ const transitionKey = evaluation.transition?.transitionKey; ++ if (transitionKey && this.processedTransitionKeys.has(transitionKey)) { ++ this.processedTriggerKeys.add(idempotencyKey); ++ return { ++ candidateId, ++ state: "duplicate", ++ beforeStatus, ++ afterStatus: previous.status, ++ transitionKey, ++ rejectionReasons: ["duplicate_transition_ignored"], ++ idempotencyKey ++ }; ++ } ++ ++ const next: MemoryPromotionRecord = { ++ ...previous, ++ status: evaluation.transition?.to ?? previous.status, ++ evidence: evaluation.nextEvidence, ++ version: previous.version + 1, ++ lastRunId: input.run.runId, ++ lastTransitionKey: transitionKey ?? previous.lastTransitionKey, ++ updatedAt: now ++ }; ++ ++ this.records.set(candidateId, next); ++ ++ try { ++ await this.writePromotionAudit({ ++ run: input.run, ++ requestId: input.requestId, ++ datasourceId: input.datasourceId, ++ candidateId, ++ idempotencyKey, ++ beforeStatus, ++ afterStatus: next.status, ++ transitionKey, ++ rejectionReasons: evaluation.rejectionReasons, ++ evidence: next.evidence ++ }); ++ ++ if (transitionKey) { ++ this.processedTransitionKeys.add(transitionKey); ++ } ++ this.processedTriggerKeys.add(idempotencyKey); ++ ++ return { ++ candidateId, ++ state: transitionKey ? "promoted" : "held", ++ beforeStatus, ++ afterStatus: next.status, ++ transitionKey, ++ rejectionReasons: evaluation.rejectionReasons, ++ idempotencyKey ++ }; ++ } catch (error) { ++ this.records.set(candidateId, previous); ++ const compensation = await this.emitCompensation({ ++ run: input.run, ++ requestId: input.requestId, ++ datasourceId: input.datasourceId, ++ candidateId, ++ beforeStatus, ++ attemptedStatus: next.status, ++ transitionKey, ++ idempotencyKey, ++ error ++ }); ++ return { ++ candidateId, ++ state: "rolled_back", ++ beforeStatus, ++ afterStatus: previous.status, ++ transitionKey, ++ rejectionReasons: ["promotion_write_failed"], ++ idempotencyKey, ++ compensation ++ }; ++ } ++ } ++ ++ getRecord(candidateId: string): MemoryPromotionRecord | undefined { ++ const record = this.records.get(candidateId); ++ if (!record) { ++ return undefined; ++ } ++ return { ++ ...record, ++ evidence: { ++ ...record.evidence ++ } ++ }; ++ } ++ ++ listCompensations(): MemoryCompensationEntry[] { ++ return Array.from(this.compensations.values()).map((item) => ({ ++ ...item ++ })); ++ } ++ ++ buildCandidateIdForRun(input: { ++ run: SqlRun; ++ datasourceId: string; ++ }): string { ++ return this.policy.buildCandidateId({ ++ datasourceId: input.datasourceId, ++ sessionId: input.run.sessionId, ++ question: input.run.question, ++ sql: input.run.sql ++ }); ++ } ++ ++ async applyFeedback(input: { ++ runId: string; ++ targetStatus: RagMemoryStatus; ++ note?: string; ++ actorId: string; ++ requestId?: string; ++ }): Promise { ++ const run = await this.chatRepository.getRunById(input.runId); ++ if (!run) { ++ throw new DomainError("RUN_NOT_FOUND", "运行记录不存在", 404, { ++ runId: input.runId ++ }); ++ } ++ const session = await this.chatRepository.getSessionById(run.sessionId); ++ if (!session) { ++ throw new DomainError("RUN_NOT_FOUND", "运行记录不存在", 404, { ++ runId: input.runId ++ }); ++ } ++ ++ const candidateId = this.buildCandidateIdForRun({ ++ run, ++ datasourceId: session.datasource ++ }); ++ const now = new Date().toISOString(); ++ const previous = this.getOrCreateRecord(candidateId, now); ++ const beforeStatus = previous.status; ++ const afterStatus = this.resolveFeedbackStatusTransition( ++ previous.status, ++ input.targetStatus ++ ); ++ ++ const note = input.note?.trim() || undefined; ++ if (beforeStatus === afterStatus) { ++ return { ++ runId: run.runId, ++ candidateId, ++ beforeStatus, ++ afterStatus, ++ applied: false, ++ note, ++ updatedAt: previous.updatedAt ++ }; ++ } ++ ++ const transitionKey = `manual:${candidateId}:${beforeStatus}->${afterStatus}:${run.runId}`; ++ const next: MemoryPromotionRecord = { ++ ...previous, ++ status: afterStatus, ++ version: previous.version + 1, ++ lastRunId: run.runId, ++ lastTransitionKey: transitionKey, ++ updatedAt: now ++ }; ++ this.records.set(candidateId, next); ++ ++ try { ++ await this.auditLogRepository.appendEvent({ ++ phase: "memory_feedback", ++ severity: "info", ++ eventType: "memory.promotion.feedback.applied", ++ eventCode: "MEMORY_FEEDBACK_APPLIED", ++ runId: run.runId, ++ sessionId: run.sessionId, ++ requestId: input.requestId, ++ message: `memory feedback transitioned ${beforeStatus} -> ${afterStatus}`, ++ metadata: { ++ actorId: input.actorId, ++ candidateId, ++ beforeStatus, ++ afterStatus, ++ note: note ?? null ++ } ++ }); ++ await this.ragReplayRepository.writeReplay({ ++ runId: run.runId, ++ replayKey: `memory:promotion:feedback:${candidateId}:${Date.parse(now)}`, ++ datasourceId: session.datasource, ++ stage: "memory_promotion_feedback", ++ payload: { ++ actorId: input.actorId, ++ candidateId, ++ beforeStatus, ++ afterStatus, ++ note: note ?? null ++ } ++ }); ++ } catch { ++ this.records.set(candidateId, previous); ++ throw new DomainError( ++ "MEMORY_FEEDBACK_WRITE_FAILED", ++ "记忆反馈写入失败,请稍后重试。", ++ 409, ++ { ++ runId: run.runId, ++ candidateId ++ } ++ ); ++ } ++ ++ return { ++ runId: run.runId, ++ candidateId, ++ beforeStatus, ++ afterStatus, ++ applied: true, ++ note, ++ updatedAt: now ++ }; ++ } ++ ++ private extractRiskTags(run: SqlRun): string[] { ++ const value = run.delivery?.evidence?.riskTags; ++ if (!Array.isArray(value)) { ++ return []; ++ } ++ return value.filter((item): item is string => typeof item === "string"); ++ } ++ ++ private resolveFeedbackStatusTransition( ++ current: MemoryPromotionStatus, ++ target: RagMemoryStatus ++ ): MemoryPromotionStatus { ++ const rank: Record = { ++ candidate: 0, ++ verified: 1, ++ production: 2 ++ }; ++ if (target === current) { ++ return current; ++ } ++ if (rank[target] < rank[current]) { ++ throw new DomainError( ++ "MEMORY_FEEDBACK_CONFLICT", ++ "不允许将记忆状态降级到更低等级。", ++ 409, ++ { ++ currentStatus: current, ++ targetStatus: target ++ } ++ ); ++ } ++ if (rank[target] - rank[current] > 1) { ++ throw new DomainError( ++ "MEMORY_FEEDBACK_CONFLICT", ++ "记忆反馈仅支持相邻等级迁移。", ++ 409, ++ { ++ currentStatus: current, ++ targetStatus: target ++ } ++ ); ++ } ++ return target; ++ } ++ ++ private getOrCreateRecord(candidateId: string, now: string): MemoryPromotionRecord { ++ const existing = this.records.get(candidateId); ++ if (existing) { ++ return { ++ ...existing, ++ evidence: { ++ ...existing.evidence ++ } ++ }; ++ } ++ const created = this.policy.createInitialRecord(candidateId, now); ++ this.records.set(candidateId, created); ++ return { ++ ...created, ++ evidence: { ++ ...created.evidence ++ } ++ }; ++ } ++ ++ private async writePromotionAudit(input: { ++ run: SqlRun; ++ requestId?: string; ++ datasourceId: string; ++ candidateId: string; ++ idempotencyKey: string; ++ beforeStatus: MemoryPromotionStatus; ++ afterStatus: MemoryPromotionStatus; ++ transitionKey?: string; ++ rejectionReasons: string[]; ++ evidence: MemoryPromotionRecord["evidence"]; ++ }): Promise { ++ const replayKey = `memory:promotion:${input.candidateId}:v${input.evidence.sampleCount}`; ++ const eventType = input.transitionKey ++ ? "memory.promotion.transition.applied" ++ : "memory.promotion.transition.held"; ++ const auditEvent = await this.auditLogRepository.appendEvent({ ++ phase: "memory_promotion", ++ severity: input.transitionKey ? "info" : "warning", ++ eventType, ++ eventCode: input.transitionKey ? "PROMOTION_APPLIED" : "PROMOTION_HELD", ++ runId: input.run.runId, ++ sessionId: input.run.sessionId, ++ requestId: input.requestId, ++ message: input.transitionKey ++ ? `memory promotion transitioned ${input.beforeStatus} -> ${input.afterStatus}` ++ : `memory promotion held at ${input.afterStatus}`, ++ metadata: { ++ candidateId: input.candidateId, ++ datasourceId: input.datasourceId, ++ idempotencyKey: input.idempotencyKey, ++ transitionKey: input.transitionKey ?? null, ++ beforeStatus: input.beforeStatus, ++ afterStatus: input.afterStatus, ++ replayKey, ++ rejectionReasons: input.rejectionReasons, ++ evidence: input.evidence ++ } ++ }); ++ ++ await this.ragReplayRepository.writeReplay({ ++ runId: input.run.runId, ++ replayKey, ++ datasourceId: input.datasourceId, ++ stage: "memory_promotion", ++ payload: { ++ requestId: input.requestId ?? null, ++ candidateId: input.candidateId, ++ auditEventId: auditEvent.id, ++ idempotencyKey: input.idempotencyKey, ++ replayKey, ++ transitionKey: input.transitionKey ?? null, ++ beforeStatus: input.beforeStatus, ++ afterStatus: input.afterStatus, ++ rejectionReasons: input.rejectionReasons, ++ evidence: input.evidence ++ } ++ }); ++ } ++ ++ private async emitCompensation(input: { ++ run: SqlRun; ++ requestId?: string; ++ datasourceId: string; ++ candidateId: string; ++ beforeStatus: MemoryPromotionStatus; ++ attemptedStatus: MemoryPromotionStatus; ++ transitionKey?: string; ++ idempotencyKey: string; ++ error: unknown; ++ }): Promise { ++ const transitionPart = input.transitionKey ?? "held"; ++ const compensationKey = `${input.candidateId}:${input.run.runId}:${transitionPart}`; ++ const previous = this.compensations.get(compensationKey); ++ const attempts = (previous?.attempts ?? 0) + 1; ++ const state = attempts >= MAX_COMPENSATION_ATTEMPTS ? "manual_review" : "pending_retry"; ++ const entry: MemoryCompensationEntry = { ++ compensationKey, ++ candidateId: input.candidateId, ++ runId: input.run.runId, ++ attempts, ++ state, ++ lastError: ++ input.error instanceof Error ++ ? input.error.message ++ : String(input.error), ++ updatedAt: new Date().toISOString() ++ }; ++ this.compensations.set(compensationKey, entry); ++ const replayKey = `memory:promotion:compensation:${entry.compensationKey}`; ++ ++ try { ++ const compensationAuditEvent = await this.auditLogRepository.appendEvent({ ++ phase: "memory_promotion", ++ severity: state === "manual_review" ? "error" : "warning", ++ eventType: "memory.promotion.compensated", ++ eventCode: ++ state === "manual_review" ++ ? "PROMOTION_COMPENSATED_MANUAL_REVIEW" ++ : "PROMOTION_COMPENSATED", ++ runId: input.run.runId, ++ sessionId: input.run.sessionId, ++ requestId: input.requestId, ++ message: ++ state === "manual_review" ++ ? "memory promotion write failed repeatedly and requires manual review" ++ : "memory promotion write failed; state rolled back and compensation queued", ++ metadata: { ++ candidateId: input.candidateId, ++ datasourceId: input.datasourceId, ++ idempotencyKey: input.idempotencyKey, ++ transitionKey: input.transitionKey ?? null, ++ rollbackTo: input.beforeStatus, ++ attemptedStatus: input.attemptedStatus, ++ compensationKey: entry.compensationKey, ++ attempts: entry.attempts, ++ compensationState: entry.state, ++ replayKey, ++ error: entry.lastError ++ } ++ }); ++ ++ await this.ragReplayRepository.writeReplay({ ++ runId: input.run.runId, ++ replayKey, ++ datasourceId: input.datasourceId, ++ stage: "memory_promotion_compensation", ++ payload: { ++ requestId: input.requestId ?? null, ++ candidateId: input.candidateId, ++ compensationAuditEventId: compensationAuditEvent.id, ++ compensationKey: entry.compensationKey, ++ replayKey, ++ attempts: entry.attempts, ++ state: entry.state, ++ rollbackTo: input.beforeStatus, ++ attemptedStatus: input.attemptedStatus, ++ transitionKey: input.transitionKey ?? null, ++ error: entry.lastError ++ } ++ }); ++ } catch (compensationError) { ++ this.logger.warn( ++ `memory promotion compensation signal failed: ${ ++ compensationError instanceof Error ++ ? compensationError.message ++ : String(compensationError) ++ }` ++ ); ++ } ++ ++ return entry; ++ } ++} +diff --git a/apps/backend/src/modules/memory/memory.controller.ts b/apps/backend/src/modules/memory/memory.controller.ts +new file mode 100644 +index 0000000..772693a +--- /dev/null ++++ b/apps/backend/src/modules/memory/memory.controller.ts +@@ -0,0 +1,51 @@ ++import { Body, Controller, Post, Req, Res, UseGuards } from "@nestjs/common"; ++import type { ApiResponse } from "@text2sql/shared-types"; ++import type { Request, Response } from "express"; ++import { fail, ok } from "../../common/api-response"; ++import { DomainError } from "../../common/domain-error"; ++import { AdminOnlyGuard } from "../auth/admin-only.guard"; ++import { ApplyMemoryFeedbackDto } from "./dto/apply-memory-feedback.dto"; ++import { MemoryPromotionService } from "./memory-promotion.service"; ++ ++@Controller("/api/v1/rag/memory") ++export class MemoryController { ++ constructor(private readonly promotionService: MemoryPromotionService) {} ++ ++ @Post("/feedback") ++ @UseGuards(AdminOnlyGuard) ++ async applyFeedback( ++ @Body() body: ApplyMemoryFeedbackDto, ++ @Req() req: Request, ++ @Res({ passthrough: true }) res: Response ++ ): Promise> { ++ try { ++ const feedback = await this.promotionService.applyFeedback({ ++ runId: body.runId, ++ targetStatus: body.targetStatus, ++ note: body.note, ++ actorId: req.actor?.id ?? "unknown", ++ requestId: req.requestId ++ }); ++ return ok(req.requestId, feedback); ++ } catch (error) { ++ return this.toError(req.requestId, error, res); ++ } ++ } ++ ++ private toError( ++ requestId: string, ++ error: unknown, ++ res: Response ++ ): ApiResponse { ++ if (error instanceof DomainError) { ++ res.status(error.statusCode); ++ return fail(requestId, error.code, error.message, error.details); ++ } ++ res.status(500); ++ return fail( ++ requestId, ++ "INTERNAL_ERROR", ++ error instanceof Error ? error.message : "未知错误" ++ ); ++ } ++} +diff --git a/apps/backend/src/modules/memory/memory.module.ts b/apps/backend/src/modules/memory/memory.module.ts +new file mode 100644 +index 0000000..3d0933d +--- /dev/null ++++ b/apps/backend/src/modules/memory/memory.module.ts +@@ -0,0 +1,15 @@ ++import { Module } from "@nestjs/common"; ++import { AdminOnlyGuard } from "../auth/admin-only.guard"; ++import { DataModule } from "../data/data.module"; ++import { RagModule } from "../rag/rag.module"; ++import { MemoryController } from "./memory.controller"; ++import { MemoryPromotionPolicy } from "./memory-promotion-policy"; ++import { MemoryPromotionService } from "./memory-promotion.service"; ++ ++@Module({ ++ imports: [DataModule, RagModule], ++ controllers: [MemoryController], ++ providers: [MemoryPromotionPolicy, MemoryPromotionService, AdminOnlyGuard], ++ exports: [MemoryPromotionPolicy, MemoryPromotionService] ++}) ++export class MemoryModule {} +diff --git a/apps/backend/src/modules/observability/observability.module.ts b/apps/backend/src/modules/observability/observability.module.ts +index f66eeb3..e880df1 100644 +--- a/apps/backend/src/modules/observability/observability.module.ts ++++ b/apps/backend/src/modules/observability/observability.module.ts +@@ -1,12 +1,23 @@ + import { Module } from "@nestjs/common"; + import { AppConfigModule } from "../config/config.module"; + import { TraceService } from "./trace.service"; + import { LangsmithTraceService } from "./langsmith-trace.service"; + import { GateMetricsService } from "./gate-metrics.service"; ++import { RagIngestionMetricsService } from "../rag/observability/rag-ingestion-metrics.service"; + + @Module({ + imports: [AppConfigModule], +- providers: [TraceService, LangsmithTraceService, GateMetricsService], +- exports: [TraceService, LangsmithTraceService, GateMetricsService] ++ providers: [ ++ TraceService, ++ LangsmithTraceService, ++ GateMetricsService, ++ RagIngestionMetricsService ++ ], ++ exports: [ ++ TraceService, ++ LangsmithTraceService, ++ GateMetricsService, ++ RagIngestionMetricsService ++ ] + }) + export class ObservabilityModule {} +diff --git a/apps/backend/src/modules/rag/audit/rag-audit-replay.service.ts b/apps/backend/src/modules/rag/audit/rag-audit-replay.service.ts +new file mode 100644 +index 0000000..e102699 +--- /dev/null ++++ b/apps/backend/src/modules/rag/audit/rag-audit-replay.service.ts +@@ -0,0 +1,577 @@ ++import type { ExecutionTrace } from "@text2sql/shared-types"; ++import { Injectable } from "@nestjs/common"; ++import type { GovernanceAuditLog } from "../../data/persistence/audit-log.repository"; ++import { AuditLogRepository } from "../../data/persistence/audit-log.repository"; ++import { ChatRepository } from "../../data/persistence/chat.repository"; ++import { RagIndexRepository } from "../index/rag-index.repository"; ++import type { RagReplayRecord } from "../observability/rag-replay.repository"; ++import { RagReplayRepository } from "../observability/rag-replay.repository"; ++ ++export interface RagAuditReplayQueryInput { ++ runId?: string; ++ requestId?: string; ++ fromAt?: string; ++ toAt?: string; ++} ++ ++export interface RagAuditReplayEventRecord { ++ eventId: string; ++ eventType: string; ++ requestId?: string; ++ datasourceId: string; ++ sourceVersion: string; ++ idempotencyKey: string; ++ replayToken: string; ++ status: "processed" | "retry_scheduled" | "dlq" | "noop"; ++ attempts: number; ++ occurredAt: string; ++ processedAt?: string; ++ failureReason?: string; ++ anchor?: { ++ anchorId: string; ++ anchorType: "release" | "rollback"; ++ anchorVersion: number; ++ scope?: "global" | "datasource"; ++ scopeKey?: string; ++ rollbackFromAnchorId?: string; ++ rollbackReason?: string | null; ++ }; ++ semanticSnapshot?: { ++ versionId: string; ++ domain: string; ++ semanticVersion: number; ++ status: "active" | "deprecated"; ++ riskTags: string[]; ++ }; ++ indexVersion?: { ++ id: string; ++ status: string; ++ sourceVersion: string; ++ activatedAt?: string; ++ }; ++ replay: Array<{ ++ replayKey: string; ++ stage: string; ++ createdAt: string; ++ }>; ++ audits: GovernanceAuditLog[]; ++} ++ ++export interface RagAuditReplayChain { ++ runId: string; ++ requestId?: string; ++ runTrace?: ExecutionTrace; ++ events: RagAuditReplayEventRecord[]; ++ generatedAt: string; ++} ++ ++interface EventNode { ++ eventId: string; ++ eventSource: "incremental" | "glossary"; ++ eventType: string; ++ requestId?: string; ++ datasourceId: string; ++ sourceVersion: string; ++ idempotencyKey: string; ++ replayToken: string; ++ status: "processed" | "retry_scheduled" | "dlq" | "noop"; ++ attempts: number; ++ occurredAt: string; ++ processedAt?: string; ++ failureReason?: string; ++ anchor?: { ++ anchorId: string; ++ anchorType: "release" | "rollback"; ++ anchorVersion: number; ++ scope?: "global" | "datasource"; ++ scopeKey?: string; ++ rollbackFromAnchorId?: string; ++ rollbackReason?: string | null; ++ }; ++ semanticSnapshot?: { ++ versionId: string; ++ domain: string; ++ semanticVersion: number; ++ status: "active" | "deprecated"; ++ riskTags: string[]; ++ }; ++ indexVersionId?: string; ++ replay: Array<{ ++ replayKey: string; ++ stage: string; ++ createdAt: string; ++ }>; ++} ++ ++@Injectable() ++export class RagAuditReplayService { ++ constructor( ++ private readonly ragReplayRepository: RagReplayRepository, ++ private readonly ragIndexRepository: RagIndexRepository, ++ private readonly chatRepository: ChatRepository, ++ private readonly auditLogRepository: AuditLogRepository ++ ) {} ++ ++ async queryChain(input: RagAuditReplayQueryInput): Promise { ++ const requestedRunId = input.runId?.trim() || ""; ++ const requestedRequestId = input.requestId?.trim() || ""; ++ if (!requestedRunId && !requestedRequestId) { ++ return { ++ runId: "", ++ requestId: requestedRequestId || undefined, ++ events: [], ++ generatedAt: new Date().toISOString() ++ }; ++ } ++ ++ const auditRows = await this.auditLogRepository.listEvents({ ++ runId: requestedRunId || undefined, ++ requestId: requestedRequestId || undefined, ++ limit: 500 ++ }); ++ const resolvedRunId = requestedRunId || auditRows.find((item) => item.runId)?.runId || ""; ++ const [replayRows, run] = await Promise.all([ ++ resolvedRunId ? this.ragReplayRepository.listByRunId(resolvedRunId) : Promise.resolve([]), ++ resolvedRunId ? this.chatRepository.getRunById(resolvedRunId) : Promise.resolve(undefined) ++ ]); ++ ++ const fromAt = this.parseTimestamp(input.fromAt); ++ const toAt = this.parseTimestamp(input.toAt); ++ const eventNodes = this.buildEventNodes(replayRows); ++ const filteredNodes = eventNodes.filter((item) => { ++ if (!this.isWithinWindow(item.occurredAt, fromAt, toAt)) { ++ return false; ++ } ++ if (requestedRequestId && item.requestId !== requestedRequestId) { ++ return false; ++ } ++ return true; ++ }); ++ const scopedNodes = requestedRequestId ++ ? filteredNodes ++ : filteredNodes.filter((item) => item.eventSource === "incremental"); ++ ++ const indexVersionMap = await this.loadIndexVersions(scopedNodes); ++ const relevantAuditRows = auditRows.filter((item) => { ++ if (requestedRequestId && item.requestId !== requestedRequestId) { ++ return false; ++ } ++ if (!requestedRequestId && !item.eventType.startsWith("rag.incremental-refresh")) { ++ return false; ++ } ++ return Boolean(this.readMetadataEventId(item.metadata)); ++ }); ++ ++ const events = scopedNodes ++ .sort((left, right) => Date.parse(left.occurredAt) - Date.parse(right.occurredAt)) ++ .map((item) => ({ ++ eventId: item.eventId, ++ eventType: item.eventType, ++ requestId: item.requestId, ++ datasourceId: item.datasourceId, ++ sourceVersion: item.sourceVersion, ++ idempotencyKey: item.idempotencyKey, ++ replayToken: item.replayToken, ++ status: item.status, ++ attempts: item.attempts, ++ occurredAt: item.occurredAt, ++ processedAt: item.processedAt, ++ failureReason: item.failureReason, ++ anchor: item.anchor ? { ...item.anchor } : undefined, ++ semanticSnapshot: item.semanticSnapshot ++ ? { ++ versionId: item.semanticSnapshot.versionId, ++ domain: item.semanticSnapshot.domain, ++ semanticVersion: item.semanticSnapshot.semanticVersion, ++ status: item.semanticSnapshot.status, ++ riskTags: [...item.semanticSnapshot.riskTags] ++ } ++ : undefined, ++ indexVersion: item.indexVersionId ++ ? indexVersionMap.get(item.indexVersionId) ++ : undefined, ++ replay: [...item.replay], ++ audits: relevantAuditRows.filter( ++ (auditRow) => this.readMetadataEventId(auditRow.metadata) === item.eventId ++ ) ++ })); ++ ++ return { ++ runId: resolvedRunId, ++ requestId: requestedRequestId || undefined, ++ runTrace: run?.trace, ++ events, ++ generatedAt: new Date().toISOString() ++ }; ++ } ++ ++ private buildEventNodes(rows: RagReplayRecord[]): EventNode[] { ++ const nodes = new Map(); ++ for (const row of rows) { ++ const payload = this.parsePayload(row.payload); ++ if (row.replayKey.startsWith("incremental:event:")) { ++ const eventId = this.resolveEventId(row.replayKey, payload.eventId); ++ if (!eventId) { ++ continue; ++ } ++ ++ const current = ++ nodes.get(eventId) ?? ++ this.createInitialNode({ ++ eventId, ++ datasourceId: row.datasourceId, ++ occurredAt: row.createdAt, ++ payload ++ }); ++ current.replay.push({ ++ replayKey: row.replayKey, ++ stage: row.stage, ++ createdAt: row.createdAt ++ }); ++ ++ if (row.stage === "incremental_event_received") { ++ current.eventType = this.readString(payload.eventType, current.eventType); ++ current.requestId = this.readOptionalString(payload.requestId) ?? current.requestId; ++ current.datasourceId = this.readString(payload.datasourceId, current.datasourceId); ++ current.sourceVersion = this.readString(payload.sourceVersion, current.sourceVersion); ++ current.idempotencyKey = this.readString( ++ payload.idempotencyKey, ++ current.idempotencyKey ++ ); ++ current.replayToken = this.readString(payload.replayToken, current.replayToken); ++ current.attempts = this.readNumber(payload.attempt, current.attempts); ++ current.occurredAt = this.readString(payload.occurredAt, current.occurredAt); ++ } ++ ++ if (row.stage === "incremental_event_processed") { ++ current.status = "processed"; ++ current.processedAt = this.readString(payload.processedAt, row.createdAt); ++ current.attempts = this.readNumber(payload.attempt, current.attempts); ++ current.indexVersionId = ++ this.readOptionalString(payload.indexVersionId) ?? ++ row.indexVersionId ?? ++ current.indexVersionId; ++ } ++ ++ if (row.stage === "incremental_event_failed") { ++ const status = this.readString(payload.status, current.status); ++ current.status = status === "dlq" ? "dlq" : "retry_scheduled"; ++ current.attempts = this.readNumber(payload.attempt, current.attempts); ++ current.failureReason = ++ this.readOptionalString(payload.failureReason) ?? current.failureReason; ++ } ++ ++ nodes.set(eventId, current); ++ continue; ++ } ++ ++ if (row.stage.startsWith("glossary_anchor_")) { ++ const eventId = this.resolveEventId(row.replayKey, payload.eventId); ++ if (!eventId) { ++ continue; ++ } ++ ++ const current = ++ nodes.get(eventId) ?? ++ this.createInitialNode({ ++ eventId, ++ datasourceId: row.datasourceId, ++ occurredAt: row.createdAt, ++ payload ++ }); ++ current.eventType = this.readString( ++ payload.eventType, ++ row.stage === "glossary_anchor_release" ++ ? "glossary.anchor.created" ++ : "glossary.anchor.rollback" ++ ); ++ current.eventSource = "glossary"; ++ current.requestId = this.readOptionalString(payload.requestId) ?? current.requestId; ++ current.datasourceId = this.readString(payload.datasourceId, current.datasourceId); ++ current.sourceVersion = this.readGlossarySourceVersion(payload, current.sourceVersion); ++ current.idempotencyKey = this.readString( ++ payload.idempotencyKey, ++ current.idempotencyKey ++ ); ++ current.replayToken = this.readString(payload.replayToken, row.replayKey); ++ const normalizedStatus = this.readString( ++ payload.status, ++ row.stage === "glossary_anchor_rollback_noop" ? "noop" : "processed" ++ ); ++ current.status = ++ normalizedStatus === "noop" ++ ? "noop" ++ : normalizedStatus === "processed" ++ ? "processed" ++ : current.status; ++ current.attempts = this.readNumber(payload.attempt, 1); ++ current.processedAt = row.createdAt; ++ current.occurredAt = this.readString(payload.occurredAt, row.createdAt); ++ current.failureReason = ++ this.readOptionalString(payload.failureReason) ?? current.failureReason; ++ current.anchor = ++ this.readGlossaryAnchor(payload) ?? ++ current.anchor; ++ current.semanticSnapshot = ++ this.readSemanticSnapshot(payload) ?? ++ current.semanticSnapshot; ++ current.replay.push({ ++ replayKey: row.replayKey, ++ stage: row.stage, ++ createdAt: row.createdAt ++ }); ++ nodes.set(eventId, current); ++ } ++ } ++ ++ return Array.from(nodes.values()).map((item) => ({ ++ ...item, ++ replay: item.replay.sort( ++ (left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) ++ ) ++ })); ++ } ++ ++ private createInitialNode(input: { ++ eventId: string; ++ datasourceId: string; ++ occurredAt: string; ++ payload: Record; ++ }): EventNode { ++ return { ++ eventId: input.eventId, ++ eventSource: "incremental", ++ eventType: this.readString(input.payload.eventType, "unknown"), ++ requestId: this.readOptionalString(input.payload.requestId), ++ datasourceId: this.readString(input.payload.datasourceId, input.datasourceId), ++ sourceVersion: this.readString(input.payload.sourceVersion, "unknown"), ++ idempotencyKey: this.readString(input.payload.idempotencyKey, "unknown"), ++ replayToken: this.readString(input.payload.replayToken, "unknown"), ++ status: "retry_scheduled", ++ attempts: this.readNumber(input.payload.attempt, 1), ++ occurredAt: this.readString(input.payload.occurredAt, input.occurredAt), ++ replay: [] ++ }; ++ } ++ ++ private async loadIndexVersions( ++ nodes: EventNode[] ++ ): Promise< ++ Map< ++ string, ++ { ++ id: string; ++ status: string; ++ sourceVersion: string; ++ activatedAt?: string; ++ } ++ > ++ > { ++ const ids = Array.from( ++ new Set( ++ nodes ++ .map((item) => item.indexVersionId) ++ .filter((item): item is string => Boolean(item)) ++ ) ++ ); ++ const versions = await Promise.all( ++ ids.map(async (id) => { ++ const version = await this.ragIndexRepository.getVersionById(id); ++ if (!version) { ++ return undefined; ++ } ++ return { ++ id: version.id, ++ status: version.status, ++ sourceVersion: version.sourceVersion, ++ activatedAt: version.activatedAt ++ }; ++ }) ++ ); ++ ++ const map = new Map< ++ string, ++ { ++ id: string; ++ status: string; ++ sourceVersion: string; ++ activatedAt?: string; ++ } ++ >(); ++ for (const version of versions) { ++ if (version) { ++ map.set(version.id, version); ++ } ++ } ++ return map; ++ } ++ ++ private parsePayload(payload: string): Record { ++ try { ++ const parsed = JSON.parse(payload) as unknown; ++ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { ++ return {}; ++ } ++ return parsed as Record; ++ } catch { ++ return {}; ++ } ++ } ++ ++ private resolveEventId(replayKey: string, payloadEventId: unknown): string | undefined { ++ if (typeof payloadEventId === "string" && payloadEventId.trim()) { ++ return payloadEventId.trim(); ++ } ++ ++ const segments = replayKey.split(":"); ++ if (segments.length < 4) { ++ return undefined; ++ } ++ return segments[3]; ++ } ++ ++ private readGlossarySourceVersion(payload: Record, fallback: string): string { ++ const anchorVersion = payload.anchorVersion; ++ if (typeof anchorVersion === "number" && Number.isFinite(anchorVersion)) { ++ return `glossary-v${Math.floor(anchorVersion)}`; ++ } ++ return this.readString(anchorVersion, fallback); ++ } ++ ++ private readGlossaryAnchor(payload: Record): ++ | { ++ anchorId: string; ++ anchorType: "release" | "rollback"; ++ anchorVersion: number; ++ scope?: "global" | "datasource"; ++ scopeKey?: string; ++ rollbackFromAnchorId?: string; ++ rollbackReason?: string | null; ++ } ++ | undefined { ++ const anchorId = this.readOptionalString(payload.anchorId); ++ if (!anchorId) { ++ return undefined; ++ } ++ const anchorVersionRaw = payload.anchorVersion; ++ const anchorVersion = ++ typeof anchorVersionRaw === "number" && Number.isFinite(anchorVersionRaw) ++ ? Math.max(1, Math.floor(anchorVersionRaw)) ++ : 1; ++ const anchorTypeRaw = this.readString(payload.anchorType, "release"); ++ const anchorType = anchorTypeRaw === "rollback" ? "rollback" : "release"; ++ const scopeRaw = this.readOptionalString(payload.scope); ++ const scope = ++ scopeRaw === "global" || scopeRaw === "datasource" ? scopeRaw : undefined; ++ ++ return { ++ anchorId, ++ anchorType, ++ anchorVersion, ++ scope, ++ scopeKey: this.readOptionalString(payload.scopeKey), ++ rollbackFromAnchorId: this.readOptionalString(payload.rollbackFromAnchorId), ++ rollbackReason: ++ this.readOptionalString(payload.rollbackReason) ?? ++ (payload.rollbackReason === null ? null : undefined) ++ }; ++ } ++ ++ private readSemanticSnapshot(payload: Record): ++ | { ++ versionId: string; ++ domain: string; ++ semanticVersion: number; ++ status: "active" | "deprecated"; ++ riskTags: string[]; ++ } ++ | undefined { ++ const snapshotRaw = payload.semanticSnapshot; ++ if (!snapshotRaw || typeof snapshotRaw !== "object" || Array.isArray(snapshotRaw)) { ++ return undefined; ++ } ++ const snapshot = snapshotRaw as Record; ++ const versionId = this.readOptionalString(snapshot.versionId); ++ const domain = this.readOptionalString(snapshot.domain); ++ const semanticVersionRaw = snapshot.semanticVersion; ++ if (!versionId || !domain || typeof semanticVersionRaw !== "number") { ++ return undefined; ++ } ++ const statusRaw = this.readString(snapshot.status, "active"); ++ const status = statusRaw === "deprecated" ? "deprecated" : "active"; ++ const riskTagsRaw = snapshot.riskTags; ++ const riskTags = Array.isArray(riskTagsRaw) ++ ? riskTagsRaw ++ .map((tag) => (typeof tag === "string" ? tag.trim() : "")) ++ .filter((tag): tag is string => Boolean(tag)) ++ : []; ++ ++ return { ++ versionId, ++ domain, ++ semanticVersion: Math.max(1, Math.floor(semanticVersionRaw)), ++ status, ++ riskTags ++ }; ++ } ++ ++ private readString(input: unknown, fallback: string): string { ++ if (typeof input !== "string") { ++ return fallback; ++ } ++ const normalized = input.trim(); ++ return normalized || fallback; ++ } ++ ++ private readOptionalString(input: unknown): string | undefined { ++ if (typeof input !== "string") { ++ return undefined; ++ } ++ const normalized = input.trim(); ++ return normalized || undefined; ++ } ++ ++ private readNumber(input: unknown, fallback: number): number { ++ if (typeof input !== "number" || !Number.isFinite(input)) { ++ return fallback; ++ } ++ return Math.max(1, Math.floor(input)); ++ } ++ ++ private readMetadataEventId(metadata: Record | null | undefined): string { ++ const value = metadata?.eventId; ++ if (typeof value !== "string") { ++ return ""; ++ } ++ return value.trim(); ++ } ++ ++ private parseTimestamp(input?: string): number | undefined { ++ if (!input) { ++ return undefined; ++ } ++ const parsed = Date.parse(input); ++ if (Number.isNaN(parsed)) { ++ return undefined; ++ } ++ return parsed; ++ } ++ ++ private isWithinWindow( ++ occurredAt: string, ++ fromAt?: number, ++ toAt?: number ++ ): boolean { ++ const current = Date.parse(occurredAt); ++ if (Number.isNaN(current)) { ++ return true; ++ } ++ if (fromAt !== undefined && current < fromAt) { ++ return false; ++ } ++ if (toAt !== undefined && current > toAt) { ++ return false; ++ } ++ return true; ++ } ++} +diff --git a/apps/backend/src/modules/rag/events/rag-event-consumer.service.ts b/apps/backend/src/modules/rag/events/rag-event-consumer.service.ts +new file mode 100644 +index 0000000..6395449 +--- /dev/null ++++ b/apps/backend/src/modules/rag/events/rag-event-consumer.service.ts +@@ -0,0 +1,606 @@ ++import { createHash } from "node:crypto"; ++import { Injectable } from "@nestjs/common"; ++import { ModuleRef } from "@nestjs/core"; ++import { AuditLogRepository } from "../../data/persistence/audit-log.repository"; ++import { SemanticRegistryService } from "../../semantic-registry/semantic-registry.service"; ++import type { RagChunkBuildInput } from "../index/rag-index.repository"; ++import { BuildRagIndexJob } from "../jobs/build-rag-index.job"; ++import { RagReplayRepository } from "../observability/rag-replay.repository"; ++ ++export type RagIncrementalEventType = ++ | "ddl_changed" ++ | "semantic_promoted" ++ | "sql_feedback_positive"; ++ ++export interface RagIncrementalRefreshEvent { ++ eventId: string; ++ datasourceId: string; ++ sourceVersion: string; ++ eventType: RagIncrementalEventType; ++ runId?: string; ++ sessionId?: string; ++ requestId?: string; ++ replayToken?: string; ++ idempotencyKey?: string; ++ occurredAt?: string; ++ payload?: Record; ++} ++ ++export type RagEventConsumeStatus = ++ | "processed" ++ | "duplicate" ++ | "retry_scheduled" ++ | "dlq"; ++ ++export interface RagEventConsumeResult { ++ eventId: string; ++ datasourceId: string; ++ eventType: RagIncrementalEventType; ++ sourceVersion: string; ++ runId: string; ++ idempotencyKey: string; ++ replayToken: string; ++ attempts: number; ++ status: RagEventConsumeStatus; ++ indexVersionId?: string; ++ failureReason?: string; ++} ++ ++interface NormalizedEvent extends RagIncrementalRefreshEvent { ++ eventId: string; ++ datasourceId: string; ++ sourceVersion: string; ++ eventType: RagIncrementalEventType; ++ runId: string; ++ idempotencyKey: string; ++ replayToken: string; ++ occurredAt: string; ++ payload: Record; ++} ++ ++interface GlossaryPromotionTerm { ++ id: string; ++ term: string; ++ definition: string; ++ scope: "global" | "datasource"; ++ datasourceId?: string; ++ priority: number; ++ updatedAt: string; ++ synonyms: string[]; ++} ++ ++const MAX_EVENT_ATTEMPTS = 3; ++const GLOSSARY_CONFLICT_RESOLUTION = "priority_then_updated_at"; ++const SEMANTIC_PROMOTED_FAILURE_REASON = "semantic_promoted_linkage_failed"; ++const SEMANTIC_PROMOTED_DEGRADED_REASON = "semantic_promoted_linkage_degraded"; ++ ++@Injectable() ++export class RagEventConsumerService { ++ private readonly attemptsByIdempotency = new Map(); ++ private readonly terminalResultsByIdempotency = new Map(); ++ private semanticRegistryService?: SemanticRegistryService; ++ ++ constructor( ++ private readonly buildRagIndexJob: BuildRagIndexJob, ++ private readonly ragReplayRepository: RagReplayRepository, ++ private readonly auditLogRepository: AuditLogRepository, ++ private readonly moduleRef?: ModuleRef ++ ) {} ++ ++ async consumeEvent(input: RagIncrementalRefreshEvent): Promise { ++ const event = this.normalizeInput(input); ++ const terminalResult = this.terminalResultsByIdempotency.get(event.idempotencyKey); ++ if (terminalResult) { ++ return { ++ ...terminalResult, ++ status: "duplicate" ++ }; ++ } ++ ++ const attempts = (this.attemptsByIdempotency.get(event.idempotencyKey) ?? 0) + 1; ++ this.attemptsByIdempotency.set(event.idempotencyKey, attempts); ++ ++ await this.ragReplayRepository.writeReplay({ ++ runId: event.runId, ++ replayKey: `incremental:event:received:${event.eventId}`, ++ datasourceId: event.datasourceId, ++ stage: "incremental_event_received", ++ payload: { ++ eventId: event.eventId, ++ eventType: event.eventType, ++ sourceVersion: event.sourceVersion, ++ idempotencyKey: event.idempotencyKey, ++ replayToken: event.replayToken, ++ occurredAt: event.occurredAt, ++ attempt: attempts, ++ payload: event.payload ++ } ++ }); ++ ++ try { ++ const semanticPromotion = ++ event.eventType === "semantic_promoted" ++ ? await this.prepareSemanticPromotedPayload(event) ++ : undefined; ++ const buildResult = await this.buildRagIndexJob.run({ ++ datasourceId: event.datasourceId, ++ sourceVersion: event.sourceVersion, ++ buildReason: `incremental_refresh:${event.eventType}`, ++ runId: event.runId, ++ chunks: semanticPromotion?.chunks ++ }); ++ ++ const processedAt = new Date().toISOString(); ++ await this.ragReplayRepository.writeReplay({ ++ runId: event.runId, ++ replayKey: `incremental:event:processed:${event.eventId}`, ++ datasourceId: event.datasourceId, ++ stage: "incremental_event_processed", ++ indexVersionId: buildResult.indexVersionId, ++ payload: { ++ eventId: event.eventId, ++ eventType: event.eventType, ++ sourceVersion: event.sourceVersion, ++ idempotencyKey: event.idempotencyKey, ++ replayToken: event.replayToken, ++ attempt: attempts, ++ processedAt, ++ entryCount: buildResult.entryCount, ++ indexVersionId: buildResult.indexVersionId, ++ semanticPromotion ++ } ++ }); ++ ++ await this.auditLogRepository.appendEvent({ ++ runId: event.runId, ++ sessionId: event.sessionId, ++ requestId: event.requestId, ++ phase: "rag_incremental_refresh", ++ severity: "info", ++ eventType: "rag.incremental-refresh.processed", ++ eventCode: "EVENT_PROCESSED", ++ message: `incremental refresh event ${event.eventId} applied`, ++ metadata: { ++ eventId: event.eventId, ++ eventType: event.eventType, ++ datasourceId: event.datasourceId, ++ sourceVersion: event.sourceVersion, ++ idempotencyKey: event.idempotencyKey, ++ replayToken: event.replayToken, ++ attempts, ++ indexVersionId: buildResult.indexVersionId, ++ semanticPromotion, ++ processedAt ++ } ++ }); ++ ++ const result: RagEventConsumeResult = { ++ eventId: event.eventId, ++ datasourceId: event.datasourceId, ++ eventType: event.eventType, ++ sourceVersion: event.sourceVersion, ++ runId: event.runId, ++ idempotencyKey: event.idempotencyKey, ++ replayToken: event.replayToken, ++ attempts, ++ status: "processed", ++ indexVersionId: buildResult.indexVersionId ++ }; ++ this.attemptsByIdempotency.delete(event.idempotencyKey); ++ this.terminalResultsByIdempotency.set(event.idempotencyKey, result); ++ return { ...result }; ++ } catch (error) { ++ const failureReason = this.resolveFailureReason(error); ++ const status: RagEventConsumeStatus = ++ attempts >= MAX_EVENT_ATTEMPTS ? "dlq" : "retry_scheduled"; ++ ++ await this.ragReplayRepository.writeReplay({ ++ runId: event.runId, ++ replayKey: `incremental:event:failed:${event.eventId}:attempt:${attempts}`, ++ datasourceId: event.datasourceId, ++ stage: "incremental_event_failed", ++ payload: { ++ eventId: event.eventId, ++ eventType: event.eventType, ++ sourceVersion: event.sourceVersion, ++ idempotencyKey: event.idempotencyKey, ++ replayToken: event.replayToken, ++ attempt: attempts, ++ status, ++ failureReason ++ } ++ }); ++ ++ await this.auditLogRepository.appendEvent({ ++ runId: event.runId, ++ sessionId: event.sessionId, ++ requestId: event.requestId, ++ phase: "rag_incremental_refresh", ++ severity: status === "dlq" ? "error" : "warning", ++ eventType: ++ status === "dlq" ++ ? "rag.incremental-refresh.dlq" ++ : "rag.incremental-refresh.failed", ++ eventCode: ++ status === "dlq" ? "EVENT_DLQ" : "EVENT_RETRY_SCHEDULED", ++ message: ++ status === "dlq" ++ ? `incremental refresh event ${event.eventId} moved to dlq` ++ : `incremental refresh event ${event.eventId} scheduled for retry`, ++ metadata: { ++ eventId: event.eventId, ++ eventType: event.eventType, ++ datasourceId: event.datasourceId, ++ sourceVersion: event.sourceVersion, ++ idempotencyKey: event.idempotencyKey, ++ replayToken: event.replayToken, ++ attempts, ++ failureReason, ++ status ++ } ++ }); ++ ++ const result: RagEventConsumeResult = { ++ eventId: event.eventId, ++ datasourceId: event.datasourceId, ++ eventType: event.eventType, ++ sourceVersion: event.sourceVersion, ++ runId: event.runId, ++ idempotencyKey: event.idempotencyKey, ++ replayToken: event.replayToken, ++ attempts, ++ status, ++ failureReason ++ }; ++ ++ if (status === "dlq") { ++ this.attemptsByIdempotency.delete(event.idempotencyKey); ++ this.terminalResultsByIdempotency.set(event.idempotencyKey, result); ++ } ++ ++ return result; ++ } ++ } ++ ++ private async prepareSemanticPromotedPayload(event: NormalizedEvent): Promise<{ ++ chunkCount: number; ++ linkageStatus: "success" | "degraded"; ++ linkageDegradeReason?: string; ++ chunks: RagChunkBuildInput[]; ++ }> { ++ const payload = event.payload; ++ const glossaryTerms = this.readGlossaryTerms(payload, event.datasourceId); ++ if (glossaryTerms.length === 0) { ++ throw new Error(SEMANTIC_PROMOTED_FAILURE_REASON); ++ } ++ ++ const linkageStatus = this.readString(payload.linkageStatus) === "degraded" ? "degraded" : "success"; ++ const linkageDegradeReason = this.readString(payload.degradeReason); ++ const grouped = this.groupGlossaryTerms(glossaryTerms); ++ const chunks: RagChunkBuildInput[] = []; ++ ++ for (const [scopeKey, terms] of grouped.entries()) { ++ const resolvedTerms = this.resolveWinnersByConflictKey(terms); ++ for (const resolved of resolvedTerms) { ++ const winner = resolved.winner; ++ const candidatesForSemanticVersion = [ ++ winner, ++ ...resolved.losers ++ ]; ++ const canonicalDomain = ++ winner.scope === "datasource" && winner.datasourceId ++ ? this.getSemanticRegistryService()?.buildDatasourceScopedDomain( ++ "semantic_term", ++ winner.datasourceId ++ ) ?? this.buildFallbackDatasourceDomain("semantic_term", winner.datasourceId) ++ : "semantic_term"; ++ await this.publishSemanticRegistryFromGlossary({ ++ runId: event.runId, ++ occurredAt: event.occurredAt, ++ domain: canonicalDomain, ++ terms: candidatesForSemanticVersion, ++ winner ++ }); ++ ++ chunks.push({ ++ id: `semantic-promoted:${scopeKey}:${winner.term.toLowerCase()}`, ++ datasourceId: event.datasourceId, ++ domain: "semantic_term", ++ content: this.buildSemanticChunkContent(winner), ++ metadata: JSON.stringify({ ++ chunkProfile: "semantic_term", ++ tableNames: [], ++ columnNames: [], ++ glossary: { ++ source: "glossary", ++ scope: winner.scope, ++ scopeKey, ++ datasourceId: winner.datasourceId ?? null, ++ winnerTermId: winner.id, ++ loserTermIds: resolved.losers.map((item) => item.id), ++ conflictResolution: GLOSSARY_CONFLICT_RESOLUTION, ++ priority: winner.priority, ++ updatedAt: winner.updatedAt ++ }, ++ linkageStatus, ++ linkageDegradeReason: ++ linkageStatus === "degraded" ++ ? linkageDegradeReason ?? SEMANTIC_PROMOTED_DEGRADED_REASON ++ : undefined, ++ semanticHitClues: ["semantic_hit:glossary"] ++ }) ++ }); ++ } ++ } ++ ++ return { ++ chunkCount: chunks.length, ++ linkageStatus, ++ linkageDegradeReason: ++ linkageStatus === "degraded" ++ ? linkageDegradeReason ?? SEMANTIC_PROMOTED_DEGRADED_REASON ++ : undefined, ++ chunks ++ }; ++ } ++ ++ private readGlossaryTerms( ++ payload: Record, ++ eventDatasourceId: string ++ ): GlossaryPromotionTerm[] { ++ const raw = payload.glossaryTerms; ++ if (!Array.isArray(raw)) { ++ return []; ++ } ++ const terms: GlossaryPromotionTerm[] = []; ++ for (const item of raw) { ++ if (!this.isRecord(item)) { ++ continue; ++ } ++ const term = this.readString(item.term); ++ const definition = this.readString(item.definition); ++ if (!term || !definition) { ++ continue; ++ } ++ const scope = this.readString(item.scope) === "datasource" ? "datasource" : "global"; ++ const datasourceId = ++ scope === "datasource" ++ ? this.readString(item.datasourceId) ?? eventDatasourceId ++ : undefined; ++ const priorityRaw = item.priority; ++ const priority = ++ typeof priorityRaw === "number" && Number.isFinite(priorityRaw) ++ ? Math.floor(priorityRaw) ++ : 50; ++ const updatedAt = this.toIsoTimestamp( ++ this.readString(item.updatedAt) ?? new Date().toISOString() ++ ); ++ const synonyms = Array.isArray(item.synonyms) ++ ? item.synonyms ++ .map((candidate) => this.readString(candidate)) ++ .filter((candidate): candidate is string => Boolean(candidate)) ++ : []; ++ const id = ++ this.readString(item.id) ?? ++ `${scope}:${datasourceId ?? "global"}:${term.toLowerCase()}`; ++ terms.push({ ++ id, ++ term, ++ definition, ++ scope, ++ datasourceId, ++ priority, ++ updatedAt, ++ synonyms ++ }); ++ } ++ return terms; ++ } ++ ++ private groupGlossaryTerms(terms: GlossaryPromotionTerm[]): Map { ++ const grouped = new Map(); ++ for (const term of terms) { ++ const scopeKey = ++ term.scope === "datasource" ++ ? `datasource:${term.datasourceId ?? "unknown"}` ++ : "global"; ++ const bucket = grouped.get(scopeKey) ?? []; ++ bucket.push(term); ++ grouped.set(scopeKey, bucket); ++ } ++ return grouped; ++ } ++ ++ private resolveWinnersByConflictKey(terms: GlossaryPromotionTerm[]): Array<{ ++ winner: GlossaryPromotionTerm; ++ losers: GlossaryPromotionTerm[]; ++ }> { ++ const byNormalizedTerm = new Map(); ++ for (const term of terms) { ++ const key = term.term.trim().toLowerCase(); ++ const bucket = byNormalizedTerm.get(key) ?? []; ++ bucket.push(term); ++ byNormalizedTerm.set(key, bucket); ++ } ++ ++ const resolved: Array<{ winner: GlossaryPromotionTerm; losers: GlossaryPromotionTerm[] }> = []; ++ for (const bucket of byNormalizedTerm.values()) { ++ const sorted = [...bucket].sort((left, right) => { ++ if (left.priority !== right.priority) { ++ return right.priority - left.priority; ++ } ++ const updatedAtDiff = Date.parse(right.updatedAt) - Date.parse(left.updatedAt); ++ if (updatedAtDiff !== 0) { ++ return updatedAtDiff; ++ } ++ return left.id.localeCompare(right.id); ++ }); ++ resolved.push({ ++ winner: sorted[0]!, ++ losers: sorted.slice(1) ++ }); ++ } ++ return resolved; ++ } ++ ++ private async publishSemanticRegistryFromGlossary(input: { ++ runId: string; ++ occurredAt: string; ++ domain: string; ++ terms: GlossaryPromotionTerm[]; ++ winner: GlossaryPromotionTerm; ++ }): Promise { ++ const service = this.getSemanticRegistryService(); ++ if (!service) { ++ return; ++ } ++ ++ try { ++ await service.publishVersion({ ++ domain: input.domain, ++ releaseSummary: `semantic promoted from glossary (${input.winner.scope})`, ++ auditSummary: `event-driven semantic promotion for ${input.winner.term}`, ++ publishedByRunId: input.runId, ++ activatedByRunId: input.runId, ++ activatedAt: input.occurredAt, ++ riskTags: ["semantic_promoted"], ++ terms: input.terms.map((term) => ({ ++ term: term.term, ++ canonicalKey: this.buildCanonicalKey(term), ++ definition: term.definition, ++ binding: JSON.stringify({ ++ source: "glossary", ++ scope: term.scope, ++ datasourceId: term.datasourceId ?? null, ++ priority: term.priority, ++ updatedAt: term.updatedAt ++ }), ++ metadata: JSON.stringify({ ++ synonyms: term.synonyms, ++ conflictResolution: GLOSSARY_CONFLICT_RESOLUTION ++ }) ++ })) ++ }); ++ } catch { ++ // Best effort publish; retrieval linkage should remain available via promoted semantic chunks. ++ } ++ } ++ ++ private buildSemanticChunkContent(term: GlossaryPromotionTerm): string { ++ const synonyms = term.synonyms.length > 0 ? `; synonyms: ${term.synonyms.join(", ")}` : ""; ++ const scopeDetail = ++ term.scope === "datasource" && term.datasourceId ++ ? `datasource ${term.datasourceId}` ++ : "global"; ++ return `${term.term} (${scopeDetail}): ${term.definition}${synonyms}`; ++ } ++ ++ private buildCanonicalKey(term: GlossaryPromotionTerm): string { ++ const scopeKey = ++ term.scope === "datasource" && term.datasourceId ++ ? `datasource.${term.datasourceId}` ++ : "global"; ++ return `glossary.${scopeKey}.${term.term.trim().toLowerCase()}`; ++ } ++ ++ private buildFallbackDatasourceDomain(domain: string, datasourceId: string): string { ++ return `${domain.trim().toLowerCase()}::datasource::${datasourceId.trim().toLowerCase()}`; ++ } ++ ++ private getSemanticRegistryService(): SemanticRegistryService | undefined { ++ if (this.semanticRegistryService) { ++ return this.semanticRegistryService; ++ } ++ if (!this.moduleRef) { ++ return undefined; ++ } ++ this.semanticRegistryService = this.moduleRef.get(SemanticRegistryService, { ++ strict: false ++ }); ++ return this.semanticRegistryService; ++ } ++ ++ private normalizeInput(input: RagIncrementalRefreshEvent): NormalizedEvent { ++ const eventId = input.eventId?.trim(); ++ if (!eventId) { ++ throw new Error("rag event eventId 不能为空"); ++ } ++ const datasourceId = input.datasourceId?.trim(); ++ if (!datasourceId) { ++ throw new Error("rag event datasourceId 不能为空"); ++ } ++ const sourceVersion = input.sourceVersion?.trim(); ++ if (!sourceVersion) { ++ throw new Error("rag event sourceVersion 不能为空"); ++ } ++ const runId = input.runId?.trim() || `rag-event:${datasourceId}:${eventId}`; ++ const idempotencyKey = ++ input.idempotencyKey?.trim() || `${datasourceId}:${input.eventType}:${eventId}`; ++ const occurredAt = this.toIsoTimestamp(input.occurredAt); ++ const replayToken = ++ input.replayToken?.trim() || ++ this.buildReplayToken({ ++ datasourceId, ++ eventId, ++ eventType: input.eventType, ++ occurredAt ++ }); ++ ++ return { ++ ...input, ++ eventId, ++ datasourceId, ++ sourceVersion, ++ runId, ++ idempotencyKey, ++ replayToken, ++ occurredAt, ++ payload: input.payload ?? {} ++ }; ++ } ++ ++ private buildReplayToken(input: { ++ datasourceId: string; ++ eventId: string; ++ eventType: RagIncrementalEventType; ++ occurredAt: string; ++ }): string { ++ const digest = createHash("sha1") ++ .update( ++ `${input.datasourceId}|${input.eventType}|${input.eventId}|${input.occurredAt}` ++ ) ++ .digest("hex") ++ .slice(0, 20); ++ return `replay-${digest}`; ++ } ++ ++ private toIsoTimestamp(input?: string): string { ++ if (!input) { ++ return new Date().toISOString(); ++ } ++ const parsed = Date.parse(input); ++ if (Number.isNaN(parsed)) { ++ return new Date().toISOString(); ++ } ++ return new Date(parsed).toISOString(); ++ } ++ ++ private resolveFailureReason(error: unknown): string { ++ if (error instanceof Error && error.message.trim().length > 0) { ++ return error.message; ++ } ++ return String(error); ++ } ++ ++ private isRecord(value: unknown): value is Record { ++ return typeof value === "object" && value !== null && !Array.isArray(value); ++ } ++ ++ private readString(value: unknown): string | undefined { ++ if (typeof value !== "string") { ++ return undefined; ++ } ++ const normalized = value.trim(); ++ return normalized.length > 0 ? normalized : undefined; ++ } ++} +diff --git a/apps/backend/src/modules/rag/index/rag-index-builder.service.ts b/apps/backend/src/modules/rag/index/rag-index-builder.service.ts +new file mode 100644 +index 0000000..80c2704 +--- /dev/null ++++ b/apps/backend/src/modules/rag/index/rag-index-builder.service.ts +@@ -0,0 +1,153 @@ ++import { createHash } from "node:crypto"; ++import { Injectable } from "@nestjs/common"; ++import { DomainError } from "../../../common/domain-error"; ++import { ++ type RagChunkBuildInput, ++ type RagChunkIndexEntryRecord, ++ RagIndexRepository ++} from "./rag-index.repository"; ++ ++export interface RagIndexBuildRequest { ++ datasourceId: string; ++ sourceVersion: string; ++ buildReason?: string; ++ createdByRunId?: string; ++ activatedByRunId?: string; ++ chunks?: RagChunkBuildInput[]; ++} ++ ++export interface RagIndexBuildResult { ++ indexVersionId: string; ++ datasourceId: string; ++ status: "active"; ++ entryCount: number; ++ archivedChannels: Array<"lexical" | "dense">; ++ denseMode: "placeholder_vector_string"; ++} ++ ++@Injectable() ++export class RagIndexBuilderService { ++ constructor(private readonly repository: RagIndexRepository) {} ++ ++ async buildAndActivate(input: RagIndexBuildRequest): Promise { ++ const chunks = ++ input.chunks ?? (await this.repository.listChunksForBuild(input.datasourceId)); ++ if (chunks.length === 0) { ++ throw new DomainError( ++ "RAG_INDEX_BUILD_EMPTY_INPUT", ++ "索引构建输入为空,无法生成新版本。", ++ 400, ++ { ++ datasourceId: input.datasourceId ++ } ++ ); ++ } ++ ++ const version = await this.repository.createBuildingVersion({ ++ datasourceId: input.datasourceId, ++ sourceVersion: input.sourceVersion, ++ buildReason: input.buildReason, ++ createdByRunId: input.createdByRunId ++ }); ++ ++ try { ++ const now = new Date().toISOString(); ++ const entries = chunks.map((chunk) => this.toIndexEntry(chunk, version.id, now)); ++ ++ await this.repository.replaceEntriesForVersion(version.id, entries); ++ await this.repository.markVersionReady(version.id); ++ const active = await this.repository.activateVersion({ ++ datasourceId: input.datasourceId, ++ indexVersionId: version.id, ++ activatedByRunId: input.activatedByRunId ++ }); ++ const archived = await this.repository.listEntriesByVersion(version.id); ++ ++ return { ++ indexVersionId: active.id, ++ datasourceId: active.datasourceId, ++ status: "active", ++ entryCount: archived.length, ++ archivedChannels: ["lexical", "dense"], ++ denseMode: "placeholder_vector_string" ++ }; ++ } catch (error) { ++ await this.safeDeprecate(version.id); ++ throw error; ++ } ++ } ++ ++ private toIndexEntry( ++ chunk: RagChunkBuildInput, ++ indexVersionId: string, ++ timestamp: string ++ ): RagChunkIndexEntryRecord { ++ const lexicalContent = chunk.content.trim(); ++ if (!lexicalContent) { ++ throw new DomainError("RAG_INDEX_EMPTY_CHUNK", "切块内容为空,无法构建索引条目。", 400, { ++ chunkId: chunk.id, ++ datasourceId: chunk.datasourceId ++ }); ++ } ++ ++ return { ++ id: this.buildEntryId(indexVersionId, chunk.id, lexicalContent), ++ indexVersionId, ++ chunkId: chunk.id, ++ datasourceId: chunk.datasourceId, ++ domain: chunk.domain, ++ lexicalContent, ++ denseVector: this.buildPlaceholderDenseVector(lexicalContent), ++ metadata: this.buildEntryMetadata(chunk.metadata), ++ createdAt: timestamp, ++ updatedAt: timestamp ++ }; ++ } ++ ++ private buildEntryId(indexVersionId: string, chunkId: string, content: string): string { ++ return createHash("sha256") ++ .update(`${indexVersionId}|${chunkId}|${content}`) ++ .digest("hex"); ++ } ++ ++ private buildPlaceholderDenseVector(content: string): string { ++ const digest = createHash("sha256").update(content).digest(); ++ const dimensions = 8; ++ const vector = Array.from({ length: dimensions }, (_, index) => { ++ const byte = digest[index] ?? 0; ++ const normalized = byte / 255; ++ const scaled = normalized * 2 - 1; ++ return Number(scaled.toFixed(6)); ++ }); ++ return JSON.stringify(vector); ++ } ++ ++ private buildEntryMetadata(rawMetadata?: string): string { ++ const sourceMetadata = ++ rawMetadata && rawMetadata.trim().length > 0 ++ ? this.safeParseJson(rawMetadata) ++ : undefined; ++ ++ return JSON.stringify({ ++ denseMode: "placeholder_vector_string", ++ lexicalMode: "plain_text", ++ ...(sourceMetadata ? { sourceMetadata } : {}) ++ }); ++ } ++ ++ private safeParseJson(value: string): unknown { ++ try { ++ return JSON.parse(value); ++ } catch { ++ return { raw: value }; ++ } ++ } ++ ++ private async safeDeprecate(indexVersionId: string): Promise { ++ const version = await this.repository.getVersionById(indexVersionId); ++ if (!version || version.status === "active") { ++ return; ++ } ++ await this.repository.markVersionDeprecated(indexVersionId); ++ } ++} +diff --git a/apps/backend/src/modules/rag/index/rag-index.repository.ts b/apps/backend/src/modules/rag/index/rag-index.repository.ts +new file mode 100644 +index 0000000..07d68e0 +--- /dev/null ++++ b/apps/backend/src/modules/rag/index/rag-index.repository.ts +@@ -0,0 +1,718 @@ ++import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; ++import { v4 as uuidv4 } from "uuid"; ++import { AppConfigService } from "../../config/app-config.service"; ++import { DomainError } from "../../../common/domain-error"; ++ ++export type RagIndexStatus = "building" | "ready" | "active" | "deprecated"; ++ ++export interface RagIndexVersionRecord { ++ id: string; ++ datasourceId: string; ++ status: RagIndexStatus; ++ sourceVersion: string; ++ buildReason?: string; ++ createdByRunId?: string; ++ activatedByRunId?: string; ++ activatedAt?: string; ++ createdAt: string; ++ updatedAt: string; ++} ++ ++export interface RagChunkBuildInput { ++ id: string; ++ datasourceId: string; ++ domain: string; ++ content: string; ++ metadata?: string; ++} ++ ++export interface RagChunkIndexEntryRecord { ++ id: string; ++ indexVersionId: string; ++ chunkId: string; ++ datasourceId: string; ++ domain: string; ++ lexicalContent: string; ++ denseVector?: string; ++ metadata?: string; ++ createdAt: string; ++ updatedAt: string; ++} ++ ++export interface CreateBuildingVersionInput { ++ datasourceId: string; ++ sourceVersion: string; ++ buildReason?: string; ++ createdByRunId?: string; ++} ++ ++export interface ActivateVersionInput { ++ datasourceId: string; ++ indexVersionId: string; ++ activatedByRunId?: string; ++ simulateFailure?: "after_deprecating_current_active"; ++} ++ ++type PrismaClientLike = { ++ ragChunk: { ++ findMany: (args: Record) => Promise; ++ }; ++ ragIndexVersion: { ++ create: (args: Record) => Promise; ++ update: (args: Record) => Promise; ++ updateMany: (args: Record) => Promise<{ count: number }>; ++ findMany: (args: Record) => Promise; ++ findUnique: (args: Record) => Promise; ++ }; ++ ragChunkIndexEntry: { ++ deleteMany: (args: Record) => Promise<{ count: number }>; ++ createMany: (args: Record) => Promise<{ count: number }>; ++ findMany: (args: Record) => Promise; ++ }; ++ $transaction: (fn: (tx: PrismaTransactionClientLike) => Promise) => Promise; ++ $disconnect: () => Promise; ++}; ++ ++type PrismaTransactionClientLike = Omit; ++ ++type RagChunkRow = { ++ id: string; ++ datasourceId: string; ++ domain: string; ++ content: string; ++ metadata: string | null; ++ chunkOrder: number; ++}; ++ ++type RagIndexVersionRow = { ++ id: string; ++ datasourceId: string; ++ status: string; ++ sourceVersion: string; ++ buildReason: string | null; ++ createdByRunId: string | null; ++ activatedByRunId: string | null; ++ activatedAt: Date | null; ++ createdAt: Date; ++ updatedAt: Date; ++}; ++ ++type RagChunkIndexEntryRow = { ++ id: string; ++ indexVersionId: string; ++ chunkId: string; ++ datasourceId: string; ++ domain: string; ++ lexicalContent: string; ++ denseVector: string | null; ++ metadata: string | null; ++ createdAt: Date; ++ updatedAt: Date; ++}; ++ ++@Injectable() ++export class RagIndexRepository implements OnModuleInit, OnModuleDestroy { ++ private readonly logger = new Logger(RagIndexRepository.name); ++ private prisma?: PrismaClientLike; ++ private readonly indexVersions = new Map(); ++ private readonly entriesByVersionId = new Map(); ++ private readonly chunksByDatasource = new Map(); ++ private readonly activationLocks = new Map>(); ++ ++ constructor(private readonly appConfig: AppConfigService) {} ++ ++ async onModuleInit(): Promise { ++ if (!this.isPrimaryPersistenceConfigured()) { ++ return; ++ } ++ ++ try { ++ const prismaClientModulePath = "../../../generated/prisma/client"; ++ const prismaModule = (await import(prismaClientModulePath)) as unknown as { ++ PrismaClient?: new (...args: unknown[]) => PrismaClientLike; ++ default?: { ++ PrismaClient?: new (...args: unknown[]) => PrismaClientLike; ++ }; ++ }; ++ const adapterModule = (await import("@prisma/adapter-pg")) as unknown as { ++ PrismaPg?: new (...args: unknown[]) => unknown; ++ default?: { ++ PrismaPg?: new (...args: unknown[]) => unknown; ++ }; ++ }; ++ const PrismaCtor = prismaModule.PrismaClient ?? prismaModule.default?.PrismaClient; ++ const PrismaPgCtor = adapterModule.PrismaPg ?? adapterModule.default?.PrismaPg; ++ if (!PrismaCtor) { ++ throw new Error("PrismaClient 未生成,请先执行 prisma generate"); ++ } ++ if (!PrismaPgCtor) { ++ throw new Error("Prisma PostgreSQL adapter 未安装"); ++ } ++ const adapter = new PrismaPgCtor({ ++ connectionString: this.appConfig.databaseUrl ++ }); ++ this.prisma = new PrismaCtor({ ++ adapter ++ }) as PrismaClientLike; ++ this.logger.log("RAG Index 仓储已启用 PostgreSQL 持久化。"); ++ } catch (error) { ++ this.logger.warn( ++ `RAG Index 仓储初始化失败,降级为内存模式: ${ ++ error instanceof Error ? error.message : String(error) ++ }` ++ ); ++ } ++ } ++ ++ async onModuleDestroy(): Promise { ++ if (this.prisma) { ++ await this.prisma.$disconnect(); ++ } ++ } ++ ++ seedChunksForDatasource(datasourceId: string, chunks: RagChunkBuildInput[]): void { ++ this.chunksByDatasource.set( ++ datasourceId, ++ chunks.map((chunk) => ({ ++ id: chunk.id, ++ datasourceId: chunk.datasourceId, ++ domain: chunk.domain, ++ content: chunk.content, ++ metadata: chunk.metadata ++ })) ++ ); ++ } ++ ++ async listChunksForBuild(datasourceId: string): Promise { ++ const memory = this.chunksByDatasource.get(datasourceId) ?? []; ++ if (!this.isPrimaryPersistenceConfigured() || !this.prisma) { ++ return memory.map((item) => ({ ...item })); ++ } ++ ++ const rows = (await this.tryPrismaRead(async () => ++ this.prisma?.ragChunk.findMany({ ++ where: { datasourceId }, ++ orderBy: [{ chunkOrder: "asc" }, { createdAt: "asc" }] ++ }) ++ )) as RagChunkRow[] | null; ++ ++ if (!rows || rows.length === 0) { ++ return memory.map((item) => ({ ...item })); ++ } ++ ++ const fromDb = rows.map((row) => ({ ++ id: row.id, ++ datasourceId: row.datasourceId, ++ domain: row.domain, ++ content: row.content, ++ metadata: row.metadata ?? undefined ++ })); ++ this.chunksByDatasource.set(datasourceId, fromDb); ++ return fromDb.map((item) => ({ ...item })); ++ } ++ ++ async createBuildingVersion( ++ input: CreateBuildingVersionInput ++ ): Promise { ++ const now = new Date().toISOString(); ++ const next: RagIndexVersionRecord = { ++ id: uuidv4(), ++ datasourceId: input.datasourceId, ++ status: "building", ++ sourceVersion: input.sourceVersion, ++ buildReason: input.buildReason, ++ createdByRunId: input.createdByRunId, ++ createdAt: now, ++ updatedAt: now ++ }; ++ ++ this.indexVersions.set(next.id, next); ++ ++ if (this.isPrimaryPersistenceConfigured() && this.prisma) { ++ await this.tryPrismaWrite(async () => { ++ await this.prisma?.ragIndexVersion.create({ ++ data: { ++ id: next.id, ++ datasourceId: next.datasourceId, ++ status: next.status, ++ sourceVersion: next.sourceVersion, ++ buildReason: next.buildReason ?? null, ++ createdByRunId: next.createdByRunId ?? null, ++ createdAt: new Date(next.createdAt), ++ updatedAt: new Date(next.updatedAt) ++ } ++ }); ++ }); ++ } ++ ++ return { ...next }; ++ } ++ ++ async replaceEntriesForVersion( ++ indexVersionId: string, ++ entries: RagChunkIndexEntryRecord[] ++ ): Promise { ++ const current = await this.getVersionById(indexVersionId); ++ if (!current) { ++ throw new DomainError( ++ "RAG_INDEX_VERSION_NOT_FOUND", ++ `索引版本不存在: ${indexVersionId}`, ++ 404, ++ { indexVersionId } ++ ); ++ } ++ ++ this.entriesByVersionId.set( ++ indexVersionId, ++ entries.map((entry) => ({ ...entry })) ++ ); ++ ++ if (this.isPrimaryPersistenceConfigured() && this.prisma) { ++ await this.tryPrismaWrite(async () => { ++ await this.prisma?.ragChunkIndexEntry.deleteMany({ ++ where: { indexVersionId } ++ }); ++ if (entries.length === 0) { ++ return; ++ } ++ await this.prisma?.ragChunkIndexEntry.createMany({ ++ data: entries.map((entry) => ({ ++ id: entry.id, ++ indexVersionId: entry.indexVersionId, ++ chunkId: entry.chunkId, ++ datasourceId: entry.datasourceId, ++ domain: entry.domain, ++ lexicalContent: entry.lexicalContent, ++ denseVector: entry.denseVector ?? null, ++ metadata: entry.metadata ?? null, ++ createdAt: new Date(entry.createdAt), ++ updatedAt: new Date(entry.updatedAt) ++ })) ++ }); ++ }); ++ } ++ } ++ ++ async markVersionReady(indexVersionId: string): Promise { ++ return this.updateVersionStatus(indexVersionId, "ready"); ++ } ++ ++ async markVersionDeprecated(indexVersionId: string): Promise { ++ return this.updateVersionStatus(indexVersionId, "deprecated"); ++ } ++ ++ async activateVersion(input: ActivateVersionInput): Promise { ++ return this.withDatasourceActivationLock(input.datasourceId, async () => { ++ if (this.isPrimaryPersistenceConfigured() && this.prisma) { ++ return this.activateWithPrisma(input); ++ } ++ return this.activateInMemory(input); ++ }); ++ } ++ ++ async getVersionById(indexVersionId: string): Promise { ++ const memory = this.indexVersions.get(indexVersionId); ++ if (memory) { ++ return { ...memory }; ++ } ++ ++ if (!this.isPrimaryPersistenceConfigured() || !this.prisma) { ++ return undefined; ++ } ++ ++ const row = (await this.tryPrismaRead(async () => ++ this.prisma?.ragIndexVersion.findUnique({ ++ where: { id: indexVersionId } ++ }) ++ )) as RagIndexVersionRow | null; ++ ++ if (!row) { ++ return undefined; ++ } ++ ++ const version = this.fromRagIndexVersionRow(row); ++ this.indexVersions.set(version.id, version); ++ return { ...version }; ++ } ++ ++ async getActiveVersion(datasourceId: string): Promise { ++ const fromMemory = this.findActiveVersionFromMemory(datasourceId); ++ if (fromMemory) { ++ return fromMemory; ++ } ++ ++ if (!this.isPrimaryPersistenceConfigured() || !this.prisma) { ++ return undefined; ++ } ++ ++ const rows = (await this.tryPrismaRead(async () => ++ this.prisma?.ragIndexVersion.findMany({ ++ where: { datasourceId, status: "active" }, ++ orderBy: [{ activatedAt: "desc" }, { updatedAt: "desc" }], ++ take: 1 ++ }) ++ )) as RagIndexVersionRow[] | null; ++ ++ if (!rows || rows.length === 0) { ++ return undefined; ++ } ++ ++ const version = this.fromRagIndexVersionRow(rows[0]); ++ this.indexVersions.set(version.id, version); ++ return { ...version }; ++ } ++ ++ async listVersionsByDatasource(datasourceId: string): Promise { ++ const memory = Array.from(this.indexVersions.values()) ++ .filter((item) => item.datasourceId === datasourceId) ++ .sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt)) ++ .map((item) => ({ ...item })); ++ ++ if (!this.isPrimaryPersistenceConfigured() || !this.prisma) { ++ return memory; ++ } ++ ++ const rows = (await this.tryPrismaRead(async () => ++ this.prisma?.ragIndexVersion.findMany({ ++ where: { datasourceId }, ++ orderBy: [{ createdAt: "asc" }, { updatedAt: "asc" }] ++ }) ++ )) as RagIndexVersionRow[] | null; ++ ++ if (!rows) { ++ return memory; ++ } ++ ++ const merged = new Map(); ++ for (const row of rows) { ++ const version = this.fromRagIndexVersionRow(row); ++ merged.set(version.id, version); ++ this.indexVersions.set(version.id, version); ++ } ++ for (const version of memory) { ++ merged.set(version.id, version); ++ } ++ ++ return Array.from(merged.values()).sort( ++ (left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) ++ ); ++ } ++ ++ async listEntriesByVersion(indexVersionId: string): Promise { ++ const memory = this.entriesByVersionId.get(indexVersionId); ++ if (memory) { ++ return memory.map((entry) => ({ ...entry })); ++ } ++ ++ if (!this.isPrimaryPersistenceConfigured() || !this.prisma) { ++ return []; ++ } ++ ++ const rows = (await this.tryPrismaRead(async () => ++ this.prisma?.ragChunkIndexEntry.findMany({ ++ where: { indexVersionId }, ++ orderBy: [{ createdAt: "asc" }, { id: "asc" }] ++ }) ++ )) as RagChunkIndexEntryRow[] | null; ++ ++ if (!rows) { ++ return []; ++ } ++ ++ const entries = rows.map((row) => this.fromRagChunkIndexEntryRow(row)); ++ this.entriesByVersionId.set( ++ indexVersionId, ++ entries.map((entry) => ({ ...entry })) ++ ); ++ return entries; ++ } ++ ++ private async activateInMemory( ++ input: ActivateVersionInput ++ ): Promise { ++ const target = this.indexVersions.get(input.indexVersionId); ++ if (!target || target.datasourceId !== input.datasourceId) { ++ throw new DomainError("RAG_INDEX_VERSION_NOT_FOUND", "待激活索引版本不存在。", 404, { ++ indexVersionId: input.indexVersionId, ++ datasourceId: input.datasourceId ++ }); ++ } ++ if (target.status !== "ready") { ++ throw new DomainError( ++ "RAG_INDEX_VERSION_INVALID_STATUS", ++ "仅 ready 状态索引可激活。", ++ 409, ++ { ++ indexVersionId: input.indexVersionId, ++ status: target.status ++ } ++ ); ++ } ++ ++ const snapshot = this.snapshotDatasourceVersions(input.datasourceId); ++ try { ++ const now = new Date().toISOString(); ++ for (const version of this.indexVersions.values()) { ++ if (version.datasourceId === input.datasourceId && version.status === "active") { ++ version.status = "deprecated"; ++ version.updatedAt = now; ++ this.indexVersions.set(version.id, { ...version }); ++ } ++ } ++ ++ if (input.simulateFailure === "after_deprecating_current_active") { ++ throw new Error("simulated activation failure"); ++ } ++ ++ const next: RagIndexVersionRecord = { ++ ...target, ++ status: "active", ++ activatedAt: now, ++ activatedByRunId: input.activatedByRunId, ++ updatedAt: now ++ }; ++ this.indexVersions.set(next.id, next); ++ return { ...next }; ++ } catch (error) { ++ this.restoreDatasourceVersions(input.datasourceId, snapshot); ++ throw error; ++ } ++ } ++ ++ private async activateWithPrisma( ++ input: ActivateVersionInput ++ ): Promise { ++ const result = await this.tryPrismaWrite(async () => ++ this.prisma?.$transaction(async (tx) => { ++ const target = (await tx.ragIndexVersion.findUnique({ ++ where: { id: input.indexVersionId } ++ })) as RagIndexVersionRow | null; ++ if (!target || target.datasourceId !== input.datasourceId) { ++ throw new DomainError("RAG_INDEX_VERSION_NOT_FOUND", "待激活索引版本不存在。", 404, { ++ indexVersionId: input.indexVersionId, ++ datasourceId: input.datasourceId ++ }); ++ } ++ if (target.status !== "ready") { ++ throw new DomainError( ++ "RAG_INDEX_VERSION_INVALID_STATUS", ++ "仅 ready 状态索引可激活。", ++ 409, ++ { ++ indexVersionId: input.indexVersionId, ++ status: target.status ++ } ++ ); ++ } ++ ++ const now = new Date(); ++ await tx.ragIndexVersion.updateMany({ ++ where: { ++ datasourceId: input.datasourceId, ++ status: "active" ++ }, ++ data: { ++ status: "deprecated", ++ updatedAt: now ++ } ++ }); ++ ++ if (input.simulateFailure === "after_deprecating_current_active") { ++ throw new Error("simulated activation failure"); ++ } ++ ++ const activated = (await tx.ragIndexVersion.update({ ++ where: { id: input.indexVersionId }, ++ data: { ++ status: "active", ++ activatedAt: now, ++ activatedByRunId: input.activatedByRunId ?? null, ++ updatedAt: now ++ } ++ })) as RagIndexVersionRow; ++ return this.fromRagIndexVersionRow(activated); ++ }) ++ ); ++ ++ if (!result) { ++ throw new Error("RAG 激活事务返回空结果"); ++ } ++ ++ this.indexVersions.set(result.id, result); ++ return { ...result }; ++ } ++ ++ private async updateVersionStatus( ++ indexVersionId: string, ++ status: RagIndexStatus ++ ): Promise { ++ const current = await this.getVersionById(indexVersionId); ++ if (!current) { ++ throw new DomainError( ++ "RAG_INDEX_VERSION_NOT_FOUND", ++ `索引版本不存在: ${indexVersionId}`, ++ 404, ++ { indexVersionId } ++ ); ++ } ++ ++ const next: RagIndexVersionRecord = { ++ ...current, ++ status, ++ updatedAt: new Date().toISOString() ++ }; ++ this.indexVersions.set(next.id, next); ++ ++ if (this.isPrimaryPersistenceConfigured() && this.prisma) { ++ await this.tryPrismaWrite(async () => { ++ await this.prisma?.ragIndexVersion.update({ ++ where: { id: next.id }, ++ data: { ++ status: next.status, ++ updatedAt: new Date(next.updatedAt) ++ } ++ }); ++ }); ++ } ++ ++ return { ...next }; ++ } ++ ++ private findActiveVersionFromMemory( ++ datasourceId: string ++ ): RagIndexVersionRecord | undefined { ++ const active = Array.from(this.indexVersions.values()) ++ .filter((item) => item.datasourceId === datasourceId && item.status === "active") ++ .sort((left, right) => { ++ const rightTime = Date.parse(right.activatedAt ?? right.updatedAt); ++ const leftTime = Date.parse(left.activatedAt ?? left.updatedAt); ++ return rightTime - leftTime; ++ })[0]; ++ ++ return active ? { ...active } : undefined; ++ } ++ ++ private snapshotDatasourceVersions( ++ datasourceId: string ++ ): Map { ++ const snapshot = new Map(); ++ for (const [id, value] of this.indexVersions.entries()) { ++ if (value.datasourceId === datasourceId) { ++ snapshot.set(id, { ...value }); ++ } ++ } ++ return snapshot; ++ } ++ ++ private restoreDatasourceVersions( ++ datasourceId: string, ++ snapshot: Map ++ ): void { ++ for (const [id, value] of this.indexVersions.entries()) { ++ if (value.datasourceId === datasourceId && !snapshot.has(id)) { ++ this.indexVersions.delete(id); ++ } ++ } ++ for (const [id, value] of snapshot.entries()) { ++ this.indexVersions.set(id, { ...value }); ++ } ++ } ++ ++ private async withDatasourceActivationLock( ++ datasourceId: string, ++ task: () => Promise ++ ): Promise { ++ const previous = this.activationLocks.get(datasourceId) ?? Promise.resolve(); ++ let release: () => void = () => undefined; ++ const current = new Promise((resolve) => { ++ release = resolve; ++ }); ++ this.activationLocks.set(datasourceId, current); ++ ++ await previous; ++ try { ++ return await task(); ++ } finally { ++ release(); ++ if (this.activationLocks.get(datasourceId) === current) { ++ this.activationLocks.delete(datasourceId); ++ } ++ } ++ } ++ ++ private fromRagIndexVersionRow(row: RagIndexVersionRow): RagIndexVersionRecord { ++ return { ++ id: row.id, ++ datasourceId: row.datasourceId, ++ status: this.toRagIndexStatus(row.status), ++ sourceVersion: row.sourceVersion, ++ buildReason: row.buildReason ?? undefined, ++ createdByRunId: row.createdByRunId ?? undefined, ++ activatedByRunId: row.activatedByRunId ?? undefined, ++ activatedAt: row.activatedAt?.toISOString(), ++ createdAt: row.createdAt.toISOString(), ++ updatedAt: row.updatedAt.toISOString() ++ }; ++ } ++ ++ private fromRagChunkIndexEntryRow( ++ row: RagChunkIndexEntryRow ++ ): RagChunkIndexEntryRecord { ++ return { ++ id: row.id, ++ indexVersionId: row.indexVersionId, ++ chunkId: row.chunkId, ++ datasourceId: row.datasourceId, ++ domain: row.domain, ++ lexicalContent: row.lexicalContent, ++ denseVector: row.denseVector ?? undefined, ++ metadata: row.metadata ?? undefined, ++ createdAt: row.createdAt.toISOString(), ++ updatedAt: row.updatedAt.toISOString() ++ }; ++ } ++ ++ private toRagIndexStatus(status: string): RagIndexStatus { ++ switch (status) { ++ case "building": ++ case "ready": ++ case "active": ++ case "deprecated": ++ return status; ++ default: ++ this.logger.warn(`未知 RAG 索引状态 ${status},已回退为 deprecated`); ++ return "deprecated"; ++ } ++ } ++ ++ private isPrimaryPersistenceConfigured(): boolean { ++ return Boolean(this.appConfig.databaseUrl); ++ } ++ ++ private async tryPrismaWrite(op: () => Promise): Promise { ++ try { ++ return await op(); ++ } catch (error) { ++ this.logger.warn( ++ `RAG Prisma 写入失败,保持内存数据可读: ${ ++ error instanceof Error ? error.message : String(error) ++ }` ++ ); ++ return undefined; ++ } ++ } ++ ++ private async tryPrismaRead(op: () => Promise): Promise { ++ try { ++ const result = await op(); ++ return result ?? null; ++ } catch (error) { ++ this.logger.warn( ++ `RAG Prisma 读取失败,回退内存缓存: ${ ++ error instanceof Error ? error.message : String(error) ++ }` ++ ); ++ return null; ++ } ++ } ++} +diff --git a/apps/backend/src/modules/rag/ingestion/chunk-profiles.ts b/apps/backend/src/modules/rag/ingestion/chunk-profiles.ts +new file mode 100644 +index 0000000..18dd303 +--- /dev/null ++++ b/apps/backend/src/modules/rag/ingestion/chunk-profiles.ts +@@ -0,0 +1,37 @@ ++export type RagChunkProfile = ++ | "schema_table" ++ | "schema_column" ++ | "sql_example" ++ | "semantic_term"; ++ ++export interface ChunkProfileConfig { ++ maxCharacters: number; ++ overlapCharacters: number; ++ hardChunkLimit: number; ++} ++ ++const CHUNK_PROFILE_CONFIGS: Record = { ++ schema_table: { ++ maxCharacters: 1200, ++ overlapCharacters: 120, ++ hardChunkLimit: 5000 ++ }, ++ schema_column: { ++ maxCharacters: 700, ++ overlapCharacters: 100, ++ hardChunkLimit: 5000 ++ }, ++ sql_example: { ++ maxCharacters: 1100, ++ overlapCharacters: 140, ++ hardChunkLimit: 5000 ++ }, ++ semantic_term: { ++ maxCharacters: 800, ++ overlapCharacters: 120, ++ hardChunkLimit: 5000 ++ } ++}; ++ ++export const getChunkProfileConfig = (profile: RagChunkProfile): ChunkProfileConfig => ++ CHUNK_PROFILE_CONFIGS[profile]; +diff --git a/apps/backend/src/modules/rag/ingestion/ingestion-source.adapter.ts b/apps/backend/src/modules/rag/ingestion/ingestion-source.adapter.ts +new file mode 100644 +index 0000000..75e62c1 +--- /dev/null ++++ b/apps/backend/src/modules/rag/ingestion/ingestion-source.adapter.ts +@@ -0,0 +1,174 @@ ++import { Injectable } from "@nestjs/common"; ++import type { RagChunkProfile } from "./chunk-profiles"; ++ ++export type RagIngestionSourceType = "schema" | "sql_example" | "semantic_term"; ++ ++interface IngestionBaseInput { ++ datasourceId: string; ++ sourceVersion: string; ++ contentChecksum: string; ++ sourceRef?: string; ++ metadata?: Record; ++} ++ ++export interface SchemaIngestionSourceInput extends IngestionBaseInput { ++ sourceType: "schema"; ++ tableName: string; ++ columnName?: string; ++ ddl?: string; ++ description?: string; ++} ++ ++export interface SqlExampleIngestionSourceInput extends IngestionBaseInput { ++ sourceType: "sql_example"; ++ exampleId?: string; ++ question: string; ++ sql: string; ++ rationale?: string; ++ tableNames?: string[]; ++ columnNames?: string[]; ++} ++ ++export interface SemanticTermIngestionSourceInput extends IngestionBaseInput { ++ sourceType: "semantic_term"; ++ termId?: string; ++ term: string; ++ definition: string; ++ synonyms?: string[]; ++ tableNames?: string[]; ++ columnNames?: string[]; ++} ++ ++export type IngestionSourceInput = ++ | SchemaIngestionSourceInput ++ | SqlExampleIngestionSourceInput ++ | SemanticTermIngestionSourceInput; ++ ++export interface NormalizedRagDocumentInput { ++ datasourceId: string; ++ domain: string; ++ sourceType: RagIngestionSourceType; ++ sourceRef?: string; ++ sourceVersion: string; ++ contentChecksum: string; ++ title?: string; ++ content: string; ++ tableNames: string[]; ++ columnNames: string[]; ++ metadata?: Record; ++ chunkProfile: RagChunkProfile; ++} ++ ++@Injectable() ++export class IngestionSourceAdapter { ++ normalize(input: IngestionSourceInput): NormalizedRagDocumentInput { ++ switch (input.sourceType) { ++ case "schema": ++ return this.normalizeSchemaSource(input); ++ case "sql_example": ++ return this.normalizeSqlExampleSource(input); ++ case "semantic_term": ++ return this.normalizeSemanticTermSource(input); ++ default: { ++ const exhaustive: never = input; ++ return exhaustive; ++ } ++ } ++ } ++ ++ private normalizeSchemaSource(input: SchemaIngestionSourceInput): NormalizedRagDocumentInput { ++ const tableName = input.tableName.trim(); ++ const columnName = input.columnName?.trim() || undefined; ++ const sourceRef = input.sourceRef ?? (columnName ? `${tableName}.${columnName}` : tableName); ++ const title = columnName ? `Schema Column ${sourceRef}` : `Schema Table ${tableName}`; ++ const contentParts = [ ++ `Schema Source: ${columnName ? "column" : "table"}`, ++ `Table: ${tableName}`, ++ columnName ? `Column: ${columnName}` : "", ++ input.ddl?.trim() ? `DDL:\n${input.ddl.trim()}` : "", ++ input.description?.trim() ? `Description: ${input.description.trim()}` : "" ++ ].filter(Boolean); ++ ++ return { ++ datasourceId: input.datasourceId, ++ domain: "schema", ++ sourceType: input.sourceType, ++ sourceRef, ++ sourceVersion: input.sourceVersion, ++ contentChecksum: input.contentChecksum, ++ title, ++ content: contentParts.join("\n\n"), ++ tableNames: uniqueNonEmpty([tableName]), ++ columnNames: uniqueNonEmpty(columnName ? [columnName] : []), ++ metadata: { ++ schemaKind: columnName ? "column" : "table", ++ ...(input.metadata ?? {}) ++ }, ++ chunkProfile: columnName ? "schema_column" : "schema_table" ++ }; ++ } ++ ++ private normalizeSqlExampleSource( ++ input: SqlExampleIngestionSourceInput ++ ): NormalizedRagDocumentInput { ++ const sourceRef = input.sourceRef ?? input.exampleId; ++ const contentParts = [ ++ `Question: ${input.question.trim()}`, ++ `SQL:\n${input.sql.trim()}`, ++ input.rationale?.trim() ? `Rationale: ${input.rationale.trim()}` : "" ++ ].filter(Boolean); ++ ++ return { ++ datasourceId: input.datasourceId, ++ domain: "sql_example", ++ sourceType: input.sourceType, ++ sourceRef, ++ sourceVersion: input.sourceVersion, ++ contentChecksum: input.contentChecksum, ++ title: sourceRef ? `SQL Example ${sourceRef}` : "SQL Example", ++ content: contentParts.join("\n\n"), ++ tableNames: uniqueNonEmpty(input.tableNames ?? []), ++ columnNames: uniqueNonEmpty(input.columnNames ?? []), ++ metadata: { ++ exampleId: input.exampleId, ++ ...(input.metadata ?? {}) ++ }, ++ chunkProfile: "sql_example" ++ }; ++ } ++ ++ private normalizeSemanticTermSource( ++ input: SemanticTermIngestionSourceInput ++ ): NormalizedRagDocumentInput { ++ const sourceRef = input.sourceRef ?? input.termId ?? input.term.trim(); ++ const synonymList = uniqueNonEmpty(input.synonyms ?? []); ++ const contentParts = [ ++ `Term: ${input.term.trim()}`, ++ `Definition: ${input.definition.trim()}`, ++ synonymList.length > 0 ? `Synonyms: ${synonymList.join(", ")}` : "" ++ ].filter(Boolean); ++ ++ return { ++ datasourceId: input.datasourceId, ++ domain: "semantic_term", ++ sourceType: input.sourceType, ++ sourceRef, ++ sourceVersion: input.sourceVersion, ++ contentChecksum: input.contentChecksum, ++ title: `Semantic Term ${input.term.trim()}`, ++ content: contentParts.join("\n\n"), ++ tableNames: uniqueNonEmpty(input.tableNames ?? []), ++ columnNames: uniqueNonEmpty(input.columnNames ?? []), ++ metadata: { ++ term: input.term.trim(), ++ ...(input.metadata ?? {}) ++ }, ++ chunkProfile: "semantic_term" ++ }; ++ } ++} ++ ++const uniqueNonEmpty = (values: string[]): string[] => { ++ const normalized = values.map((value) => value.trim()).filter((value) => value.length > 0); ++ return Array.from(new Set(normalized.values())); ++}; +diff --git a/apps/backend/src/modules/rag/ingestion/rag-chunking.service.ts b/apps/backend/src/modules/rag/ingestion/rag-chunking.service.ts +new file mode 100644 +index 0000000..2ae4079 +--- /dev/null ++++ b/apps/backend/src/modules/rag/ingestion/rag-chunking.service.ts +@@ -0,0 +1,110 @@ ++import { createHash } from "node:crypto"; ++import { Injectable } from "@nestjs/common"; ++import { DomainError } from "../../../common/domain-error"; ++import { getChunkProfileConfig, type RagChunkProfile } from "./chunk-profiles"; ++ ++export interface RagChunkingInput { ++ documentId: string; ++ datasourceId: string; ++ domain: string; ++ chunkProfile: RagChunkProfile; ++ content: string; ++ documentChecksum: string; ++ tableNames: string[]; ++ columnNames: string[]; ++ metadata?: Record; ++} ++ ++export interface RagChunkDraft { ++ id: string; ++ chunkOrder: number; ++ content: string; ++ contentChecksum: string; ++ startOffset: number; ++ endOffset: number; ++ tableNames: string[]; ++ columnNames: string[]; ++ metadata?: Record; ++} ++ ++@Injectable() ++export class RagChunkingService { ++ chunk(input: RagChunkingInput): RagChunkDraft[] { ++ const normalizedContent = input.content.trim(); ++ if (!normalizedContent) { ++ throw new DomainError("RAG_EMPTY_CONTENT", "RAG 文档内容为空,无法切块。", 400, { ++ documentId: input.documentId ++ }); ++ } ++ ++ const profileConfig = getChunkProfileConfig(input.chunkProfile); ++ const chunks: RagChunkDraft[] = []; ++ let startOffset = 0; ++ const fullLength = normalizedContent.length; ++ while (startOffset < fullLength) { ++ if (chunks.length >= profileConfig.hardChunkLimit) { ++ throw new DomainError( ++ "RAG_CHUNK_LIMIT_EXCEEDED", ++ `切块数量超过上限 ${profileConfig.hardChunkLimit}。`, ++ 400, ++ { ++ documentId: input.documentId, ++ chunkProfile: input.chunkProfile, ++ contentLength: fullLength ++ } ++ ); ++ } ++ ++ const endOffset = Math.min(startOffset + profileConfig.maxCharacters, fullLength); ++ const chunkContent = normalizedContent.slice(startOffset, endOffset).trim(); ++ if (chunkContent.length > 0) { ++ const chunkOrder = chunks.length; ++ const chunkChecksum = sha256(chunkContent); ++ const chunkId = sha256( ++ [ ++ input.documentId, ++ input.chunkProfile, ++ String(startOffset), ++ String(endOffset), ++ chunkChecksum, ++ input.documentChecksum ++ ].join("|") ++ ); ++ ++ chunks.push({ ++ id: chunkId, ++ chunkOrder, ++ content: chunkContent, ++ contentChecksum: chunkChecksum, ++ startOffset, ++ endOffset, ++ tableNames: [...input.tableNames], ++ columnNames: [...input.columnNames], ++ metadata: { ++ ...(input.metadata ?? {}), ++ startOffset, ++ endOffset, ++ chunkProfile: input.chunkProfile ++ } ++ }); ++ } ++ ++ if (endOffset >= fullLength) { ++ break; ++ } ++ ++ const nextStart = endOffset - profileConfig.overlapCharacters; ++ startOffset = nextStart > startOffset ? nextStart : startOffset + 1; ++ } ++ ++ if (chunks.length === 0) { ++ throw new DomainError("RAG_EMPTY_CONTENT", "RAG 文档无可用切块内容。", 400, { ++ documentId: input.documentId ++ }); ++ } ++ ++ return chunks; ++ } ++} ++ ++const sha256 = (value: string): string => createHash("sha256").update(value).digest("hex"); +diff --git a/apps/backend/src/modules/rag/ingestion/rag-document.factory.ts b/apps/backend/src/modules/rag/ingestion/rag-document.factory.ts +new file mode 100644 +index 0000000..ba83256 +--- /dev/null ++++ b/apps/backend/src/modules/rag/ingestion/rag-document.factory.ts +@@ -0,0 +1,145 @@ ++import { createHash } from "node:crypto"; ++import { Injectable } from "@nestjs/common"; ++import { DomainError } from "../../../common/domain-error"; ++import { ++ IngestionSourceAdapter, ++ type IngestionSourceInput ++} from "./ingestion-source.adapter"; ++import { RagChunkingService, type RagChunkDraft } from "./rag-chunking.service"; ++ ++export interface RagDocumentDraft { ++ id: string; ++ datasourceId: string; ++ domain: string; ++ sourceType: string; ++ sourceRef?: string; ++ sourceVersion: string; ++ contentChecksum: string; ++ title?: string; ++ content: string; ++ tableNames: string[]; ++ columnNames: string[]; ++ metadata?: string; ++} ++ ++export interface RagChunkRecordDraft { ++ id: string; ++ documentId: string; ++ datasourceId: string; ++ domain: string; ++ chunkProfile: string; ++ chunkOrder: number; ++ content: string; ++ contentChecksum: string; ++ tableNames: string[]; ++ columnNames: string[]; ++ metadata?: string; ++} ++ ++export interface RagDocumentBuildResult { ++ document: RagDocumentDraft; ++ chunks: RagChunkRecordDraft[]; ++} ++ ++@Injectable() ++export class RagDocumentFactory { ++ constructor( ++ private readonly sourceAdapter: IngestionSourceAdapter = new IngestionSourceAdapter(), ++ private readonly chunkingService: RagChunkingService = new RagChunkingService() ++ ) {} ++ ++ create(input: IngestionSourceInput): RagDocumentBuildResult { ++ const normalized = this.sourceAdapter.normalize(input); ++ this.assertMandatoryField(normalized.sourceVersion, "source_version"); ++ this.assertMandatoryField(normalized.contentChecksum, "content_checksum"); ++ this.assertMandatoryField(normalized.content, "content"); ++ ++ const documentId = sha256( ++ [ ++ normalized.datasourceId, ++ normalized.sourceType, ++ normalized.domain, ++ normalized.sourceRef ?? "", ++ normalized.sourceVersion, ++ normalized.contentChecksum ++ ].join("|") ++ ); ++ ++ const document: RagDocumentDraft = { ++ id: documentId, ++ datasourceId: normalized.datasourceId, ++ domain: normalized.domain, ++ sourceType: normalized.sourceType, ++ sourceRef: normalized.sourceRef, ++ sourceVersion: normalized.sourceVersion, ++ contentChecksum: normalized.contentChecksum, ++ title: normalized.title, ++ content: normalized.content, ++ tableNames: [...normalized.tableNames], ++ columnNames: [...normalized.columnNames], ++ metadata: this.serializeMetadata({ ++ chunkProfile: normalized.chunkProfile, ++ ...(normalized.metadata ?? {}) ++ }) ++ }; ++ ++ const chunkDrafts = this.chunkingService.chunk({ ++ documentId, ++ datasourceId: normalized.datasourceId, ++ domain: normalized.domain, ++ chunkProfile: normalized.chunkProfile, ++ content: normalized.content, ++ documentChecksum: normalized.contentChecksum, ++ tableNames: normalized.tableNames, ++ columnNames: normalized.columnNames, ++ metadata: normalized.metadata ++ }); ++ ++ return { ++ document, ++ chunks: chunkDrafts.map((chunk) => this.mapChunkDraftToRecord(normalized.chunkProfile, documentId, normalized.datasourceId, normalized.domain, chunk)) ++ }; ++ } ++ ++ private mapChunkDraftToRecord( ++ chunkProfile: string, ++ documentId: string, ++ datasourceId: string, ++ domain: string, ++ chunk: RagChunkDraft ++ ): RagChunkRecordDraft { ++ return { ++ id: chunk.id, ++ documentId, ++ datasourceId, ++ domain, ++ chunkProfile, ++ chunkOrder: chunk.chunkOrder, ++ content: chunk.content, ++ contentChecksum: chunk.contentChecksum, ++ tableNames: [...chunk.tableNames], ++ columnNames: [...chunk.columnNames], ++ metadata: this.serializeMetadata(chunk.metadata) ++ }; ++ } ++ ++ private assertMandatoryField(value: string | undefined, fieldName: string): void { ++ if (!value || value.trim().length === 0) { ++ throw new DomainError( ++ "RAG_DOCUMENT_REQUIRED_FIELD_MISSING", ++ `RAG 文档缺少必填字段: ${fieldName}。`, ++ 400, ++ { fieldName } ++ ); ++ } ++ } ++ ++ private serializeMetadata(metadata?: Record): string | undefined { ++ if (!metadata || Object.keys(metadata).length === 0) { ++ return undefined; ++ } ++ return JSON.stringify(metadata); ++ } ++} ++ ++const sha256 = (value: string): string => createHash("sha256").update(value).digest("hex"); +diff --git a/apps/backend/src/modules/rag/jobs/build-rag-index.job.ts b/apps/backend/src/modules/rag/jobs/build-rag-index.job.ts +new file mode 100644 +index 0000000..edc7349 +--- /dev/null ++++ b/apps/backend/src/modules/rag/jobs/build-rag-index.job.ts +@@ -0,0 +1,162 @@ ++import { Injectable } from "@nestjs/common"; ++import { ++ RagIndexBuilderService, ++ type RagIndexBuildResult ++} from "../index/rag-index-builder.service"; ++import { ++ RagIndexRepository, ++ type RagChunkBuildInput ++} from "../index/rag-index.repository"; ++import { RagIngestionMetricsService } from "../observability/rag-ingestion-metrics.service"; ++import { RagReplayRepository } from "../observability/rag-replay.repository"; ++ ++export interface BuildRagIndexJobInput { ++ datasourceId: string; ++ sourceVersion: string; ++ buildReason?: string; ++ runId?: string; ++ queuedAt?: string; ++ chunks?: RagChunkBuildInput[]; ++} ++ ++@Injectable() ++export class BuildRagIndexJob { ++ constructor( ++ private readonly builder: RagIndexBuilderService, ++ private readonly repository: RagIndexRepository, ++ private readonly ingestionMetrics: RagIngestionMetricsService, ++ private readonly replayRepository: RagReplayRepository ++ ) {} ++ ++ async run(input: BuildRagIndexJobInput): Promise { ++ const startedAt = Date.now(); ++ const queueWaitMs = this.resolveQueueWaitMs(input.queuedAt, startedAt); ++ const replayRunId = this.resolveReplayRunId(input.runId, input.datasourceId, startedAt); ++ const baseChunks = await this.repository.listChunksForBuild(input.datasourceId); ++ const chunks = this.mergeChunks(baseChunks, input.chunks ?? []); ++ try { ++ const result = await this.builder.buildAndActivate({ ++ datasourceId: input.datasourceId, ++ sourceVersion: input.sourceVersion, ++ buildReason: input.buildReason ?? "scheduled_build", ++ createdByRunId: input.runId, ++ activatedByRunId: input.runId, ++ chunks ++ }); ++ const activationLatencyMs = Date.now() - startedAt; ++ this.ingestionMetrics.recordBuild({ ++ datasourceId: input.datasourceId, ++ status: "success", ++ indexVersionId: result.indexVersionId, ++ sourceVersion: input.sourceVersion, ++ activationLatencyMs ++ }); ++ ++ await this.replayRepository.writeReplay({ ++ runId: replayRunId, ++ replayKey: `index:build:completed:${result.indexVersionId}`, ++ datasourceId: input.datasourceId, ++ stage: "index_build_completed", ++ indexVersionId: result.indexVersionId, ++ payload: { ++ sourceVersion: input.sourceVersion, ++ buildReason: input.buildReason ?? "scheduled_build", ++ entryCount: result.entryCount, ++ archivedChannels: result.archivedChannels, ++ denseMode: result.denseMode, ++ activationLatencyMs, ++ queueWaitMs ++ } ++ }); ++ ++ const indexedChunks = await this.repository.listEntriesByVersion(result.indexVersionId); ++ for (const entry of indexedChunks) { ++ await this.replayRepository.writeReplay({ ++ runId: replayRunId, ++ replayKey: `chunk:indexed:${entry.chunkId}`, ++ datasourceId: entry.datasourceId, ++ stage: "chunk_indexed", ++ indexVersionId: entry.indexVersionId, ++ chunkId: entry.chunkId, ++ payload: { ++ domain: entry.domain, ++ lexicalContentLength: entry.lexicalContent.length ++ } ++ }); ++ } ++ ++ return result; ++ } catch (error) { ++ this.ingestionMetrics.recordBuild({ ++ datasourceId: input.datasourceId, ++ status: "failure", ++ sourceVersion: input.sourceVersion, ++ failureReason: this.resolveFailureReason(error) ++ }); ++ await this.replayRepository.writeReplay({ ++ runId: replayRunId, ++ replayKey: `index:build:failed:${startedAt}`, ++ datasourceId: input.datasourceId, ++ stage: "index_build_failed", ++ payload: { ++ sourceVersion: input.sourceVersion, ++ buildReason: input.buildReason ?? "scheduled_build", ++ error: error instanceof Error ? error.message : String(error), ++ queueWaitMs ++ } ++ }); ++ throw error; ++ } ++ } ++ ++ private resolveReplayRunId( ++ runId: string | undefined, ++ datasourceId: string, ++ startedAt: number ++ ): string { ++ const normalized = runId?.trim(); ++ if (normalized) { ++ return normalized; ++ } ++ return `rag-build:${datasourceId}:${startedAt}`; ++ } ++ ++ private resolveFailureReason(error: unknown): string { ++ if (error instanceof Error && error.message.trim().length > 0) { ++ return error.message.trim().toLowerCase(); ++ } ++ return "unknown"; ++ } ++ ++ private resolveQueueWaitMs(queuedAt: string | undefined, startedAt: number): number { ++ if (!queuedAt) { ++ return 0; ++ } ++ const queuedAtMs = Date.parse(queuedAt); ++ if (Number.isNaN(queuedAtMs)) { ++ return 0; ++ } ++ return Math.max(0, startedAt - queuedAtMs); ++ } ++ ++ private mergeChunks( ++ baseChunks: RagChunkBuildInput[], ++ incrementalChunks: RagChunkBuildInput[] ++ ): RagChunkBuildInput[] { ++ if (incrementalChunks.length === 0) { ++ return [...baseChunks]; ++ } ++ const merged = new Map(); ++ for (const chunk of baseChunks) { ++ merged.set(chunk.id, { ++ ...chunk ++ }); ++ } ++ for (const chunk of incrementalChunks) { ++ merged.set(chunk.id, { ++ ...chunk ++ }); ++ } ++ return [...merged.values()]; ++ } ++} +diff --git a/apps/backend/src/modules/rag/observability/rag-ingestion-metrics.service.ts b/apps/backend/src/modules/rag/observability/rag-ingestion-metrics.service.ts +new file mode 100644 +index 0000000..7765d0a +--- /dev/null ++++ b/apps/backend/src/modules/rag/observability/rag-ingestion-metrics.service.ts +@@ -0,0 +1,185 @@ ++import { Injectable } from "@nestjs/common"; ++ ++export type RagIngestionBuildStatus = "success" | "failure"; ++ ++interface RagIngestionBuildMetricEvent { ++ at: number; ++ datasourceId: string; ++ status: RagIngestionBuildStatus; ++ indexVersionId?: string; ++ sourceVersion?: string; ++ activationLatencyMs?: number; ++ failureReason?: string; ++} ++ ++export interface RagIngestionBuildMetricInput { ++ datasourceId: string; ++ status: RagIngestionBuildStatus; ++ indexVersionId?: string; ++ sourceVersion?: string; ++ activationLatencyMs?: number; ++ failureReason?: string; ++ at?: string; ++} ++ ++export interface RagIngestionActiveIndexSummaryItem { ++ datasourceId: string; ++ indexVersionId: string; ++ sourceVersion?: string; ++ activatedAt: string; ++} ++ ++export interface RagIngestionMetricsSnapshot { ++ windowMinutes: number; ++ observedBuilds: number; ++ buildSuccessCount: number; ++ buildFailureCount: number; ++ buildSuccessRate: number; ++ buildFailureRate: number; ++ failureReasons: Record; ++ activationLatencyMs: { ++ count: number; ++ avg: number; ++ min: number; ++ max: number; ++ p50: number; ++ p95: number; ++ }; ++ activeIndexSummary: { ++ total: number; ++ items: RagIngestionActiveIndexSummaryItem[]; ++ }; ++ degradedReason?: "no_active_index"; ++ generatedAt: string; ++} ++ ++@Injectable() ++export class RagIngestionMetricsService { ++ private readonly events: RagIngestionBuildMetricEvent[] = []; ++ private readonly activeIndexesByDatasource = new Map< ++ string, ++ RagIngestionActiveIndexSummaryItem ++ >(); ++ private readonly windowMinutes = 60; ++ ++ recordBuild(input: RagIngestionBuildMetricInput): void { ++ const at = this.resolveTimestamp(input.at); ++ const normalizedLatency = ++ typeof input.activationLatencyMs === "number" && ++ Number.isFinite(input.activationLatencyMs) && ++ input.activationLatencyMs >= 0 ++ ? input.activationLatencyMs ++ : undefined; ++ const normalizedFailureReason = ++ input.status === "failure" ++ ? (input.failureReason?.trim().toLowerCase() ?? "unknown") ++ : undefined; ++ ++ this.events.push({ ++ at, ++ datasourceId: input.datasourceId, ++ status: input.status, ++ indexVersionId: input.indexVersionId, ++ sourceVersion: input.sourceVersion, ++ activationLatencyMs: normalizedLatency, ++ failureReason: normalizedFailureReason ++ }); ++ this.prune(at); ++ ++ if (input.status === "success" && input.indexVersionId) { ++ this.activeIndexesByDatasource.set(input.datasourceId, { ++ datasourceId: input.datasourceId, ++ indexVersionId: input.indexVersionId, ++ sourceVersion: input.sourceVersion, ++ activatedAt: new Date(at).toISOString() ++ }); ++ } ++ } ++ ++ snapshot(now = Date.now()): RagIngestionMetricsSnapshot { ++ this.prune(now); ++ const total = this.events.length; ++ const buildSuccessCount = this.events.filter((event) => event.status === "success").length; ++ const buildFailureCount = total - buildSuccessCount; ++ const buildSuccessRate = total === 0 ? 0 : buildSuccessCount / total; ++ const buildFailureRate = total === 0 ? 0 : buildFailureCount / total; ++ ++ const failureReasons: Record = {}; ++ for (const event of this.events) { ++ if (event.status !== "failure") { ++ continue; ++ } ++ const reason = event.failureReason ?? "unknown"; ++ failureReasons[reason] = (failureReasons[reason] ?? 0) + 1; ++ } ++ ++ const activationLatencies = this.events ++ .filter((event) => event.status === "success") ++ .map((event) => event.activationLatencyMs) ++ .filter((value): value is number => typeof value === "number") ++ .sort((left, right) => left - right); ++ ++ const latencyCount = activationLatencies.length; ++ const latencySum = activationLatencies.reduce((sum, value) => sum + value, 0); ++ const activeIndexItems = Array.from(this.activeIndexesByDatasource.values()).sort( ++ (left, right) => Date.parse(right.activatedAt) - Date.parse(left.activatedAt) ++ ); ++ ++ return { ++ windowMinutes: this.windowMinutes, ++ observedBuilds: total, ++ buildSuccessCount, ++ buildFailureCount, ++ buildSuccessRate, ++ buildFailureRate, ++ failureReasons, ++ activationLatencyMs: { ++ count: latencyCount, ++ avg: latencyCount === 0 ? 0 : latencySum / latencyCount, ++ min: latencyCount === 0 ? 0 : activationLatencies[0], ++ max: latencyCount === 0 ? 0 : activationLatencies[latencyCount - 1], ++ p50: this.percentile(activationLatencies, 0.5), ++ p95: this.percentile(activationLatencies, 0.95) ++ }, ++ activeIndexSummary: { ++ total: activeIndexItems.length, ++ items: activeIndexItems.map((item) => ({ ...item })) ++ }, ++ degradedReason: activeIndexItems.length === 0 ? "no_active_index" : undefined, ++ generatedAt: new Date(now).toISOString() ++ }; ++ } ++ ++ reset(): void { ++ this.events.length = 0; ++ this.activeIndexesByDatasource.clear(); ++ } ++ ++ private prune(now: number): void { ++ const ttlMs = this.windowMinutes * 60 * 1000; ++ const threshold = now - ttlMs; ++ while (this.events.length > 0 && this.events[0].at < threshold) { ++ this.events.shift(); ++ } ++ } ++ ++ private percentile(values: number[], ratio: number): number { ++ if (values.length === 0) { ++ return 0; ++ } ++ const index = Math.ceil(values.length * ratio) - 1; ++ const boundedIndex = Math.min(values.length - 1, Math.max(0, index)); ++ return values[boundedIndex]; ++ } ++ ++ private resolveTimestamp(value?: string): number { ++ if (!value) { ++ return Date.now(); ++ } ++ const parsed = Date.parse(value); ++ if (Number.isNaN(parsed)) { ++ return Date.now(); ++ } ++ return parsed; ++ } ++} +diff --git a/apps/backend/src/modules/rag/observability/rag-replay.repository.ts b/apps/backend/src/modules/rag/observability/rag-replay.repository.ts +new file mode 100644 +index 0000000..af78f80 +--- /dev/null ++++ b/apps/backend/src/modules/rag/observability/rag-replay.repository.ts +@@ -0,0 +1,364 @@ ++import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; ++import { AppConfigService } from "../../config/app-config.service"; ++ ++export interface RagReplayRecord { ++ runId: string; ++ replayKey: string; ++ datasourceId: string; ++ stage: string; ++ indexVersionId?: string; ++ documentId?: string; ++ chunkId?: string; ++ payload: string; ++ createdAt: string; ++} ++ ++export interface WriteRagReplayInput { ++ runId: string; ++ replayKey: string; ++ datasourceId: string; ++ stage: string; ++ indexVersionId?: string; ++ documentId?: string; ++ chunkId?: string; ++ payload: unknown; ++ createdAt?: string; ++} ++ ++type PrismaClientLike = { ++ ragRunReplay?: { ++ upsert?: (args: Record) => Promise; ++ findMany?: (args: Record) => Promise; ++ findUnique?: (args: Record) => Promise; ++ }; ++ $disconnect: () => Promise; ++}; ++ ++type RagRunReplayRow = { ++ runId: string; ++ replayKey: string; ++ datasourceId: string; ++ stage: string; ++ indexVersionId: string | null; ++ documentId: string | null; ++ chunkId: string | null; ++ payload: string; ++ createdAt: Date; ++}; ++ ++@Injectable() ++export class RagReplayRepository implements OnModuleInit, OnModuleDestroy { ++ private readonly logger = new Logger(RagReplayRepository.name); ++ private prisma?: PrismaClientLike; ++ private readonly replays = new Map(); ++ ++ constructor(private readonly appConfig: AppConfigService) {} ++ ++ async onModuleInit(): Promise { ++ if (!this.isPrimaryPersistenceConfigured()) { ++ return; ++ } ++ ++ try { ++ const prismaClientModulePath = "../../../generated/prisma/client"; ++ const prismaModule = (await import(prismaClientModulePath)) as unknown as { ++ PrismaClient?: new (...args: unknown[]) => PrismaClientLike; ++ default?: { ++ PrismaClient?: new (...args: unknown[]) => PrismaClientLike; ++ }; ++ }; ++ const adapterModule = (await import("@prisma/adapter-pg")) as unknown as { ++ PrismaPg?: new (...args: unknown[]) => unknown; ++ default?: { ++ PrismaPg?: new (...args: unknown[]) => unknown; ++ }; ++ }; ++ const PrismaCtor = prismaModule.PrismaClient ?? prismaModule.default?.PrismaClient; ++ const PrismaPgCtor = adapterModule.PrismaPg ?? adapterModule.default?.PrismaPg; ++ if (!PrismaCtor) { ++ throw new Error("PrismaClient 未生成,请先执行 prisma generate"); ++ } ++ if (!PrismaPgCtor) { ++ throw new Error("Prisma PostgreSQL adapter 未安装"); ++ } ++ const adapter = new PrismaPgCtor({ ++ connectionString: this.appConfig.databaseUrl ++ }); ++ this.prisma = new PrismaCtor({ ++ adapter ++ }) as PrismaClientLike; ++ this.logger.log("RAG replay 仓储已启用 PostgreSQL 持久化。"); ++ } catch (error) { ++ this.logger.warn( ++ `RAG replay 仓储初始化失败,降级为内存模式: ${ ++ error instanceof Error ? error.message : String(error) ++ }` ++ ); ++ } ++ } ++ ++ async onModuleDestroy(): Promise { ++ if (this.prisma) { ++ await this.prisma.$disconnect(); ++ } ++ } ++ ++ async writeReplay(input: WriteRagReplayInput): Promise { ++ const replay = this.normalizeInput(input); ++ this.replays.set(this.buildMapKey(replay.runId, replay.replayKey), replay); ++ ++ const replayModel = this.prisma?.ragRunReplay; ++ if ( ++ !this.isPrimaryPersistenceConfigured() || ++ !this.prisma || ++ !replayModel?.upsert ++ ) { ++ return { ...replay }; ++ } ++ ++ await this.tryPrismaWrite(async () => { ++ await replayModel.upsert?.({ ++ where: { ++ runId_replayKey: { ++ runId: replay.runId, ++ replayKey: replay.replayKey ++ } ++ }, ++ update: { ++ datasourceId: replay.datasourceId, ++ stage: replay.stage, ++ indexVersionId: replay.indexVersionId ?? null, ++ documentId: replay.documentId ?? null, ++ chunkId: replay.chunkId ?? null, ++ payload: replay.payload, ++ createdAt: new Date(replay.createdAt) ++ }, ++ create: { ++ runId: replay.runId, ++ replayKey: replay.replayKey, ++ datasourceId: replay.datasourceId, ++ stage: replay.stage, ++ indexVersionId: replay.indexVersionId ?? null, ++ documentId: replay.documentId ?? null, ++ chunkId: replay.chunkId ?? null, ++ payload: replay.payload, ++ createdAt: new Date(replay.createdAt) ++ } ++ }); ++ }); ++ ++ return { ...replay }; ++ } ++ ++ async getReplay(runId: string, replayKey: string): Promise { ++ const normalizedRunId = runId.trim(); ++ const normalizedReplayKey = replayKey.trim(); ++ if (!normalizedRunId || !normalizedReplayKey) { ++ return undefined; ++ } ++ ++ const key = this.buildMapKey(normalizedRunId, normalizedReplayKey); ++ const memory = this.replays.get(key); ++ if (memory) { ++ return { ...memory }; ++ } ++ ++ const replayModel = this.prisma?.ragRunReplay; ++ if ( ++ !this.isPrimaryPersistenceConfigured() || ++ !this.prisma || ++ !replayModel?.findUnique ++ ) { ++ return undefined; ++ } ++ ++ const row = (await this.tryPrismaRead(async () => ++ replayModel.findUnique?.({ ++ where: { ++ runId_replayKey: { ++ runId: normalizedRunId, ++ replayKey: normalizedReplayKey ++ } ++ } ++ }) ++ )) as RagRunReplayRow | null; ++ ++ if (!row) { ++ return undefined; ++ } ++ ++ const replay = this.fromRow(row); ++ this.replays.set(key, replay); ++ return { ...replay }; ++ } ++ ++ async listByRunId(runId: string): Promise { ++ const normalizedRunId = runId.trim(); ++ if (!normalizedRunId) { ++ return []; ++ } ++ ++ const fromMemory = this.sortByCreatedAt( ++ Array.from(this.replays.values()).filter((item) => item.runId === normalizedRunId) ++ ); ++ ++ const replayModel = this.prisma?.ragRunReplay; ++ if ( ++ !this.isPrimaryPersistenceConfigured() || ++ !this.prisma || ++ !replayModel?.findMany ++ ) { ++ return fromMemory.map((item) => ({ ...item })); ++ } ++ ++ const rows = (await this.tryPrismaRead(async () => ++ replayModel.findMany?.({ ++ where: { runId: normalizedRunId }, ++ orderBy: [{ createdAt: "asc" }, { replayKey: "asc" }] ++ }) ++ )) as RagRunReplayRow[] | null; ++ ++ if (!rows) { ++ return fromMemory.map((item) => ({ ...item })); ++ } ++ ++ const merged = new Map(); ++ for (const row of rows) { ++ const replay = this.fromRow(row); ++ const key = this.buildMapKey(replay.runId, replay.replayKey); ++ merged.set(key, replay); ++ this.replays.set(key, replay); ++ } ++ for (const item of fromMemory) { ++ merged.set(this.buildMapKey(item.runId, item.replayKey), item); ++ } ++ ++ return this.sortByCreatedAt(Array.from(merged.values())); ++ } ++ ++ private normalizeInput(input: WriteRagReplayInput): RagReplayRecord { ++ const runId = input.runId?.trim(); ++ if (!runId) { ++ throw new Error("rag replay runId 不能为空"); ++ } ++ const replayKey = input.replayKey?.trim(); ++ if (!replayKey) { ++ throw new Error("rag replay replayKey 不能为空"); ++ } ++ const datasourceId = input.datasourceId?.trim(); ++ if (!datasourceId) { ++ throw new Error("rag replay datasourceId 不能为空"); ++ } ++ const stage = input.stage?.trim(); ++ if (!stage) { ++ throw new Error("rag replay stage 不能为空"); ++ } ++ ++ return { ++ runId, ++ replayKey, ++ datasourceId, ++ stage, ++ indexVersionId: input.indexVersionId?.trim() || undefined, ++ documentId: input.documentId?.trim() || undefined, ++ chunkId: input.chunkId?.trim() || undefined, ++ payload: this.stringifyPayload(input.payload), ++ createdAt: this.toIso(input.createdAt) ++ }; ++ } ++ ++ private fromRow(row: RagRunReplayRow): RagReplayRecord { ++ return { ++ runId: row.runId, ++ replayKey: row.replayKey, ++ datasourceId: row.datasourceId, ++ stage: row.stage, ++ indexVersionId: row.indexVersionId ?? undefined, ++ documentId: row.documentId ?? undefined, ++ chunkId: row.chunkId ?? undefined, ++ payload: row.payload, ++ createdAt: row.createdAt.toISOString() ++ }; ++ } ++ ++ private stringifyPayload(payload: unknown): string { ++ if (typeof payload === "string") { ++ const text = payload.trim(); ++ if (!text) { ++ throw new Error("rag replay payload 不能为空字符串"); ++ } ++ return text; ++ } ++ ++ try { ++ const serialized = JSON.stringify(payload); ++ if (!serialized) { ++ throw new Error("rag replay payload 无法序列化"); ++ } ++ return serialized; ++ } catch { ++ return JSON.stringify({ ++ raw: String(payload) ++ }); ++ } ++ } ++ ++ private toIso(input?: string): string { ++ if (!input) { ++ return new Date().toISOString(); ++ } ++ const timestamp = Date.parse(input); ++ if (Number.isNaN(timestamp)) { ++ throw new Error(`rag replay createdAt 非法时间格式: ${input}`); ++ } ++ return new Date(timestamp).toISOString(); ++ } ++ ++ private sortByCreatedAt(items: RagReplayRecord[]): RagReplayRecord[] { ++ return items ++ .map((item) => ({ ...item })) ++ .sort((left, right) => { ++ const leftTime = Date.parse(left.createdAt); ++ const rightTime = Date.parse(right.createdAt); ++ if (leftTime === rightTime) { ++ return left.replayKey.localeCompare(right.replayKey); ++ } ++ return leftTime - rightTime; ++ }); ++ } ++ ++ private buildMapKey(runId: string, replayKey: string): string { ++ return `${runId}::${replayKey}`; ++ } ++ ++ private isPrimaryPersistenceConfigured(): boolean { ++ return Boolean(this.appConfig.databaseUrl); ++ } ++ ++ private async tryPrismaWrite(op: () => Promise): Promise { ++ try { ++ return await op(); ++ } catch (error) { ++ this.logger.warn( ++ `RAG replay Prisma 写入失败,保持内存数据可读: ${ ++ error instanceof Error ? error.message : String(error) ++ }` ++ ); ++ return undefined; ++ } ++ } ++ ++ private async tryPrismaRead(op: () => Promise): Promise { ++ try { ++ const result = await op(); ++ return result ?? null; ++ } catch (error) { ++ this.logger.warn( ++ `RAG replay Prisma 读取失败,回退内存缓存: ${ ++ error instanceof Error ? error.message : String(error) ++ }` ++ ); ++ return null; ++ } ++ } ++} +diff --git a/apps/backend/src/modules/rag/orchestration/rag-datasource-orchestrator.service.ts b/apps/backend/src/modules/rag/orchestration/rag-datasource-orchestrator.service.ts +new file mode 100644 +index 0000000..efd7e2f +--- /dev/null ++++ b/apps/backend/src/modules/rag/orchestration/rag-datasource-orchestrator.service.ts +@@ -0,0 +1,209 @@ ++import { Injectable } from "@nestjs/common"; ++import type { RagIndexBuildResult } from "../index/rag-index-builder.service"; ++import { ++ BuildRagIndexJob, ++ type BuildRagIndexJobInput ++} from "../jobs/build-rag-index.job"; ++import { RagQualityService } from "../quality/rag-quality.service"; ++import { RagDatasourceQuotaPolicy } from "./rag-datasource-quota.policy"; ++ ++export interface RagDatasourceOrchestratorInput extends Omit { ++ workspaceId?: string; ++ queuedAt?: string; ++} ++ ++export interface RagDatasourceOrchestratorResult extends RagIndexBuildResult { ++ queuedAt: string; ++ startedAt: string; ++ completedAt: string; ++ queueWaitMs: number; ++ isolationViolation: boolean; ++} ++ ++interface RagDatasourceQueuedTask { ++ input: RagDatasourceOrchestratorInput; ++ queuedAt: string; ++ workspaceId: string; ++ resolve: (value: RagDatasourceOrchestratorResult) => void; ++ reject: (reason?: unknown) => void; ++} ++ ++@Injectable() ++export class RagDatasourceOrchestratorService { ++ private readonly queue: RagDatasourceQueuedTask[] = []; ++ private readonly runningDatasourceIds = new Set(); ++ private readonly runningByWorkspace = new Map(); ++ private readonly circuitOpenUntilByDatasource = new Map(); ++ private readonly consecutiveFailuresByDatasource = new Map(); ++ private runningGlobal = 0; ++ ++ constructor( ++ private readonly buildJob: BuildRagIndexJob, ++ private readonly ragQualityService: RagQualityService, ++ private readonly quotaPolicy: RagDatasourceQuotaPolicy ++ ) {} ++ ++ async enqueueBuild( ++ input: RagDatasourceOrchestratorInput ++ ): Promise { ++ const normalizedInput = this.normalizeInput(input); ++ return new Promise((resolve, reject) => { ++ this.queue.push({ ++ input: normalizedInput, ++ queuedAt: normalizedInput.queuedAt ?? new Date().toISOString(), ++ workspaceId: normalizedInput.workspaceId ?? "default_workspace", ++ resolve, ++ reject ++ }); ++ this.drainQueue(); ++ }); ++ } ++ ++ private drainQueue(): void { ++ let dispatched = true; ++ while (dispatched) { ++ dispatched = false; ++ const taskIndex = this.findDispatchableTaskIndex(); ++ if (taskIndex < 0) { ++ return; ++ } ++ const task = this.queue.splice(taskIndex, 1)[0]; ++ if (!task) { ++ return; ++ } ++ dispatched = true; ++ void this.startTask(task); ++ } ++ } ++ ++ private async startTask(task: RagDatasourceQueuedTask): Promise { ++ const datasourceId = task.input.datasourceId; ++ const workspaceId = task.workspaceId; ++ this.runningDatasourceIds.add(datasourceId); ++ this.runningGlobal += 1; ++ this.runningByWorkspace.set( ++ workspaceId, ++ (this.runningByWorkspace.get(workspaceId) ?? 0) + 1 ++ ); ++ ++ const startedAt = new Date().toISOString(); ++ const queuedAtMs = Date.parse(task.queuedAt); ++ const startedAtMs = Date.parse(startedAt); ++ const queueWaitMs = ++ Number.isNaN(queuedAtMs) || Number.isNaN(startedAtMs) ++ ? 0 ++ : Math.max(0, startedAtMs - queuedAtMs); ++ ++ try { ++ const buildResult = await this.buildJob.run({ ++ ...task.input, ++ queuedAt: task.queuedAt ++ }); ++ this.consecutiveFailuresByDatasource.delete(datasourceId); ++ this.circuitOpenUntilByDatasource.delete(datasourceId); ++ ++ const isolationViolation = buildResult.datasourceId !== datasourceId; ++ this.ragQualityService.recordDatasourceOrchestration({ ++ datasourceId, ++ workspaceId, ++ queueWaitMs, ++ isolationViolation, ++ recordedAt: new Date().toISOString() ++ }); ++ ++ task.resolve({ ++ ...buildResult, ++ queuedAt: task.queuedAt, ++ startedAt, ++ completedAt: new Date().toISOString(), ++ queueWaitMs, ++ isolationViolation ++ }); ++ } catch (error) { ++ const failureCount = (this.consecutiveFailuresByDatasource.get(datasourceId) ?? 0) + 1; ++ this.consecutiveFailuresByDatasource.set(datasourceId, failureCount); ++ ++ const config = this.quotaPolicy.resolveConfig(); ++ if (failureCount >= config.circuitBreakerFailures) { ++ const cooldownUntil = Date.now() + config.circuitBreakerCooldownMs; ++ this.circuitOpenUntilByDatasource.set(datasourceId, cooldownUntil); ++ setTimeout(() => this.drainQueue(), config.circuitBreakerCooldownMs); ++ } ++ ++ this.ragQualityService.recordDatasourceOrchestration({ ++ datasourceId, ++ workspaceId, ++ queueWaitMs, ++ isolationViolation: false, ++ recordedAt: new Date().toISOString() ++ }); ++ ++ task.reject(error); ++ } finally { ++ this.runningDatasourceIds.delete(datasourceId); ++ this.runningGlobal = Math.max(0, this.runningGlobal - 1); ++ const workspaceRunning = (this.runningByWorkspace.get(workspaceId) ?? 1) - 1; ++ if (workspaceRunning <= 0) { ++ this.runningByWorkspace.delete(workspaceId); ++ } else { ++ this.runningByWorkspace.set(workspaceId, workspaceRunning); ++ } ++ this.drainQueue(); ++ } ++ } ++ ++ private findDispatchableTaskIndex(): number { ++ for (let index = 0; index < this.queue.length; index += 1) { ++ const task = this.queue[index]; ++ if (!task) { ++ continue; ++ } ++ if (this.runningDatasourceIds.has(task.input.datasourceId)) { ++ continue; ++ } ++ const isCircuitOpen = this.isDatasourceCircuitOpen(task.input.datasourceId); ++ const runningInWorkspace = this.runningByWorkspace.get(task.workspaceId) ?? 0; ++ const decision = this.quotaPolicy.canDispatch({ ++ runningGlobal: this.runningGlobal, ++ runningInWorkspace, ++ workspaceId: task.workspaceId, ++ datasourceCircuitOpen: isCircuitOpen ++ }); ++ if (decision.allowed) { ++ return index; ++ } ++ } ++ return -1; ++ } ++ ++ private isDatasourceCircuitOpen(datasourceId: string): boolean { ++ const openUntil = this.circuitOpenUntilByDatasource.get(datasourceId); ++ if (!openUntil) { ++ return false; ++ } ++ if (openUntil <= Date.now()) { ++ this.circuitOpenUntilByDatasource.delete(datasourceId); ++ return false; ++ } ++ return true; ++ } ++ ++ private normalizeInput(input: RagDatasourceOrchestratorInput): RagDatasourceOrchestratorInput { ++ const datasourceId = input.datasourceId.trim(); ++ if (!datasourceId) { ++ throw new Error("rag datasource orchestrator datasourceId 不能为空"); ++ } ++ const sourceVersion = input.sourceVersion.trim(); ++ if (!sourceVersion) { ++ throw new Error("rag datasource orchestrator sourceVersion 不能为空"); ++ } ++ return { ++ ...input, ++ datasourceId, ++ sourceVersion, ++ workspaceId: input.workspaceId?.trim() || "default_workspace", ++ queuedAt: input.queuedAt ++ }; ++ } ++} ++ +diff --git a/apps/backend/src/modules/rag/orchestration/rag-datasource-quota.policy.ts b/apps/backend/src/modules/rag/orchestration/rag-datasource-quota.policy.ts +new file mode 100644 +index 0000000..23d386e +--- /dev/null ++++ b/apps/backend/src/modules/rag/orchestration/rag-datasource-quota.policy.ts +@@ -0,0 +1,63 @@ ++import { Injectable } from "@nestjs/common"; ++ ++export interface RagDatasourceQuotaPolicyConfig { ++ globalConcurrency: number; ++ workspaceConcurrency: number; ++ circuitBreakerFailures: number; ++ circuitBreakerCooldownMs: number; ++} ++ ++export interface RagDatasourceDispatchContext { ++ runningGlobal: number; ++ runningInWorkspace: number; ++ workspaceId: string; ++ datasourceCircuitOpen: boolean; ++} ++ ++export interface RagDatasourceDispatchDecision { ++ allowed: boolean; ++ reason?: ++ | "global_concurrency_exceeded" ++ | "workspace_concurrency_exceeded" ++ | "datasource_circuit_open"; ++} ++ ++const DEFAULT_CONFIG: RagDatasourceQuotaPolicyConfig = { ++ globalConcurrency: 2, ++ workspaceConcurrency: 2, ++ circuitBreakerFailures: 3, ++ circuitBreakerCooldownMs: 60_000 ++}; ++ ++@Injectable() ++export class RagDatasourceQuotaPolicy { ++ private readonly config: RagDatasourceQuotaPolicyConfig = { ++ ...DEFAULT_CONFIG ++ }; ++ ++ resolveConfig(): RagDatasourceQuotaPolicyConfig { ++ return { ...this.config }; ++ } ++ ++ canDispatch(input: RagDatasourceDispatchContext): RagDatasourceDispatchDecision { ++ if (input.datasourceCircuitOpen) { ++ return { ++ allowed: false, ++ reason: "datasource_circuit_open" ++ }; ++ } ++ if (input.runningGlobal >= this.config.globalConcurrency) { ++ return { ++ allowed: false, ++ reason: "global_concurrency_exceeded" ++ }; ++ } ++ if (input.runningInWorkspace >= this.config.workspaceConcurrency) { ++ return { ++ allowed: false, ++ reason: "workspace_concurrency_exceeded" ++ }; ++ } ++ return { allowed: true }; ++ } ++} +diff --git a/apps/backend/src/modules/rag/perf/rag-budget-policy.ts b/apps/backend/src/modules/rag/perf/rag-budget-policy.ts +new file mode 100644 +index 0000000..3b7e9d1 +--- /dev/null ++++ b/apps/backend/src/modules/rag/perf/rag-budget-policy.ts +@@ -0,0 +1,160 @@ ++import { Injectable } from "@nestjs/common"; ++ ++export interface RagBudgetSignal { ++ tokenPressure?: number; ++ latencyPressure?: number; ++ costPressure?: number; ++} ++ ++export interface RagRetrievalBudgetDecision { ++ perLaneLimit: number; ++ finalCandidateLimit: number; ++ enabledLanes: Array<"lexical" | "dense" | "graph">; ++ decisionReasons: string[]; ++ degraded: boolean; ++} ++ ++export interface RagRerankBudgetDecision { ++ secondaryEnabled: boolean; ++ secondaryTopK: number; ++ decisionReasons: string[]; ++ degraded: boolean; ++} ++ ++const DEFAULT_PRESSURE_DEGRADE_THRESHOLD = 0.8; ++const DEFAULT_PRESSURE_EXTREME_THRESHOLD = 0.95; ++const ALL_RETRIEVAL_LANES: Array<"lexical" | "dense" | "graph"> = [ ++ "lexical", ++ "dense", ++ "graph" ++]; ++ ++@Injectable() ++export class RagBudgetPolicy { ++ planRetrieval(input: { ++ requestedPerLaneLimit: number; ++ requestedFinalCandidateLimit: number; ++ signal?: RagBudgetSignal; ++ }): RagRetrievalBudgetDecision { ++ const signal = this.normalizeSignal(input.signal); ++ const reasons: string[] = []; ++ let perLaneLimit = Math.max(1, Math.floor(input.requestedPerLaneLimit)); ++ let finalCandidateLimit = Math.max(1, Math.floor(input.requestedFinalCandidateLimit)); ++ let enabledLanes: Array<"lexical" | "dense" | "graph"> = [...ALL_RETRIEVAL_LANES]; ++ ++ if (signal.costPressure >= DEFAULT_PRESSURE_EXTREME_THRESHOLD) { ++ perLaneLimit = Math.max(4, Math.floor(perLaneLimit * 0.4)); ++ finalCandidateLimit = Math.max(4, Math.floor(finalCandidateLimit * 0.4)); ++ reasons.push("budget_degrade_cost_extreme"); ++ } else if (signal.costPressure >= DEFAULT_PRESSURE_DEGRADE_THRESHOLD) { ++ perLaneLimit = Math.max(6, Math.floor(perLaneLimit * 0.6)); ++ finalCandidateLimit = Math.max(6, Math.floor(finalCandidateLimit * 0.6)); ++ reasons.push("budget_degrade_cost"); ++ } ++ ++ if (signal.latencyPressure >= DEFAULT_PRESSURE_EXTREME_THRESHOLD) { ++ perLaneLimit = Math.max(3, Math.floor(perLaneLimit * 0.5)); ++ finalCandidateLimit = Math.max(3, Math.floor(finalCandidateLimit * 0.5)); ++ reasons.push("budget_degrade_latency_extreme"); ++ } else if (signal.latencyPressure >= DEFAULT_PRESSURE_DEGRADE_THRESHOLD) { ++ perLaneLimit = Math.max(5, Math.floor(perLaneLimit * 0.7)); ++ finalCandidateLimit = Math.max(5, Math.floor(finalCandidateLimit * 0.7)); ++ reasons.push("budget_degrade_latency"); ++ } ++ ++ if (signal.tokenPressure >= DEFAULT_PRESSURE_EXTREME_THRESHOLD) { ++ finalCandidateLimit = Math.max(3, Math.floor(finalCandidateLimit * 0.5)); ++ reasons.push("budget_degrade_token_extreme"); ++ } else if (signal.tokenPressure >= DEFAULT_PRESSURE_DEGRADE_THRESHOLD) { ++ finalCandidateLimit = Math.max(4, Math.floor(finalCandidateLimit * 0.7)); ++ reasons.push("budget_degrade_token"); ++ } ++ ++ const hasExtremePressure = ++ signal.costPressure >= DEFAULT_PRESSURE_EXTREME_THRESHOLD || ++ signal.latencyPressure >= DEFAULT_PRESSURE_EXTREME_THRESHOLD || ++ signal.tokenPressure >= DEFAULT_PRESSURE_EXTREME_THRESHOLD; ++ if (hasExtremePressure) { ++ const denseOnlyMode = ++ signal.latencyPressure >= DEFAULT_PRESSURE_EXTREME_THRESHOLD && ++ signal.costPressure < DEFAULT_PRESSURE_EXTREME_THRESHOLD && ++ signal.tokenPressure < DEFAULT_PRESSURE_EXTREME_THRESHOLD; ++ enabledLanes = denseOnlyMode ? ["dense"] : ["lexical"]; ++ reasons.push( ++ denseOnlyMode ? "budget_lane_single_dense" : "budget_lane_single_lexical", ++ "degrade_level=maximum" ++ ); ++ } ++ ++ return { ++ perLaneLimit, ++ finalCandidateLimit, ++ enabledLanes, ++ decisionReasons: this.unique(reasons), ++ degraded: reasons.length > 0 ++ }; ++ } ++ ++ planRerank(input: { ++ requestedSecondaryTopK: number; ++ signal?: RagBudgetSignal; ++ }): RagRerankBudgetDecision { ++ const signal = this.normalizeSignal(input.signal); ++ const reasons: string[] = []; ++ let secondaryEnabled = true; ++ let secondaryTopK = Math.max(1, Math.floor(input.requestedSecondaryTopK)); ++ ++ if (signal.costPressure >= DEFAULT_PRESSURE_EXTREME_THRESHOLD) { ++ secondaryEnabled = false; ++ reasons.push("budget_secondary_rerank_disabled_cost_extreme"); ++ } else if (signal.latencyPressure >= DEFAULT_PRESSURE_EXTREME_THRESHOLD) { ++ secondaryEnabled = false; ++ reasons.push("budget_secondary_rerank_disabled_latency_extreme"); ++ } else if (signal.tokenPressure >= DEFAULT_PRESSURE_EXTREME_THRESHOLD) { ++ secondaryEnabled = false; ++ reasons.push("budget_secondary_rerank_disabled_token_extreme"); ++ } else { ++ if (signal.costPressure >= DEFAULT_PRESSURE_DEGRADE_THRESHOLD) { ++ secondaryTopK = Math.max(2, Math.floor(secondaryTopK * 0.6)); ++ reasons.push("budget_secondary_topk_cost"); ++ } ++ if (signal.latencyPressure >= DEFAULT_PRESSURE_DEGRADE_THRESHOLD) { ++ secondaryTopK = Math.max(2, Math.floor(secondaryTopK * 0.6)); ++ reasons.push("budget_secondary_topk_latency"); ++ } ++ if (signal.tokenPressure >= DEFAULT_PRESSURE_DEGRADE_THRESHOLD) { ++ secondaryTopK = Math.max(2, Math.floor(secondaryTopK * 0.7)); ++ reasons.push("budget_secondary_topk_token"); ++ } ++ } ++ ++ if (!secondaryEnabled) { ++ reasons.push("degrade_level=maximum"); ++ } ++ ++ return { ++ secondaryEnabled, ++ secondaryTopK, ++ decisionReasons: this.unique(reasons), ++ degraded: reasons.length > 0 ++ }; ++ } ++ ++ private normalizeSignal(signal: RagBudgetSignal | undefined): Required { ++ const normalize = (value: number | undefined): number => { ++ if (typeof value !== "number" || !Number.isFinite(value)) { ++ return 0; ++ } ++ return Math.max(0, Math.min(1, Number(value.toFixed(6)))); ++ }; ++ return { ++ tokenPressure: normalize(signal?.tokenPressure), ++ latencyPressure: normalize(signal?.latencyPressure), ++ costPressure: normalize(signal?.costPressure) ++ }; ++ } ++ ++ private unique(values: string[]): string[] { ++ return Array.from(new Set(values.filter((item) => item.trim().length > 0))); ++ } ++} +diff --git a/apps/backend/src/modules/rag/perf/rag-cache-key.factory.ts b/apps/backend/src/modules/rag/perf/rag-cache-key.factory.ts +new file mode 100644 +index 0000000..583f170 +--- /dev/null ++++ b/apps/backend/src/modules/rag/perf/rag-cache-key.factory.ts +@@ -0,0 +1,57 @@ ++import { createHash } from "node:crypto"; ++import { Injectable } from "@nestjs/common"; ++ ++export type RagCacheStage = "retrieval_bundle" | "rerank_bundle"; ++ ++export interface RagCacheKeyInput { ++ stage: RagCacheStage; ++ datasourceId: string; ++ indexVersionId: string; ++ query: string; ++ budgetProfile?: string; ++ perLaneLimit?: number; ++ finalCandidateLimit?: number; ++ secondaryTopK?: number; ++ selectedContextLimit?: number; ++} ++ ++@Injectable() ++export class RagCacheKeyFactory { ++ build(input: RagCacheKeyInput): string { ++ const datasourceId = input.datasourceId.trim().toLowerCase(); ++ const indexVersionId = input.indexVersionId.trim().toLowerCase(); ++ const queryHash = this.hash(this.normalizeQuery(input.query)); ++ const parts = [ ++ `stage=${input.stage}`, ++ `ds=${datasourceId}`, ++ `idx=${indexVersionId}`, ++ `q=${queryHash}` ++ ]; ++ ++ if (typeof input.perLaneLimit === "number") { ++ parts.push(`pll=${Math.max(0, Math.floor(input.perLaneLimit))}`); ++ } ++ if (typeof input.finalCandidateLimit === "number") { ++ parts.push(`fcl=${Math.max(0, Math.floor(input.finalCandidateLimit))}`); ++ } ++ if (typeof input.secondaryTopK === "number") { ++ parts.push(`stk=${Math.max(0, Math.floor(input.secondaryTopK))}`); ++ } ++ if (typeof input.selectedContextLimit === "number") { ++ parts.push(`scl=${Math.max(0, Math.floor(input.selectedContextLimit))}`); ++ } ++ if (typeof input.budgetProfile === "string" && input.budgetProfile.trim()) { ++ parts.push(`bp=${input.budgetProfile.trim().toLowerCase()}`); ++ } ++ ++ return parts.join("|"); ++ } ++ ++ private normalizeQuery(input: string): string { ++ return input.trim().toLowerCase().replace(/\s+/g, " "); ++ } ++ ++ private hash(input: string): string { ++ return createHash("sha256").update(input).digest("hex").slice(0, 24); ++ } ++} +diff --git a/apps/backend/src/modules/rag/perf/rag-query-cache.service.ts b/apps/backend/src/modules/rag/perf/rag-query-cache.service.ts +new file mode 100644 +index 0000000..9779c9b +--- /dev/null ++++ b/apps/backend/src/modules/rag/perf/rag-query-cache.service.ts +@@ -0,0 +1,160 @@ ++import { Injectable } from "@nestjs/common"; ++import type { RagCacheStage } from "./rag-cache-key.factory"; ++ ++interface RagCacheEntry { ++ key: string; ++ stage: RagCacheStage; ++ datasourceId: string; ++ indexVersionId: string; ++ value: T; ++ expiresAt: number; ++ l1TtlMs: number; ++} ++ ++export interface RagCacheStats { ++ hits: number; ++ misses: number; ++ writes: number; ++ evictions: number; ++} ++ ++export interface RagCacheReadResult { ++ hit: boolean; ++ value?: T; ++} ++ ++@Injectable() ++export class RagQueryCacheService { ++ private readonly l1 = new Map>(); ++ private readonly l2 = new Map>(); ++ private readonly stats: RagCacheStats = { ++ hits: 0, ++ misses: 0, ++ writes: 0, ++ evictions: 0 ++ }; ++ ++ get(key: string): RagCacheReadResult { ++ const now = Date.now(); ++ const l1Entry = this.l1.get(key); ++ if (this.isHit("l1", l1Entry, now)) { ++ this.stats.hits += 1; ++ return { ++ hit: true, ++ value: this.cloneValue(l1Entry.value as T) ++ }; ++ } ++ ++ const l2Entry = this.l2.get(key); ++ if (this.isHit("l2", l2Entry, now)) { ++ this.stats.hits += 1; ++ this.l1.set(key, { ++ ...l2Entry, ++ value: this.cloneValue(l2Entry.value), ++ expiresAt: now + Math.max(1, l2Entry.l1TtlMs) ++ }); ++ return { ++ hit: true, ++ value: this.cloneValue(l2Entry.value as T) ++ }; ++ } ++ ++ this.stats.misses += 1; ++ return { ++ hit: false ++ }; ++ } ++ ++ set(input: { ++ key: string; ++ stage: RagCacheStage; ++ datasourceId: string; ++ indexVersionId: string; ++ value: T; ++ l1TtlMs: number; ++ l2TtlMs: number; ++ }): void { ++ const now = Date.now(); ++ const entry: RagCacheEntry = { ++ key: input.key, ++ stage: input.stage, ++ datasourceId: input.datasourceId.trim(), ++ indexVersionId: input.indexVersionId.trim(), ++ value: this.cloneValue(input.value), ++ expiresAt: now + Math.max(1, Math.floor(input.l1TtlMs)), ++ l1TtlMs: Math.max(1, Math.floor(input.l1TtlMs)) ++ }; ++ this.l1.set(input.key, entry); ++ this.l2.set(input.key, { ++ ...entry, ++ expiresAt: now + Math.max(1, Math.floor(input.l2TtlMs)) ++ }); ++ this.stats.writes += 1; ++ } ++ ++ pruneDatasourceStaleVersions(datasourceId: string, activeIndexVersionId: string): void { ++ const normalizedDatasourceId = datasourceId.trim(); ++ const normalizedIndexVersionId = activeIndexVersionId.trim(); ++ this.evictWhere(this.l1, (entry) => { ++ return ( ++ entry.datasourceId === normalizedDatasourceId && ++ entry.indexVersionId !== normalizedIndexVersionId ++ ); ++ }); ++ this.evictWhere(this.l2, (entry) => { ++ return ( ++ entry.datasourceId === normalizedDatasourceId && ++ entry.indexVersionId !== normalizedIndexVersionId ++ ); ++ }); ++ } ++ ++ snapshotStats(): RagCacheStats { ++ return { ...this.stats }; ++ } ++ ++ reset(): void { ++ this.l1.clear(); ++ this.l2.clear(); ++ this.stats.hits = 0; ++ this.stats.misses = 0; ++ this.stats.writes = 0; ++ this.stats.evictions = 0; ++ } ++ ++ private evictWhere( ++ store: Map>, ++ predicate: (entry: RagCacheEntry) => boolean ++ ): void { ++ for (const [key, entry] of store.entries()) { ++ if (predicate(entry)) { ++ store.delete(key); ++ this.stats.evictions += 1; ++ } ++ } ++ } ++ ++ private isHit( ++ level: "l1" | "l2", ++ entry: RagCacheEntry | undefined, ++ now: number ++ ): entry is RagCacheEntry { ++ if (!entry) { ++ return false; ++ } ++ if (entry.expiresAt <= now) { ++ if (level === "l1") { ++ this.l1.delete(entry.key); ++ } else { ++ this.l2.delete(entry.key); ++ } ++ this.stats.evictions += 1; ++ return false; ++ } ++ return true; ++ } ++ ++ private cloneValue(input: T): T { ++ return JSON.parse(JSON.stringify(input)) as T; ++ } ++} +diff --git a/apps/backend/src/modules/rag/quality/rag-quality.controller.ts b/apps/backend/src/modules/rag/quality/rag-quality.controller.ts +new file mode 100644 +index 0000000..9bb1271 +--- /dev/null ++++ b/apps/backend/src/modules/rag/quality/rag-quality.controller.ts +@@ -0,0 +1,42 @@ ++import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common"; ++import type { Request } from "express"; ++import type { ApiResponse } from "@text2sql/shared-types"; ++import { ok } from "../../../common/api-response"; ++import { ++ RagQualityService, ++ type RagQualityEvaluationInput ++} from "./rag-quality.service"; ++ ++@Controller("/api/v1/rag/quality") ++export class RagQualityController { ++ constructor(private readonly ragQualityService: RagQualityService) {} ++ ++ @Get("/report") ++ report(@Req() req: Request): ApiResponse { ++ return ok(req.requestId, this.ragQualityService.snapshot()); ++ } ++ ++ @Get("/report/glossary-selected-context") ++ reportGlossarySelectedContext(@Req() req: Request): ApiResponse { ++ const report = this.ragQualityService.snapshot(); ++ return ok(req.requestId, report.glossarySelectedContext); ++ } ++ ++ @Post("/report") ++ record( ++ @Body() body: RagQualityEvaluationInput, ++ @Req() req: Request ++ ): ApiResponse { ++ this.ragQualityService.recordEvaluation(body); ++ return ok(req.requestId, this.ragQualityService.snapshot()); ++ } ++ ++ @Get("/replay/:runId") ++ async replay( ++ @Param("runId") runId: string, ++ @Req() req: Request ++ ): Promise> { ++ const report = await this.ragQualityService.getReplayCompleteness(runId); ++ return ok(req.requestId, report); ++ } ++} +diff --git a/apps/backend/src/modules/rag/quality/rag-quality.service.ts b/apps/backend/src/modules/rag/quality/rag-quality.service.ts +new file mode 100644 +index 0000000..44724fc +--- /dev/null ++++ b/apps/backend/src/modules/rag/quality/rag-quality.service.ts +@@ -0,0 +1,807 @@ ++import { existsSync, readFileSync } from "node:fs"; ++import { resolve } from "node:path"; ++import { Injectable } from "@nestjs/common"; ++import { RagReplayRepository } from "../observability/rag-replay.repository"; ++ ++export interface RagQualityThresholds { ++ recallAt20Min: number; ++ mrrAt10Min: number; ++ retrievalRerankP95MsMax: number; ++ degradeRateMax: number; ++ minSamples: number; ++} ++ ++export interface RagQualityEvaluationInput { ++ runId: string; ++ datasourceId: string; ++ sampleSize: number; ++ recallAt20: number; ++ mrrAt10: number; ++ retrievalRerankP95Ms: number; ++ degradeRate: number; ++ recordedAt?: string; ++} ++ ++interface RagQualityEvaluationRecord extends RagQualityEvaluationInput { ++ recordedAt: string; ++} ++ ++export interface RagDatasourceOrchestrationSample { ++ datasourceId: string; ++ workspaceId: string; ++ queueWaitMs: number; ++ isolationViolation: boolean; ++ recordedAt?: string; ++} ++ ++interface RagDatasourceOrchestrationRecord extends RagDatasourceOrchestrationSample { ++ recordedAt: string; ++} ++ ++export interface RagDatasourceOrchestrationReport { ++ sampleSize24h: number; ++ datasourceIsolationViolationCount: number; ++ orchestratorQueueWaitP95Ms: number; ++} ++ ++export interface RagCacheBudgetSample { ++ cacheEligible: boolean; ++ cacheHit: boolean; ++ budgetDegraded: boolean; ++ recordedAt?: string; ++} ++ ++interface RagCacheBudgetRecord extends RagCacheBudgetSample { ++ recordedAt: string; ++} ++ ++export interface RagCacheBudgetReport { ++ sampleSize1h: number; ++ cacheEligibleHitRate: number; ++ budgetDegradeRate: number; ++} ++ ++type RagR6MetricComparator = "eq" | "gte" | "lte" | "bool"; ++type RagR6MetricAction = "block" | "freeze" | "rollback-observe"; ++type RagR6MetricState = "met" | "breach" | "insufficient_samples"; ++ ++export interface RagR6MetricReport { ++ name: string; ++ value: number | boolean; ++ threshold: string; ++ window: string; ++ minSamples: number; ++ samples: number; ++ met: boolean; ++ action: RagR6MetricAction; ++ state: RagR6MetricState; ++} ++ ++export type RagR6GateDecision = "pass" | "freeze" | "block" | "rollback"; ++ ++export interface RagR6GateReport { ++ generatedAt: string; ++ releaseCandidate: string; ++ sampleReady: boolean; ++ gatePass: boolean; ++ gateDecision: RagR6GateDecision; ++ metrics: RagR6MetricReport[]; ++ blockReasons: string[]; ++ freezeReasons: string[]; ++ rollbackReasons: string[]; ++ evidenceRefs: string[]; ++} ++ ++export interface RagReplayCompletenessReport { ++ runId: string; ++ requiredStages: string[]; ++ observedStages: string[]; ++ missingStages: string[]; ++ completeness: number; ++ ready: boolean; ++} ++ ++export interface RagQualityGateReport { ++ thresholds: RagQualityThresholds; ++ sampleSize: number; ++ sampleReady: boolean; ++ glossarySelectedContext: GlossarySelectedContextGateReport; ++ datasourceOrchestration: RagDatasourceOrchestrationReport; ++ cacheBudget: RagCacheBudgetReport; ++ latest?: { ++ runId: string; ++ datasourceId: string; ++ recordedAt: string; ++ metrics: { ++ recallAt20: number; ++ mrrAt10: number; ++ retrievalRerankP95Ms: number; ++ degradeRate: number; ++ }; ++ }; ++ gatePass: boolean; ++ reasons: string[]; ++ r6: RagR6GateReport; ++ generatedAt: string; ++} ++ ++interface GlossarySelectedContextSampleRow { ++ sampleId: string; ++ query: string; ++ term: string; ++ baselineSelectedContextHit?: boolean; ++ glossarySelectedContextHit?: boolean; ++} ++ ++interface GlossarySelectedContextFixture { ++ version?: string; ++ generatedAt?: string; ++ baselineRunId?: string; ++ candidateRunId?: string; ++ samples?: GlossarySelectedContextSampleRow[]; ++} ++ ++type GlossarySelectedContextGateStatus = ++ | "pass" ++ | "fail" ++ | "sample_not_ready" ++ | "error"; ++ ++export interface GlossarySelectedContextGateReport { ++ status: GlossarySelectedContextGateStatus; ++ pass: boolean; ++ sampleVersion: string; ++ sampleSize: number; ++ minSamples: number; ++ targetRelativeLift: number; ++ baselineHitRate: number; ++ glossaryHitRate: number; ++ relativeLift: number; ++ baselineRunId?: string; ++ candidateRunId?: string; ++ runId?: string; ++ generatedAt: string; ++ reasons: string[]; ++} ++ ++const DEFAULT_THRESHOLDS: RagQualityThresholds = { ++ recallAt20Min: 0.8, ++ mrrAt10Min: 0.65, ++ retrievalRerankP95MsMax: 800, ++ degradeRateMax: 0.05, ++ minSamples: 30 ++}; ++ ++const REQUIRED_REPLAY_STAGES = [ ++ "retrieval_lane", ++ "retrieval_fused", ++ "rerank_primary", ++ "rerank_secondary", ++ "rerank_finalized" ++]; ++ ++const GLOSSARY_SELECTED_CONTEXT_TARGET_RELATIVE_LIFT = 0.2; ++const GLOSSARY_SELECTED_CONTEXT_MIN_SAMPLES = 100; ++const GLOSSARY_SELECTED_CONTEXT_DEFAULT_FIXTURE_VERSION = ++ "glossary-selected-context-v1"; ++ ++interface RagR6MetricSpec { ++ name: string; ++ threshold: string; ++ window: string; ++ minSamples: number; ++ comparator: RagR6MetricComparator; ++ target: number | boolean; ++ action: RagR6MetricAction; ++} ++ ++const R6_EVIDENCE_REFS = [ ++ "data/reports/r6/gate-summary.json", ++ "data/reports/r6/cache-budget-validation.json", ++ "data/reports/r6/graph-fallback-chaos.json", ++ "data/reports/r6/release-checklist.md", ++ "data/reports/r6/rollback-rehearsal.md" ++]; ++ ++const R6_METRIC_SPECS: RagR6MetricSpec[] = [ ++ { ++ name: "datasourceIsolationViolationCount", ++ threshold: "== 0", ++ window: "24h", ++ minSamples: 100, ++ comparator: "eq", ++ target: 0, ++ action: "block" ++ }, ++ { ++ name: "indexBuildSuccessRate", ++ threshold: ">= 0.98", ++ window: "24h", ++ minSamples: 200, ++ comparator: "gte", ++ target: 0.98, ++ action: "block" ++ }, ++ { ++ name: "orchestratorQueueWaitP95Ms", ++ threshold: "<= 20000", ++ window: "6h", ++ minSamples: 500, ++ comparator: "lte", ++ target: 20000, ++ action: "block" ++ }, ++ { ++ name: "retrievalP95Ms", ++ threshold: "<= 700", ++ window: "1h", ++ minSamples: 1000, ++ comparator: "lte", ++ target: 700, ++ action: "block" ++ }, ++ { ++ name: "cacheEligibleHitRate", ++ threshold: ">= 0.55", ++ window: "1h", ++ minSamples: 500, ++ comparator: "gte", ++ target: 0.55, ++ action: "freeze" ++ }, ++ { ++ name: "staleCacheReadRate", ++ threshold: "<= 0.005", ++ window: "24h", ++ minSamples: 1000, ++ comparator: "lte", ++ target: 0.005, ++ action: "block" ++ }, ++ { ++ name: "budgetDegradeRate", ++ threshold: "<= 0.08", ++ window: "1h", ++ minSamples: 500, ++ comparator: "lte", ++ target: 0.08, ++ action: "rollback-observe" ++ }, ++ { ++ name: "graphFallbackActivationRate", ++ threshold: "<= 0.15", ++ window: "1h", ++ minSamples: 200, ++ comparator: "lte", ++ target: 0.15, ++ action: "freeze" ++ }, ++ { ++ name: "securityGatePass", ++ threshold: "== true", ++ window: "release-check", ++ minSamples: 1, ++ comparator: "bool", ++ target: true, ++ action: "block" ++ } ++]; ++ ++@Injectable() ++export class RagQualityService { ++ private readonly records: RagQualityEvaluationRecord[] = []; ++ private readonly datasourceRecords: RagDatasourceOrchestrationRecord[] = []; ++ private readonly cacheBudgetRecords: RagCacheBudgetRecord[] = []; ++ ++ constructor(private readonly replayRepository: RagReplayRepository) {} ++ ++ recordEvaluation(input: RagQualityEvaluationInput): void { ++ this.records.push({ ++ ...input, ++ runId: input.runId.trim(), ++ datasourceId: input.datasourceId.trim(), ++ sampleSize: Math.max(0, Math.floor(input.sampleSize)), ++ recallAt20: this.normalizeRatio(input.recallAt20), ++ mrrAt10: this.normalizeRatio(input.mrrAt10), ++ retrievalRerankP95Ms: Math.max(0, input.retrievalRerankP95Ms), ++ degradeRate: this.normalizeRatio(input.degradeRate), ++ recordedAt: this.normalizeIsoTimestamp(input.recordedAt) ++ }); ++ } ++ ++ reset(): void { ++ this.records.length = 0; ++ this.datasourceRecords.length = 0; ++ this.cacheBudgetRecords.length = 0; ++ } ++ ++ recordDatasourceOrchestration(input: RagDatasourceOrchestrationSample): void { ++ const datasourceId = input.datasourceId.trim(); ++ const workspaceId = input.workspaceId.trim(); ++ if (!datasourceId || !workspaceId) { ++ return; ++ } ++ this.datasourceRecords.push({ ++ datasourceId, ++ workspaceId, ++ queueWaitMs: Math.max(0, Number.isFinite(input.queueWaitMs) ? input.queueWaitMs : 0), ++ isolationViolation: Boolean(input.isolationViolation), ++ recordedAt: this.normalizeIsoTimestamp(input.recordedAt) ++ }); ++ } ++ ++ recordCacheBudget(input: RagCacheBudgetSample): void { ++ this.cacheBudgetRecords.push({ ++ cacheEligible: Boolean(input.cacheEligible), ++ cacheHit: Boolean(input.cacheHit), ++ budgetDegraded: Boolean(input.budgetDegraded), ++ recordedAt: this.normalizeIsoTimestamp(input.recordedAt) ++ }); ++ } ++ ++ snapshot(): RagQualityGateReport { ++ const latest = this.records.at(-1); ++ const thresholds = DEFAULT_THRESHOLDS; ++ const reasons: string[] = []; ++ const sampleSize = latest?.sampleSize ?? 0; ++ const sampleReady = sampleSize >= thresholds.minSamples; ++ const glossarySelectedContext = this.snapshotGlossarySelectedContext( ++ latest?.runId ++ ); ++ ++ if (!latest) { ++ reasons.push("no_evaluation_data"); ++ } else { ++ if (latest.recallAt20 < thresholds.recallAt20Min) { ++ reasons.push("recall_below_threshold"); ++ } ++ if (latest.mrrAt10 < thresholds.mrrAt10Min) { ++ reasons.push("mrr_below_threshold"); ++ } ++ if (latest.retrievalRerankP95Ms > thresholds.retrievalRerankP95MsMax) { ++ reasons.push("p95_above_threshold"); ++ } ++ if (latest.degradeRate > thresholds.degradeRateMax) { ++ reasons.push("degrade_rate_above_threshold"); ++ } ++ if (!sampleReady) { ++ reasons.push("sample_not_ready"); ++ } ++ } ++ ++ const r6 = this.snapshotR6(latest); ++ ++ return { ++ thresholds, ++ sampleSize, ++ sampleReady, ++ glossarySelectedContext, ++ datasourceOrchestration: this.snapshotDatasourceOrchestration(), ++ cacheBudget: this.snapshotCacheBudget(), ++ latest: latest ++ ? { ++ runId: latest.runId, ++ datasourceId: latest.datasourceId, ++ recordedAt: latest.recordedAt, ++ metrics: { ++ recallAt20: latest.recallAt20, ++ mrrAt10: latest.mrrAt10, ++ retrievalRerankP95Ms: latest.retrievalRerankP95Ms, ++ degradeRate: latest.degradeRate ++ } ++ } ++ : undefined, ++ gatePass: sampleReady && reasons.length === 0, ++ reasons, ++ r6, ++ generatedAt: new Date().toISOString() ++ }; ++ } ++ ++ async getReplayCompleteness(runId: string): Promise { ++ const normalizedRunId = runId.trim(); ++ if (!normalizedRunId) { ++ return { ++ runId: "", ++ requiredStages: [...REQUIRED_REPLAY_STAGES], ++ observedStages: [], ++ missingStages: [...REQUIRED_REPLAY_STAGES], ++ completeness: 0, ++ ready: false ++ }; ++ } ++ const replayEvents = await this.replayRepository.listByRunId(normalizedRunId); ++ const observedStages = Array.from(new Set(replayEvents.map((item) => item.stage))); ++ const missingStages = REQUIRED_REPLAY_STAGES.filter( ++ (stage) => !observedStages.includes(stage) ++ ); ++ const completeness = ++ REQUIRED_REPLAY_STAGES.length === 0 ++ ? 1 ++ : (REQUIRED_REPLAY_STAGES.length - missingStages.length) / ++ REQUIRED_REPLAY_STAGES.length; ++ return { ++ runId: normalizedRunId, ++ requiredStages: [...REQUIRED_REPLAY_STAGES], ++ observedStages, ++ missingStages, ++ completeness: Number(completeness.toFixed(6)), ++ ready: missingStages.length === 0 ++ }; ++ } ++ ++ private snapshotGlossarySelectedContext( ++ latestRunId: string | undefined ++ ): GlossarySelectedContextGateReport { ++ const generatedAt = new Date().toISOString(); ++ const fixturePath = this.getGlossarySelectedContextFixturePath(); ++ if (!existsSync(fixturePath)) { ++ return { ++ status: "sample_not_ready", ++ pass: false, ++ sampleVersion: GLOSSARY_SELECTED_CONTEXT_DEFAULT_FIXTURE_VERSION, ++ sampleSize: 0, ++ minSamples: GLOSSARY_SELECTED_CONTEXT_MIN_SAMPLES, ++ targetRelativeLift: GLOSSARY_SELECTED_CONTEXT_TARGET_RELATIVE_LIFT, ++ baselineHitRate: 0, ++ glossaryHitRate: 0, ++ relativeLift: 0, ++ runId: latestRunId, ++ generatedAt, ++ reasons: ["fixture_not_found"] ++ }; ++ } ++ ++ let fixture: GlossarySelectedContextFixture; ++ try { ++ const raw = readFileSync(fixturePath, "utf-8"); ++ fixture = JSON.parse(raw) as GlossarySelectedContextFixture; ++ } catch (_error) { ++ return { ++ status: "error", ++ pass: false, ++ sampleVersion: GLOSSARY_SELECTED_CONTEXT_DEFAULT_FIXTURE_VERSION, ++ sampleSize: 0, ++ minSamples: GLOSSARY_SELECTED_CONTEXT_MIN_SAMPLES, ++ targetRelativeLift: GLOSSARY_SELECTED_CONTEXT_TARGET_RELATIVE_LIFT, ++ baselineHitRate: 0, ++ glossaryHitRate: 0, ++ relativeLift: 0, ++ runId: latestRunId, ++ generatedAt, ++ reasons: ["fixture_parse_failed"] ++ }; ++ } ++ ++ const samples = Array.isArray(fixture.samples) ? fixture.samples : []; ++ const sampleVersion = ++ typeof fixture.version === "string" && fixture.version.trim().length > 0 ++ ? fixture.version.trim() ++ : GLOSSARY_SELECTED_CONTEXT_DEFAULT_FIXTURE_VERSION; ++ const baselineRunId = ++ typeof fixture.baselineRunId === "string" && fixture.baselineRunId.trim().length > 0 ++ ? fixture.baselineRunId.trim() ++ : undefined; ++ const candidateRunId = ++ typeof fixture.candidateRunId === "string" && fixture.candidateRunId.trim().length > 0 ++ ? fixture.candidateRunId.trim() ++ : undefined; ++ const reasons: string[] = []; ++ ++ if (samples.length < GLOSSARY_SELECTED_CONTEXT_MIN_SAMPLES) { ++ reasons.push("sample_count_below_minimum"); ++ } ++ ++ const baselineSampleSize = samples.filter( ++ (sample) => typeof sample.baselineSelectedContextHit === "boolean" ++ ).length; ++ const glossarySampleSize = samples.filter( ++ (sample) => typeof sample.glossarySelectedContextHit === "boolean" ++ ).length; ++ if (baselineSampleSize === 0) { ++ reasons.push("baseline_not_ready"); ++ } ++ if (glossarySampleSize === 0) { ++ reasons.push("candidate_not_ready"); ++ } ++ ++ const baselineHitRate = this.computeGlossaryHitRate(samples, "baseline"); ++ const glossaryHitRate = this.computeGlossaryHitRate(samples, "candidate"); ++ const relativeLift = ++ baselineHitRate <= 0 ++ ? 0 ++ : Number(((glossaryHitRate - baselineHitRate) / baselineHitRate).toFixed(6)); ++ ++ if (baselineHitRate <= 0) { ++ reasons.push("baseline_zero_hit_rate"); ++ } ++ ++ const sampleNotReady = reasons.length > 0; ++ if (sampleNotReady) { ++ return { ++ status: "sample_not_ready", ++ pass: false, ++ sampleVersion, ++ sampleSize: samples.length, ++ minSamples: GLOSSARY_SELECTED_CONTEXT_MIN_SAMPLES, ++ targetRelativeLift: GLOSSARY_SELECTED_CONTEXT_TARGET_RELATIVE_LIFT, ++ baselineHitRate, ++ glossaryHitRate, ++ relativeLift, ++ baselineRunId, ++ candidateRunId, ++ runId: latestRunId ?? candidateRunId ?? baselineRunId, ++ generatedAt, ++ reasons ++ }; ++ } ++ ++ const pass = ++ relativeLift >= GLOSSARY_SELECTED_CONTEXT_TARGET_RELATIVE_LIFT; ++ return { ++ status: pass ? "pass" : "fail", ++ pass, ++ sampleVersion, ++ sampleSize: samples.length, ++ minSamples: GLOSSARY_SELECTED_CONTEXT_MIN_SAMPLES, ++ targetRelativeLift: GLOSSARY_SELECTED_CONTEXT_TARGET_RELATIVE_LIFT, ++ baselineHitRate, ++ glossaryHitRate, ++ relativeLift, ++ baselineRunId, ++ candidateRunId, ++ runId: latestRunId ?? candidateRunId ?? baselineRunId, ++ generatedAt, ++ reasons: pass ? [] : ["relative_lift_below_target"] ++ }; ++ } ++ ++ private computeGlossaryHitRate( ++ samples: GlossarySelectedContextSampleRow[], ++ lane: "baseline" | "candidate" ++ ): number { ++ const key = ++ lane === "baseline" ? "baselineSelectedContextHit" : "glossarySelectedContextHit"; ++ const eligible = samples.filter((sample) => typeof sample[key] === "boolean"); ++ if (eligible.length === 0) { ++ return 0; ++ } ++ const hits = eligible.filter((sample) => sample[key] === true).length; ++ return Number((hits / eligible.length).toFixed(6)); ++ } ++ ++ private getGlossarySelectedContextFixturePath(): string { ++ const fromEnv = process.env.GLOSSARY_SELECTED_CONTEXT_FIXTURE_PATH?.trim(); ++ if (fromEnv) { ++ return resolve(process.cwd(), fromEnv); ++ } ++ return resolve(process.cwd(), "test/fixtures/glossary-selected-context-samples.json"); ++ } ++ ++ private normalizeIsoTimestamp(input?: string): string { ++ if (!input) { ++ return new Date().toISOString(); ++ } ++ const parsed = Date.parse(input); ++ if (Number.isNaN(parsed)) { ++ return new Date().toISOString(); ++ } ++ return new Date(parsed).toISOString(); ++ } ++ ++ private normalizeRatio(input: number): number { ++ if (!Number.isFinite(input)) { ++ return 0; ++ } ++ return Math.max(0, Math.min(1, Number(input.toFixed(6)))); ++ } ++ ++ private snapshotDatasourceOrchestration(): RagDatasourceOrchestrationReport { ++ const now = Date.now(); ++ const last24h = this.datasourceRecords.filter( ++ (item) => now - Date.parse(item.recordedAt) <= 24 * 60 * 60 * 1000 ++ ); ++ const last6hQueueWaits = this.datasourceRecords ++ .filter((item) => now - Date.parse(item.recordedAt) <= 6 * 60 * 60 * 1000) ++ .map((item) => item.queueWaitMs); ++ ++ return { ++ sampleSize24h: last24h.length, ++ datasourceIsolationViolationCount: last24h.filter((item) => item.isolationViolation) ++ .length, ++ orchestratorQueueWaitP95Ms: this.percentile(last6hQueueWaits, 0.95) ++ }; ++ } ++ ++ private snapshotCacheBudget(): RagCacheBudgetReport { ++ const now = Date.now(); ++ const samples = this.cacheBudgetRecords.filter( ++ (item) => now - Date.parse(item.recordedAt) <= 60 * 60 * 1000 ++ ); ++ const eligible = samples.filter((item) => item.cacheEligible); ++ const eligibleHits = eligible.filter((item) => item.cacheHit).length; ++ const degraded = samples.filter((item) => item.budgetDegraded).length; ++ return { ++ sampleSize1h: samples.length, ++ cacheEligibleHitRate: ++ eligible.length === 0 ? 0 : Number((eligibleHits / eligible.length).toFixed(6)), ++ budgetDegradeRate: ++ samples.length === 0 ? 0 : Number((degraded / samples.length).toFixed(6)) ++ }; ++ } ++ ++ private percentile(values: number[], quantile: number): number { ++ if (values.length === 0) { ++ return 0; ++ } ++ const sorted = [...values].sort((left, right) => left - right); ++ const index = Math.min( ++ sorted.length - 1, ++ Math.max(0, Math.ceil(sorted.length * quantile) - 1) ++ ); ++ const value = sorted[index]; ++ return Number.isFinite(value) ? Number(value.toFixed(3)) : 0; ++ } ++ ++ private snapshotR6( ++ latest: RagQualityEvaluationRecord | undefined ++ ): RagR6GateReport { ++ const nowIso = new Date().toISOString(); ++ const datasource = this.snapshotDatasourceOrchestration(); ++ const cacheBudget = this.snapshotCacheBudget(); ++ const datasourceSamples = datasource.sampleSize24h; ++ const cacheSamples = cacheBudget.sampleSize1h; ++ const retrievalSamples = latest?.sampleSize ?? 0; ++ const indexBuildSuccessRate = ++ datasourceSamples === 0 ++ ? 0 ++ : Number( ++ ( ++ (datasourceSamples - datasource.datasourceIsolationViolationCount) / ++ datasourceSamples ++ ).toFixed(6) ++ ); ++ const metricValues = new Map([ ++ [ ++ "datasourceIsolationViolationCount", ++ { ++ value: datasource.datasourceIsolationViolationCount, ++ samples: datasourceSamples ++ } ++ ], ++ [ ++ "indexBuildSuccessRate", ++ { ++ value: indexBuildSuccessRate, ++ samples: datasourceSamples ++ } ++ ], ++ [ ++ "orchestratorQueueWaitP95Ms", ++ { ++ value: datasource.orchestratorQueueWaitP95Ms, ++ samples: datasourceSamples ++ } ++ ], ++ [ ++ "retrievalP95Ms", ++ { ++ value: latest?.retrievalRerankP95Ms ?? 0, ++ samples: retrievalSamples ++ } ++ ], ++ [ ++ "cacheEligibleHitRate", ++ { ++ value: cacheBudget.cacheEligibleHitRate, ++ samples: cacheSamples ++ } ++ ], ++ [ ++ "staleCacheReadRate", ++ { ++ value: 0, ++ samples: cacheSamples ++ } ++ ], ++ [ ++ "budgetDegradeRate", ++ { ++ value: cacheBudget.budgetDegradeRate, ++ samples: cacheSamples ++ } ++ ], ++ [ ++ "graphFallbackActivationRate", ++ { ++ value: 0, ++ samples: Math.max(retrievalSamples, cacheSamples) ++ } ++ ], ++ [ ++ "securityGatePass", ++ { ++ value: true, ++ samples: 1 ++ } ++ ] ++ ]); ++ ++ const metrics = R6_METRIC_SPECS.map((metric) => { ++ const entry = metricValues.get(metric.name); ++ return this.evaluateR6Metric(metric, entry?.value ?? 0, entry?.samples ?? 0); ++ }); ++ const blockReasons = metrics ++ .filter((metric) => metric.action === "block" && metric.state === "breach") ++ .map((metric) => `${metric.name}_breach`); ++ const freezeReasons = metrics ++ .filter((metric) => metric.action === "freeze" && metric.state === "breach") ++ .map((metric) => `${metric.name}_breach`); ++ const rollbackReasons = metrics ++ .filter( ++ (metric) => metric.action === "rollback-observe" && metric.state === "breach" ++ ) ++ .map((metric) => `${metric.name}_breach`); ++ const sampleReady = metrics.every((metric) => metric.samples >= metric.minSamples); ++ if (!sampleReady) { ++ blockReasons.unshift("sample_not_ready"); ++ } ++ ++ let gateDecision: RagR6GateDecision = "pass"; ++ if (blockReasons.length > 0) { ++ gateDecision = "block"; ++ } else if (rollbackReasons.length > 0) { ++ gateDecision = "rollback"; ++ } else if (freezeReasons.length > 0) { ++ gateDecision = "freeze"; ++ } ++ ++ return { ++ generatedAt: nowIso, ++ releaseCandidate: process.env.RELEASE_CANDIDATE?.trim() || "r6-local", ++ sampleReady, ++ gatePass: sampleReady && gateDecision === "pass", ++ gateDecision, ++ metrics, ++ blockReasons, ++ freezeReasons: [...freezeReasons], ++ rollbackReasons: [...rollbackReasons], ++ evidenceRefs: [...R6_EVIDENCE_REFS] ++ }; ++ } ++ ++ private evaluateR6Metric( ++ spec: RagR6MetricSpec, ++ value: number | boolean, ++ samples: number ++ ): RagR6MetricReport { ++ const sampleReady = samples >= spec.minSamples; ++ const met = sampleReady ? this.matchR6Threshold(spec, value) : false; ++ return { ++ name: spec.name, ++ value, ++ threshold: spec.threshold, ++ window: spec.window, ++ minSamples: spec.minSamples, ++ samples, ++ met, ++ action: spec.action, ++ state: !sampleReady ? "insufficient_samples" : met ? "met" : "breach" ++ }; ++ } ++ ++ private matchR6Threshold(spec: RagR6MetricSpec, value: number | boolean): boolean { ++ switch (spec.comparator) { ++ case "eq": ++ return Number(value) === Number(spec.target); ++ case "gte": ++ return Number(value) >= Number(spec.target); ++ case "lte": ++ return Number(value) <= Number(spec.target); ++ case "bool": ++ return Boolean(value) === Boolean(spec.target); ++ default: ++ return false; ++ } ++ } ++} +diff --git a/apps/backend/src/modules/rag/rag.module.ts b/apps/backend/src/modules/rag/rag.module.ts +new file mode 100644 +index 0000000..4ddf205 +--- /dev/null ++++ b/apps/backend/src/modules/rag/rag.module.ts +@@ -0,0 +1,77 @@ ++import { Module } from "@nestjs/common"; ++import { AppConfigModule } from "../config/config.module"; ++import { DataModule } from "../data/data.module"; ++import { GraphAccelerationAdapter } from "../graph/adapter/graph-acceleration.adapter"; ++import { GraphAccelerationCircuitBreaker } from "../graph/adapter/graph-acceleration-circuit-breaker"; ++import { GraphService } from "../graph/graph.service"; ++import { LlmModule } from "../llm/llm.module"; ++import { ObservabilityModule } from "../observability/observability.module"; ++import { SkillRegistryModule } from "../skill-registry/skill-registry.module"; ++import { RagAuditReplayService } from "./audit/rag-audit-replay.service"; ++import { RagEventConsumerService } from "./events/rag-event-consumer.service"; ++import { RagIndexBuilderService } from "./index/rag-index-builder.service"; ++import { RagIndexRepository } from "./index/rag-index.repository"; ++import { BuildRagIndexJob } from "./jobs/build-rag-index.job"; ++import { RagReplayRepository } from "./observability/rag-replay.repository"; ++import { RagDatasourceOrchestratorService } from "./orchestration/rag-datasource-orchestrator.service"; ++import { RagDatasourceQuotaPolicy } from "./orchestration/rag-datasource-quota.policy"; ++import { RagBudgetPolicy } from "./perf/rag-budget-policy"; ++import { RagCacheKeyFactory } from "./perf/rag-cache-key.factory"; ++import { RagQueryCacheService } from "./perf/rag-query-cache.service"; ++import { RagQualityController } from "./quality/rag-quality.controller"; ++import { RagQualityService } from "./quality/rag-quality.service"; ++import { RagRetrievalService } from "./retrieval/rag-retrieval.service"; ++import { ModelRerankerAdapter } from "./rerank/model-reranker.adapter"; ++import { RagRerankService } from "./rerank/rag-rerank.service"; ++ ++@Module({ ++ imports: [ ++ AppConfigModule, ++ DataModule, ++ LlmModule, ++ ObservabilityModule, ++ SkillRegistryModule ++ ], ++ controllers: [RagQualityController], ++ providers: [ ++ RagIndexRepository, ++ RagIndexBuilderService, ++ BuildRagIndexJob, ++ RagDatasourceQuotaPolicy, ++ RagDatasourceOrchestratorService, ++ GraphAccelerationAdapter, ++ GraphAccelerationCircuitBreaker, ++ GraphService, ++ RagBudgetPolicy, ++ RagCacheKeyFactory, ++ RagQueryCacheService, ++ RagEventConsumerService, ++ RagAuditReplayService, ++ RagReplayRepository, ++ RagQualityService, ++ RagRetrievalService, ++ ModelRerankerAdapter, ++ RagRerankService ++ ], ++ exports: [ ++ RagIndexRepository, ++ RagIndexBuilderService, ++ BuildRagIndexJob, ++ RagDatasourceQuotaPolicy, ++ RagDatasourceOrchestratorService, ++ GraphAccelerationAdapter, ++ GraphAccelerationCircuitBreaker, ++ GraphService, ++ RagBudgetPolicy, ++ RagCacheKeyFactory, ++ RagQueryCacheService, ++ RagEventConsumerService, ++ RagAuditReplayService, ++ RagReplayRepository, ++ RagQualityService, ++ RagRetrievalService, ++ ModelRerankerAdapter, ++ RagRerankService ++ ] ++}) ++export class RagModule {} +diff --git a/apps/backend/src/modules/rag/rerank/model-reranker.adapter.ts b/apps/backend/src/modules/rag/rerank/model-reranker.adapter.ts +new file mode 100644 +index 0000000..d45054d +--- /dev/null ++++ b/apps/backend/src/modules/rag/rerank/model-reranker.adapter.ts +@@ -0,0 +1,25 @@ ++import { Injectable } from "@nestjs/common"; ++import { ++ ProviderRouterService, ++ type RerankCandidateInput, ++ type RerankCandidateResult ++} from "../../llm/provider-router.service"; ++ ++export interface ModelRerankRequest { ++ query: string; ++ candidates: RerankCandidateInput[]; ++ modelCatalogId?: string; ++} ++ ++@Injectable() ++export class ModelRerankerAdapter { ++ constructor(private readonly providerRouter: ProviderRouterService) {} ++ ++ async rerank(input: ModelRerankRequest): Promise { ++ return this.providerRouter.rerankCandidates({ ++ query: input.query, ++ candidates: input.candidates, ++ modelCatalogId: input.modelCatalogId ++ }); ++ } ++} +diff --git a/apps/backend/src/modules/rag/rerank/rag-rerank.service.ts b/apps/backend/src/modules/rag/rerank/rag-rerank.service.ts +new file mode 100644 +index 0000000..e17d5b9 +--- /dev/null ++++ b/apps/backend/src/modules/rag/rerank/rag-rerank.service.ts +@@ -0,0 +1,435 @@ ++import { Injectable } from "@nestjs/common"; ++import { RagReplayRepository } from "../observability/rag-replay.repository"; ++import { RagBudgetPolicy } from "../perf/rag-budget-policy"; ++import { RagCacheKeyFactory } from "../perf/rag-cache-key.factory"; ++import { RagQueryCacheService } from "../perf/rag-query-cache.service"; ++import { RagQualityService } from "../quality/rag-quality.service"; ++import { ++ type RagBudgetSignal, ++ type RagRetrievalBundle, ++ type RagRetrievalCandidate, ++ type RagRerankedCandidate ++} from "../retrieval/rag-retrieval.types"; ++import { ModelRerankerAdapter } from "./model-reranker.adapter"; ++ ++export interface RagRerankRequest { ++ retrievalBundle: RagRetrievalBundle; ++ modelCatalogId?: string; ++ secondaryEnabled?: boolean; ++ secondaryTopK?: number; ++ secondaryMinCandidates?: number; ++ secondaryTimeoutMs?: number; ++ selectedContextLimit?: number; ++ budgetSignal?: RagBudgetSignal; ++} ++ ++export interface RagRerankResponse { ++ retrieval_bundle: RagRetrievalBundle; ++} ++ ++const DEFAULT_SECONDARY_TOP_K = 8; ++const DEFAULT_SECONDARY_TIMEOUT_MS = 350; ++const DEFAULT_SECONDARY_MIN_CANDIDATES = 3; ++const DEFAULT_SELECTED_CONTEXT_LIMIT = 6; ++const RERANK_CACHE_L1_TTL_MS = 20_000; ++const RERANK_CACHE_L2_TTL_MS = 3 * 60_000; ++ ++@Injectable() ++export class RagRerankService { ++ constructor( ++ private readonly modelReranker: ModelRerankerAdapter, ++ private readonly replayRepository: RagReplayRepository, ++ private readonly budgetPolicy: RagBudgetPolicy, ++ private readonly cacheKeyFactory: RagCacheKeyFactory, ++ private readonly queryCache: RagQueryCacheService, ++ private readonly ragQualityService: RagQualityService ++ ) {} ++ ++ async rerank(input: RagRerankRequest): Promise { ++ const bundle = input.retrievalBundle; ++ const rerankDegradeReasons: string[] = []; ++ const selectedContextLimit = this.normalizePositiveInt( ++ input.selectedContextLimit, ++ DEFAULT_SELECTED_CONTEXT_LIMIT ++ ); ++ const requestedSecondaryTopK = this.normalizePositiveInt( ++ input.secondaryTopK, ++ DEFAULT_SECONDARY_TOP_K ++ ); ++ const budgetDecision = this.budgetPolicy.planRerank({ ++ requestedSecondaryTopK, ++ signal: input.budgetSignal ++ }); ++ rerankDegradeReasons.push(...budgetDecision.decisionReasons); ++ ++ const cacheKey = this.cacheKeyFactory.build({ ++ stage: "rerank_bundle", ++ datasourceId: bundle.datasource_id, ++ indexVersionId: bundle.index_version_id ?? "none", ++ query: bundle.query, ++ budgetProfile: budgetDecision.secondaryEnabled ++ ? "secondary_enabled" ++ : "secondary_disabled", ++ secondaryTopK: budgetDecision.secondaryTopK, ++ selectedContextLimit ++ }); ++ const cacheRead = this.queryCache.get(cacheKey); ++ if (cacheRead.hit && cacheRead.value) { ++ this.ragQualityService.recordCacheBudget({ ++ cacheEligible: true, ++ cacheHit: true, ++ budgetDegraded: budgetDecision.degraded ++ }); ++ return { ++ retrieval_bundle: { ++ ...cacheRead.value, ++ run_id: bundle.run_id, ++ decision_reasons: this.unique([ ++ ...(cacheRead.value.decision_reasons ?? []), ++ ...budgetDecision.decisionReasons, ++ "cache_hit" ++ ]), ++ degrade_reasons: this.unique([ ++ ...(cacheRead.value.degrade_reasons ?? []) ++ ]) ++ } ++ }; ++ } ++ ++ const primary = this.primaryRerank(bundle.candidates); ++ await this.writePrimaryReplay(bundle, primary); ++ ++ let secondaryScores: Record = {}; ++ const secondaryEnabled = input.secondaryEnabled ?? true; ++ const secondaryMinCandidates = this.normalizePositiveInt( ++ input.secondaryMinCandidates, ++ DEFAULT_SECONDARY_MIN_CANDIDATES ++ ); ++ const secondaryTopK = budgetDecision.secondaryTopK; ++ const secondaryTimeoutMs = this.normalizePositiveInt( ++ input.secondaryTimeoutMs, ++ DEFAULT_SECONDARY_TIMEOUT_MS ++ ); ++ ++ if (!budgetDecision.secondaryEnabled) { ++ rerankDegradeReasons.push("secondary_rerank_disabled_by_budget"); ++ await this.writeSecondaryReplay(bundle, { ++ status: "skipped", ++ reason: "secondary_rerank_disabled_by_budget", ++ timeoutMs: secondaryTimeoutMs ++ }); ++ } else if (!secondaryEnabled) { ++ rerankDegradeReasons.push("secondary_rerank_disabled"); ++ await this.writeSecondaryReplay(bundle, { ++ status: "skipped", ++ reason: "secondary_rerank_disabled", ++ timeoutMs: secondaryTimeoutMs ++ }); ++ } else if (primary.length < secondaryMinCandidates) { ++ rerankDegradeReasons.push("secondary_rerank_skipped_low_candidates"); ++ await this.writeSecondaryReplay(bundle, { ++ status: "skipped", ++ reason: "secondary_rerank_skipped_low_candidates", ++ timeoutMs: secondaryTimeoutMs ++ }); ++ } else { ++ try { ++ secondaryScores = await this.withTimeout( ++ this.runSecondaryRerank(bundle, primary.slice(0, secondaryTopK), input.modelCatalogId), ++ secondaryTimeoutMs, ++ "secondary_rerank_timeout" ++ ); ++ await this.writeSecondaryReplay(bundle, { ++ status: "ok", ++ timeoutMs: secondaryTimeoutMs, ++ scores: secondaryScores ++ }); ++ } catch (error) { ++ const reason = ++ error instanceof Error && error.message.trim() ++ ? error.message ++ : "secondary_rerank_failed"; ++ rerankDegradeReasons.push(reason); ++ await this.writeSecondaryReplay(bundle, { ++ status: "degraded", ++ reason, ++ timeoutMs: secondaryTimeoutMs ++ }); ++ } ++ } ++ ++ const reranked = this.mergePrimaryWithSecondary(primary, secondaryScores); ++ const degradeReasons = this.unique([...bundle.degrade_reasons, ...rerankDegradeReasons]); ++ const selectedContext = reranked ++ .slice(0, selectedContextLimit) ++ .map((item) => item.chunk); ++ ++ const responseBundle: RagRetrievalBundle = { ++ ...bundle, ++ status: degradeReasons.length > 0 ? "degraded" : "ready", ++ degrade_reasons: degradeReasons, ++ reranked, ++ selected_context: selectedContext, ++ risk_tags: this.buildRiskTags({ ++ degradeReasons, ++ reranked ++ }), ++ decision_reasons: this.unique([ ++ ...(bundle.decision_reasons ?? []), ++ ...budgetDecision.decisionReasons ++ ]) ++ }; ++ await this.writeBudgetReplay(responseBundle, { ++ decisionReasons: budgetDecision.decisionReasons, ++ secondaryEnabled: budgetDecision.secondaryEnabled, ++ secondaryTopK ++ }); ++ this.queryCache.set({ ++ key: cacheKey, ++ stage: "rerank_bundle", ++ datasourceId: responseBundle.datasource_id, ++ indexVersionId: responseBundle.index_version_id ?? "none", ++ value: responseBundle, ++ l1TtlMs: RERANK_CACHE_L1_TTL_MS, ++ l2TtlMs: RERANK_CACHE_L2_TTL_MS ++ }); ++ this.ragQualityService.recordCacheBudget({ ++ cacheEligible: true, ++ cacheHit: false, ++ budgetDegraded: budgetDecision.degraded ++ }); ++ await this.writeFinalReplay(responseBundle); ++ return { ++ retrieval_bundle: responseBundle ++ }; ++ } ++ ++ private primaryRerank(candidates: RagRetrievalCandidate[]): RagRerankedCandidate[] { ++ return candidates ++ .map((candidate) => { ++ const domainBoost = this.domainBoost(candidate.chunk.metadata.domain); ++ const evidenceBoost = Math.min(0.12, candidate.evidence.length * 0.03); ++ const laneBoost = candidate.source_lane === "lexical" ? 0.03 : 0; ++ const primaryScore = candidate.score + domainBoost + evidenceBoost + laneBoost; ++ return { ++ ...candidate, ++ primary_score: Number(primaryScore.toFixed(12)), ++ final_score: Number(primaryScore.toFixed(12)), ++ rank_reason: [ ++ `base=${candidate.score.toFixed(6)}`, ++ `domainBoost=${domainBoost.toFixed(3)}`, ++ `evidenceBoost=${evidenceBoost.toFixed(3)}`, ++ `laneBoost=${laneBoost.toFixed(3)}` ++ ] ++ }; ++ }) ++ .sort((left, right) => { ++ if (right.primary_score !== left.primary_score) { ++ return right.primary_score - left.primary_score; ++ } ++ return left.chunk_id.localeCompare(right.chunk_id); ++ }); ++ } ++ ++ private async runSecondaryRerank( ++ bundle: RagRetrievalBundle, ++ candidates: RagRerankedCandidate[], ++ modelCatalogId?: string ++ ): Promise> { ++ const response = await this.modelReranker.rerank({ ++ query: bundle.query, ++ modelCatalogId, ++ candidates: candidates.map((candidate) => ({ ++ candidateId: candidate.chunk_id, ++ content: candidate.chunk.content, ++ domain: candidate.chunk.metadata.domain, ++ sourceLane: candidate.source_lane, ++ evidence: candidate.evidence, ++ baseScore: candidate.primary_score ++ })) ++ }); ++ const scoreMap: Record = {}; ++ for (const item of response) { ++ if (!Number.isFinite(item.score)) { ++ continue; ++ } ++ scoreMap[item.candidateId] = { ++ score: Math.max(0, Math.min(1, Number(item.score.toFixed(6)))), ++ reason: item.reason ++ }; ++ } ++ return scoreMap; ++ } ++ ++ private mergePrimaryWithSecondary( ++ primary: RagRerankedCandidate[], ++ secondaryScores: Record ++ ): RagRerankedCandidate[] { ++ return primary ++ .map((candidate) => { ++ const secondary = secondaryScores[candidate.chunk_id]; ++ if (!secondary) { ++ return candidate; ++ } ++ const finalScore = candidate.primary_score * 0.7 + secondary.score * 0.3; ++ return { ++ ...candidate, ++ secondary_score: secondary.score, ++ final_score: Number(finalScore.toFixed(12)), ++ rank_reason: [...candidate.rank_reason, `secondary=${secondary.reason}`] ++ }; ++ }) ++ .sort((left, right) => { ++ if (right.final_score !== left.final_score) { ++ return right.final_score - left.final_score; ++ } ++ return left.chunk_id.localeCompare(right.chunk_id); ++ }); ++ } ++ ++ private domainBoost(domain: string): number { ++ if (domain === "semantic_term") { ++ return 0.15; ++ } ++ if (domain === "schema") { ++ return 0.1; ++ } ++ if (domain === "sql_example") { ++ return 0.08; ++ } ++ return 0.05; ++ } ++ ++ private buildRiskTags(input: { ++ degradeReasons: string[]; ++ reranked: RagRerankedCandidate[]; ++ }): string[] { ++ const tags: string[] = []; ++ if (input.degradeReasons.some((reason) => reason.includes("zero_recall"))) { ++ tags.push("rag_zero_recall"); ++ } ++ if (input.degradeReasons.some((reason) => reason.includes("secondary_rerank"))) { ++ tags.push("rerank_degraded"); ++ } ++ if (!input.reranked.some((item) => item.chunk.metadata.domain === "semantic_term")) { ++ tags.push("semantic_context_missing"); ++ } ++ return this.unique(tags); ++ } ++ ++ private async withTimeout( ++ task: Promise, ++ timeoutMs: number, ++ timeoutReason: string ++ ): Promise { ++ let timer: NodeJS.Timeout | undefined; ++ try { ++ return await Promise.race([ ++ task, ++ new Promise((_, reject) => { ++ timer = setTimeout(() => reject(new Error(timeoutReason)), timeoutMs); ++ }) ++ ]); ++ } finally { ++ if (timer) { ++ clearTimeout(timer); ++ } ++ } ++ } ++ ++ private async writePrimaryReplay( ++ bundle: RagRetrievalBundle, ++ primary: RagRerankedCandidate[] ++ ): Promise { ++ await this.replayRepository.writeReplay({ ++ runId: bundle.run_id, ++ replayKey: "rerank:primary", ++ datasourceId: bundle.datasource_id, ++ stage: "rerank_primary", ++ indexVersionId: bundle.index_version_id, ++ payload: { ++ candidateCount: primary.length, ++ scores: primary.map((item) => ({ ++ chunkId: item.chunk_id, ++ primaryScore: item.primary_score, ++ finalScore: item.final_score ++ })) ++ } ++ }); ++ } ++ ++ private async writeSecondaryReplay( ++ bundle: RagRetrievalBundle, ++ input: { ++ status: "ok" | "degraded" | "skipped"; ++ reason?: string; ++ timeoutMs: number; ++ scores?: Record; ++ } ++ ): Promise { ++ await this.replayRepository.writeReplay({ ++ runId: bundle.run_id, ++ replayKey: "rerank:secondary", ++ datasourceId: bundle.datasource_id, ++ stage: "rerank_secondary", ++ indexVersionId: bundle.index_version_id, ++ payload: { ++ status: input.status, ++ reason: input.reason, ++ timeoutMs: input.timeoutMs, ++ scores: input.scores ++ } ++ }); ++ } ++ ++ private async writeFinalReplay(bundle: RagRetrievalBundle): Promise { ++ await this.replayRepository.writeReplay({ ++ runId: bundle.run_id, ++ replayKey: "rerank:final", ++ datasourceId: bundle.datasource_id, ++ stage: "rerank_finalized", ++ indexVersionId: bundle.index_version_id, ++ payload: { ++ status: bundle.status, ++ degradeReasons: bundle.degrade_reasons, ++ decisionReasons: bundle.decision_reasons ?? [], ++ rerankedCount: bundle.reranked?.length ?? 0, ++ selectedContextCount: bundle.selected_context?.length ?? 0, ++ riskTags: bundle.risk_tags ?? [] ++ } ++ }); ++ } ++ ++ private async writeBudgetReplay( ++ bundle: RagRetrievalBundle, ++ input: { ++ decisionReasons: string[]; ++ secondaryEnabled: boolean; ++ secondaryTopK: number; ++ } ++ ): Promise { ++ await this.replayRepository.writeReplay({ ++ runId: bundle.run_id, ++ replayKey: "rerank:budget", ++ datasourceId: bundle.datasource_id, ++ stage: "rerank_budget", ++ indexVersionId: bundle.index_version_id, ++ payload: { ++ decisionReasons: input.decisionReasons, ++ secondaryEnabled: input.secondaryEnabled, ++ secondaryTopK: input.secondaryTopK ++ } ++ }); ++ } ++ ++ private normalizePositiveInt(value: number | undefined, fallback: number): number { ++ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { ++ return fallback; ++ } ++ return Math.floor(value); ++ } ++ ++ private unique(values: string[]): string[] { ++ return Array.from(new Set(values.filter((item) => item.trim().length > 0))); ++ } ++} +diff --git a/apps/backend/src/modules/rag/retrieval/fusion/rrf-fusion.ts b/apps/backend/src/modules/rag/retrieval/fusion/rrf-fusion.ts +new file mode 100644 +index 0000000..7906d49 +--- /dev/null ++++ b/apps/backend/src/modules/rag/retrieval/fusion/rrf-fusion.ts +@@ -0,0 +1,113 @@ ++import { ++ RAG_RETRIEVAL_LANES, ++ type RagRetrievalCandidate, ++ type RagRetrievalLane, ++ type RagRetrievalLaneHit ++} from "../rag-retrieval.types"; ++ ++export const DEFAULT_RRF_RANK_CONSTANT = 60; ++ ++export interface RrfFusionInput { ++ laneHits: Record; ++ rankConstant?: number; ++} ++ ++interface RrfAccumulator { ++ chunk_id: string; ++ source_lane: RagRetrievalLane; ++ best_rank: number; ++ score: number; ++ lane_scores: Partial>; ++ lane_ranks: Partial>; ++ evidence: string[]; ++ chunk: RagRetrievalLaneHit["chunk"]; ++} ++ ++export const fuseWithRrf = (input: RrfFusionInput): RagRetrievalCandidate[] => { ++ const rankConstant = normalizeRankConstant(input.rankConstant); ++ const accumulatorByChunkId = new Map(); ++ ++ for (const lane of RAG_RETRIEVAL_LANES) { ++ const laneHits = stableSortLaneHits(input.laneHits[lane] ?? []); ++ for (let index = 0; index < laneHits.length; index += 1) { ++ const hit = laneHits[index]; ++ const rank = index + 1; ++ const rrfContribution = 1 / (rankConstant + rank); ++ const existing = accumulatorByChunkId.get(hit.chunk_id); ++ ++ if (!existing) { ++ accumulatorByChunkId.set(hit.chunk_id, { ++ chunk_id: hit.chunk_id, ++ source_lane: lane, ++ best_rank: rank, ++ score: rrfContribution, ++ lane_scores: { [lane]: hit.score }, ++ lane_ranks: { [lane]: rank }, ++ evidence: [...hit.evidence], ++ chunk: hit.chunk ++ }); ++ continue; ++ } ++ ++ existing.score += rrfContribution; ++ existing.lane_scores[lane] = hit.score; ++ existing.lane_ranks[lane] = rank; ++ appendUnique(existing.evidence, hit.evidence); ++ ++ if (rank < existing.best_rank) { ++ existing.best_rank = rank; ++ existing.source_lane = lane; ++ } else if (rank === existing.best_rank) { ++ const existingLaneOrder = laneOrder(existing.source_lane); ++ const nextLaneOrder = laneOrder(lane); ++ if (nextLaneOrder < existingLaneOrder) { ++ existing.source_lane = lane; ++ } ++ } ++ } ++ } ++ ++ return Array.from(accumulatorByChunkId.values()) ++ .map((item) => ({ ++ chunk_id: item.chunk_id, ++ source_lane: item.source_lane, ++ evidence: item.evidence, ++ score: normalizeScore(item.score), ++ lane_scores: item.lane_scores, ++ lane_ranks: item.lane_ranks, ++ chunk: item.chunk ++ })) ++ .sort((left, right) => { ++ if (right.score !== left.score) { ++ return right.score - left.score; ++ } ++ return left.chunk_id.localeCompare(right.chunk_id); ++ }); ++}; ++ ++const stableSortLaneHits = (hits: RagRetrievalLaneHit[]): RagRetrievalLaneHit[] => ++ [...hits].sort((left, right) => { ++ if (right.score !== left.score) { ++ return right.score - left.score; ++ } ++ return left.chunk_id.localeCompare(right.chunk_id); ++ }); ++ ++const appendUnique = (target: string[], values: string[]): void => { ++ for (const value of values) { ++ if (!target.includes(value)) { ++ target.push(value); ++ } ++ } ++}; ++ ++const laneOrder = (lane: RagRetrievalLane): number => RAG_RETRIEVAL_LANES.indexOf(lane); ++ ++const normalizeRankConstant = (rankConstant?: number): number => { ++ if (typeof rankConstant !== "number" || !Number.isFinite(rankConstant) || rankConstant < 1) { ++ return DEFAULT_RRF_RANK_CONSTANT; ++ } ++ return Math.floor(rankConstant); ++}; ++ ++const normalizeScore = (value: number): number => Number(value.toFixed(12)); +diff --git a/apps/backend/src/modules/rag/retrieval/rag-retrieval.service.ts b/apps/backend/src/modules/rag/retrieval/rag-retrieval.service.ts +new file mode 100644 +index 0000000..c77c4ff +--- /dev/null ++++ b/apps/backend/src/modules/rag/retrieval/rag-retrieval.service.ts +@@ -0,0 +1,969 @@ ++import { createHash } from "node:crypto"; ++import { Injectable } from "@nestjs/common"; ++import { GraphService } from "../../graph/graph.service"; ++import { ++ SKILL_REGISTRY_UNAVAILABLE_REASON, ++ SkillRegistryService ++} from "../../skill-registry/skill-registry.service"; ++import { RagIndexRepository } from "../index/rag-index.repository"; ++import { RagReplayRepository } from "../observability/rag-replay.repository"; ++import { RagBudgetPolicy } from "../perf/rag-budget-policy"; ++import { RagCacheKeyFactory } from "../perf/rag-cache-key.factory"; ++import { RagQueryCacheService } from "../perf/rag-query-cache.service"; ++import { RagQualityService } from "../quality/rag-quality.service"; ++import { fuseWithRrf } from "./fusion/rrf-fusion"; ++import { ++ RAG_RETRIEVAL_LANES, ++ type RagRetrievalCandidate, ++ type RagRetrievalChunkMetadata, ++ type RagRetrievalChunkPayload, ++ type RagRetrievalEntryContext, ++ type RagRetrievalLane, ++ type RagRetrievalLaneHit, ++ type RagRetrievalLaneResult, ++ type RagRetrievalRequest, ++ type RagRetrievalResponse, ++ type RagSkillContext ++} from "./rag-retrieval.types"; ++ ++const DEFAULT_PER_LANE_LIMIT = 20; ++const DEFAULT_FINAL_CANDIDATE_LIMIT = 20; ++const RETRIEVAL_CACHE_L1_TTL_MS = 30_000; ++const RETRIEVAL_CACHE_L2_TTL_MS = 5 * 60_000; ++const DEFAULT_LANE_TIMEOUT_MS: Record = { ++ lexical: 250, ++ dense: 300, ++ graph: 300 ++}; ++const REQUIRED_DOMAIN_COVERAGE = ["schema", "sql_example", "semantic_term"]; ++const SEMANTIC_PROMOTED_DEGRADED_REASON = "semantic_promoted_linkage_degraded"; ++ ++class LaneTimeoutError extends Error { ++ constructor(public readonly lane: RagRetrievalLane) { ++ super(`${lane} lane timeout`); ++ } ++} ++ ++type LaneExecutionOutput = ++ | RagRetrievalLaneHit[] ++ | { ++ hits: RagRetrievalLaneHit[]; ++ degradeReason?: string; ++ }; ++ ++@Injectable() ++export class RagRetrievalService { ++ constructor( ++ private readonly indexRepository: RagIndexRepository, ++ private readonly replayRepository: RagReplayRepository, ++ private readonly skillRegistry: SkillRegistryService, ++ private readonly graphService: GraphService, ++ private readonly cacheKeyFactory: RagCacheKeyFactory, ++ private readonly queryCache: RagQueryCacheService, ++ private readonly budgetPolicy: RagBudgetPolicy, ++ private readonly ragQualityService: RagQualityService ++ ) {} ++ ++ async retrieve(input: RagRetrievalRequest): Promise { ++ const query = input.query.trim(); ++ const datasourceId = input.datasourceId.trim(); ++ const runId = input.runId.trim(); ++ const requestedPerLaneLimit = this.normalizeLimit(input.perLaneLimit, DEFAULT_PER_LANE_LIMIT); ++ const requestedFinalCandidateLimit = this.normalizeLimit( ++ input.finalCandidateLimit, ++ DEFAULT_FINAL_CANDIDATE_LIMIT ++ ); ++ const laneTimeoutMs = this.resolveLaneTimeoutMs(input); ++ ++ if (!query || !datasourceId || !runId) { ++ const degradeReasons = ["invalid_retrieval_input"]; ++ return { ++ retrieval_bundle: { ++ query, ++ run_id: runId, ++ datasource_id: datasourceId, ++ status: "degraded", ++ degrade_reasons: degradeReasons, ++ lane_results: this.createEmptyLaneResults(laneTimeoutMs, "invalid_retrieval_input"), ++ candidates: [] ++ } ++ }; ++ } ++ ++ const activeVersion = input.activeIndexVersionId?.trim() ++ ? await this.indexRepository.getVersionById(input.activeIndexVersionId.trim()) ++ : await this.indexRepository.getActiveVersion(datasourceId); ++ if (!activeVersion || activeVersion.status !== "active") { ++ const degradeReasons = ["no_active_index"]; ++ const response: RagRetrievalResponse = { ++ retrieval_bundle: { ++ query, ++ run_id: runId, ++ datasource_id: datasourceId, ++ index_version_id: activeVersion?.id, ++ status: "degraded", ++ degrade_reasons: degradeReasons, ++ lane_results: this.createEmptyLaneResults(laneTimeoutMs, "no_active_index"), ++ candidates: [] ++ } ++ }; ++ await this.persistReplay(response.retrieval_bundle); ++ return response; ++ } ++ ++ this.queryCache.pruneDatasourceStaleVersions(datasourceId, activeVersion.id); ++ const budgetDecision = this.budgetPolicy.planRetrieval({ ++ requestedPerLaneLimit, ++ requestedFinalCandidateLimit, ++ signal: input.budgetSignal ++ }); ++ const perLaneLimit = budgetDecision.perLaneLimit; ++ const finalCandidateLimit = budgetDecision.finalCandidateLimit; ++ const budgetLaneProfile = budgetDecision.enabledLanes.join("+"); ++ await this.writeBudgetReplay({ ++ runId, ++ datasourceId, ++ indexVersionId: activeVersion.id, ++ decisionReasons: budgetDecision.decisionReasons, ++ perLaneLimit, ++ finalCandidateLimit, ++ enabledLanes: budgetDecision.enabledLanes ++ }); ++ ++ const cacheKey = this.cacheKeyFactory.build({ ++ stage: "retrieval_bundle", ++ datasourceId, ++ indexVersionId: activeVersion.id, ++ query, ++ budgetProfile: budgetLaneProfile, ++ perLaneLimit, ++ finalCandidateLimit ++ }); ++ const cacheRead = this.queryCache.get(cacheKey); ++ if (cacheRead.hit && cacheRead.value) { ++ const cachedBundle = this.hydrateCachedBundle({ ++ cachedBundle: cacheRead.value, ++ query, ++ datasourceId, ++ runId, ++ decisionReasons: budgetDecision.decisionReasons ++ }); ++ this.ragQualityService.recordCacheBudget({ ++ cacheEligible: true, ++ cacheHit: true, ++ budgetDegraded: budgetDecision.degraded ++ }); ++ await this.persistReplay(cachedBundle); ++ return { ++ retrieval_bundle: cachedBundle ++ }; ++ } ++ ++ const entries = await this.indexRepository.listEntriesByVersion(activeVersion.id); ++ const contexts = entries.map((entry) => this.toEntryContext(activeVersion.id, entry)); ++ const laneResults = await this.collectLaneResults({ ++ query, ++ contexts, ++ perLaneLimit, ++ laneTimeoutMs, ++ enabledLanes: budgetDecision.enabledLanes, ++ laneArtificialDelayMs: input.laneArtificialDelayMs ++ }); ++ ++ const laneHits = { ++ lexical: laneResults.lexical.hits, ++ dense: laneResults.dense.hits, ++ graph: laneResults.graph.hits ++ }; ++ const fused = fuseWithRrf({ laneHits }); ++ const coveredCandidates = this.applyDomainCoverage( ++ fused, ++ finalCandidateLimit, ++ REQUIRED_DOMAIN_COVERAGE ++ ); ++ const candidates = this.decorateSemanticCandidates(coveredCandidates); ++ const skillContext = await this.resolveSkillContext(query, candidates); ++ ++ const degradeReasons = this.collectDegradeReasons(laneResults); ++ degradeReasons.push(...this.collectSemanticLinkageDegradeReasons(candidates)); ++ if (skillContext.degrade_reason) { ++ degradeReasons.push(skillContext.degrade_reason); ++ } ++ if (candidates.length === 0) { ++ degradeReasons.push("zero_recall"); ++ } ++ const uniqueDegradeReasons = this.unique([ ++ ...degradeReasons, ++ ...budgetDecision.decisionReasons ++ ]); ++ ++ const response: RagRetrievalResponse = { ++ retrieval_bundle: { ++ query, ++ run_id: runId, ++ datasource_id: datasourceId, ++ index_version_id: activeVersion.id, ++ status: uniqueDegradeReasons.length > 0 ? "degraded" : "ready", ++ degrade_reasons: uniqueDegradeReasons, ++ lane_results: laneResults, ++ candidates, ++ skill_context: skillContext, ++ decision_reasons: budgetDecision.decisionReasons ++ } ++ }; ++ ++ this.queryCache.set({ ++ key: cacheKey, ++ stage: "retrieval_bundle", ++ datasourceId, ++ indexVersionId: activeVersion.id, ++ value: response.retrieval_bundle, ++ l1TtlMs: RETRIEVAL_CACHE_L1_TTL_MS, ++ l2TtlMs: RETRIEVAL_CACHE_L2_TTL_MS ++ }); ++ this.ragQualityService.recordCacheBudget({ ++ cacheEligible: true, ++ cacheHit: false, ++ budgetDegraded: budgetDecision.degraded ++ }); ++ await this.persistReplay(response.retrieval_bundle); ++ return response; ++ } ++ ++ private hydrateCachedBundle(input: { ++ cachedBundle: RagRetrievalResponse["retrieval_bundle"]; ++ query: string; ++ datasourceId: string; ++ runId: string; ++ decisionReasons: string[]; ++ }): RagRetrievalResponse["retrieval_bundle"] { ++ return { ++ ...input.cachedBundle, ++ query: input.query, ++ datasource_id: input.datasourceId, ++ run_id: input.runId, ++ decision_reasons: this.unique([ ++ ...(input.cachedBundle.decision_reasons ?? []), ++ ...input.decisionReasons, ++ "cache_hit" ++ ]), ++ degrade_reasons: this.unique([ ++ ...(input.cachedBundle.degrade_reasons ?? []), ++ ...input.decisionReasons ++ ]) ++ }; ++ } ++ ++ private async collectLaneResults(input: { ++ query: string; ++ contexts: RagRetrievalEntryContext[]; ++ perLaneLimit: number; ++ laneTimeoutMs: Record; ++ enabledLanes: RagRetrievalLane[]; ++ laneArtificialDelayMs?: Partial>; ++ }): Promise> { ++ const enabledLanes = new Set(input.enabledLanes); ++ const lexicalPromise = enabledLanes.has("lexical") ++ ? this.executeLane("lexical", input, () => ++ this.runLexicalLane(input.query, input.contexts, input.perLaneLimit) ++ ) ++ : Promise.resolve(this.createBudgetDisabledLaneResult("lexical", input.laneTimeoutMs.lexical)); ++ const densePromise = enabledLanes.has("dense") ++ ? this.executeLane("dense", input, () => ++ this.runDenseLane(input.query, input.contexts, input.perLaneLimit) ++ ) ++ : Promise.resolve(this.createBudgetDisabledLaneResult("dense", input.laneTimeoutMs.dense)); ++ const graphPromise = enabledLanes.has("graph") ++ ? this.executeLane("graph", input, () => ++ this.runGraphLane(input.query, input.contexts, input.perLaneLimit) ++ ) ++ : Promise.resolve(this.createBudgetDisabledLaneResult("graph", input.laneTimeoutMs.graph)); ++ ++ const [lexical, dense, graph] = await Promise.all([ ++ lexicalPromise, ++ densePromise, ++ graphPromise ++ ]); ++ return { ++ lexical, ++ dense, ++ graph ++ }; ++ } ++ ++ private createBudgetDisabledLaneResult( ++ lane: RagRetrievalLane, ++ timeoutMs: number ++ ): RagRetrievalLaneResult { ++ return { ++ lane, ++ status: "degraded", ++ timeout_ms: timeoutMs, ++ elapsed_ms: 0, ++ degrade_reason: `budget_lane_disabled_${lane}`, ++ hits: [] ++ }; ++ } ++ ++ private async executeLane( ++ lane: RagRetrievalLane, ++ input: { ++ query: string; ++ contexts: RagRetrievalEntryContext[]; ++ perLaneLimit: number; ++ laneTimeoutMs: Record; ++ laneArtificialDelayMs?: Partial>; ++ }, ++ laneExecutor: () => LaneExecutionOutput | Promise ++ ): Promise { ++ const timeoutMs = input.laneTimeoutMs[lane]; ++ const startedAt = Date.now(); ++ try { ++ const lanePromise = Promise.resolve().then(async () => { ++ const artificialDelayMs = input.laneArtificialDelayMs?.[lane]; ++ if (typeof artificialDelayMs === "number" && artificialDelayMs > 0) { ++ await this.sleep(artificialDelayMs); ++ } ++ return laneExecutor(); ++ }); ++ const laneOutput = await Promise.race([ ++ lanePromise, ++ this.timeout(timeoutMs, lane) ++ ]); ++ const normalizedOutput = this.normalizeLaneOutput(laneOutput); ++ return { ++ lane, ++ status: normalizedOutput.degradeReason ? "degraded" : "ok", ++ timeout_ms: timeoutMs, ++ elapsed_ms: Date.now() - startedAt, ++ degrade_reason: normalizedOutput.degradeReason, ++ hits: normalizedOutput.hits.slice(0, input.perLaneLimit) ++ }; ++ } catch (error) { ++ const degradeReason = ++ error instanceof LaneTimeoutError ++ ? `${lane}_timeout` ++ : `${lane}_error:${error instanceof Error ? error.message : String(error)}`; ++ return { ++ lane, ++ status: "degraded", ++ timeout_ms: timeoutMs, ++ elapsed_ms: Date.now() - startedAt, ++ degrade_reason: degradeReason, ++ hits: [] ++ }; ++ } ++ } ++ ++ private runLexicalLane( ++ query: string, ++ contexts: RagRetrievalEntryContext[], ++ limit: number ++ ): RagRetrievalLaneHit[] { ++ const tokens = this.extractTokens(query); ++ const hits: RagRetrievalLaneHit[] = []; ++ for (const context of contexts) { ++ const searchable = context.entry.lexicalContent.toLowerCase(); ++ const evidence: string[] = []; ++ let tokenMatchScore = 0; ++ for (const token of tokens) { ++ if (searchable.includes(token)) { ++ tokenMatchScore += 1; ++ evidence.push(`token:${token}`); ++ } ++ } ++ if (tokenMatchScore <= 0) { ++ continue; ++ } ++ ++ const tableHit = context.parsedMetadata.tableNames.filter((tableName) => ++ searchable.includes(tableName.toLowerCase()) ++ ); ++ const columnHit = context.parsedMetadata.columnNames.filter((columnName) => ++ searchable.includes(columnName.toLowerCase()) ++ ); ++ for (const tableName of tableHit) { ++ evidence.push(`table:${tableName}`); ++ } ++ for (const columnName of columnHit) { ++ evidence.push(`column:${columnName}`); ++ } ++ ++ const score = ++ tokenMatchScore * 1.0 + tableHit.length * 0.4 + columnHit.length * 0.2; ++ hits.push({ ++ lane: "lexical", ++ chunk_id: context.entry.chunkId, ++ score, ++ evidence: this.unique(evidence), ++ chunk: this.toChunkPayload(context) ++ }); ++ } ++ return this.sortHits(hits).slice(0, limit); ++ } ++ ++ private runDenseLane( ++ query: string, ++ contexts: RagRetrievalEntryContext[], ++ limit: number ++ ): RagRetrievalLaneHit[] { ++ const queryVector = this.buildDenseVector(query); ++ const hits: RagRetrievalLaneHit[] = []; ++ for (const context of contexts) { ++ const vector = this.parseDenseVector(context.entry.denseVector); ++ if (!vector || vector.length === 0 || vector.length !== queryVector.length) { ++ continue; ++ } ++ const cosine = this.cosineSimilarity(queryVector, vector); ++ if (!Number.isFinite(cosine) || cosine <= 0) { ++ continue; ++ } ++ hits.push({ ++ lane: "dense", ++ chunk_id: context.entry.chunkId, ++ score: Number(cosine.toFixed(12)), ++ evidence: [`cosine:${cosine.toFixed(6)}`], ++ chunk: this.toChunkPayload(context) ++ }); ++ } ++ return this.sortHits(hits).slice(0, limit); ++ } ++ ++ private async runGraphLane( ++ query: string, ++ contexts: RagRetrievalEntryContext[], ++ limit: number ++ ): Promise { ++ return this.graphService.runLane({ ++ query, ++ contexts, ++ limit, ++ fallbackRunner: () => this.runGraphLaneHeuristic(query, contexts, limit) ++ }); ++ } ++ ++ private runGraphLaneHeuristic( ++ query: string, ++ contexts: RagRetrievalEntryContext[], ++ limit: number ++ ): RagRetrievalLaneHit[] { ++ const tokens = this.extractTokens(query); ++ const hits: RagRetrievalLaneHit[] = []; ++ for (const context of contexts) { ++ const evidence: string[] = []; ++ let score = 0; ++ ++ if (context.entry.domain === "semantic_term") { ++ score += 0.6; ++ evidence.push("domain:semantic_term"); ++ } ++ if (context.entry.domain === "schema") { ++ score += 0.2; ++ evidence.push("domain:schema"); ++ } ++ ++ for (const tableName of context.parsedMetadata.tableNames) { ++ const normalizedTable = tableName.toLowerCase(); ++ if (tokens.some((token) => normalizedTable.includes(token) || token.includes(normalizedTable))) { ++ score += 0.8; ++ evidence.push(`table:${tableName}`); ++ } ++ } ++ for (const columnName of context.parsedMetadata.columnNames) { ++ const normalizedColumn = columnName.toLowerCase(); ++ if ( ++ tokens.some((token) => normalizedColumn.includes(token) || token.includes(normalizedColumn)) ++ ) { ++ score += 0.5; ++ evidence.push(`column:${columnName}`); ++ } ++ } ++ ++ if (/\b(join|relationship|foreign|关联|外键)\b/i.test(query) && context.entry.domain === "schema") { ++ score += 0.4; ++ evidence.push("graph:relationship_hint"); ++ } ++ ++ if (score <= 0) { ++ continue; ++ } ++ hits.push({ ++ lane: "graph", ++ chunk_id: context.entry.chunkId, ++ score: Number(score.toFixed(12)), ++ evidence: this.unique(evidence), ++ chunk: this.toChunkPayload(context) ++ }); ++ } ++ return this.sortHits(hits).slice(0, limit); ++ } ++ ++ private normalizeLaneOutput(output: LaneExecutionOutput): { ++ hits: RagRetrievalLaneHit[]; ++ degradeReason?: string; ++ } { ++ if (Array.isArray(output)) { ++ return { ++ hits: output ++ }; ++ } ++ return { ++ hits: output.hits, ++ degradeReason: output.degradeReason ++ }; ++ } ++ ++ private toEntryContext( ++ indexVersionId: string, ++ entry: RagRetrievalEntryContext["entry"] ++ ): RagRetrievalEntryContext { ++ const parsed = this.safeParseJson(entry.metadata); ++ const sourceMetadata = this.isRecord(parsed.sourceMetadata) ++ ? parsed.sourceMetadata ++ : {}; ++ const tableNames = this.readStringArray(parsed.tableNames).length ++ ? this.readStringArray(parsed.tableNames) ++ : this.readStringArray(sourceMetadata.tableNames); ++ const columnNames = this.readStringArray(parsed.columnNames).length ++ ? this.readStringArray(parsed.columnNames) ++ : this.readStringArray(sourceMetadata.columnNames); ++ ++ return { ++ indexVersionId, ++ entry, ++ parsedMetadata: { ++ ...parsed, ++ sourceMetadata, ++ tableNames, ++ columnNames, ++ chunkProfile: ++ this.readString(parsed.chunkProfile) ?? ++ this.readString(sourceMetadata.chunkProfile), ++ startOffset: this.readNumber(parsed.startOffset) ?? this.readNumber(sourceMetadata.startOffset), ++ endOffset: this.readNumber(parsed.endOffset) ?? this.readNumber(sourceMetadata.endOffset) ++ } ++ }; ++ } ++ ++ private toChunkPayload(context: RagRetrievalEntryContext): RagRetrievalChunkPayload { ++ const metadata: RagRetrievalChunkMetadata = { ++ datasourceId: context.entry.datasourceId, ++ indexVersionId: context.indexVersionId, ++ chunkId: context.entry.chunkId, ++ domain: context.entry.domain, ++ chunkProfile: this.readString(context.parsedMetadata.chunkProfile), ++ startOffset: this.readNumber(context.parsedMetadata.startOffset), ++ endOffset: this.readNumber(context.parsedMetadata.endOffset), ++ tableNames: this.readStringArray(context.parsedMetadata.tableNames), ++ columnNames: this.readStringArray(context.parsedMetadata.columnNames), ++ sourceMetadata: this.isRecord(context.parsedMetadata.sourceMetadata) ++ ? context.parsedMetadata.sourceMetadata ++ : {} ++ }; ++ ++ return { ++ chunk_id: context.entry.chunkId, ++ content: context.entry.lexicalContent, ++ metadata ++ }; ++ } ++ ++ private applyDomainCoverage( ++ candidates: RagRetrievalCandidate[], ++ limit: number, ++ requiredDomains: string[] ++ ): RagRetrievalCandidate[] { ++ const selected: RagRetrievalCandidate[] = []; ++ const byId = new Set(); ++ for (const candidate of candidates) { ++ if (selected.length >= limit) { ++ break; ++ } ++ selected.push(candidate); ++ byId.add(candidate.chunk_id); ++ } ++ ++ const selectedDomains = new Set( ++ selected.map((candidate) => candidate.chunk.metadata.domain) ++ ); ++ for (const domain of requiredDomains) { ++ if (selected.length >= limit || selectedDomains.has(domain)) { ++ continue; ++ } ++ const fallback = candidates.find( ++ (candidate) => ++ candidate.chunk.metadata.domain === domain && !byId.has(candidate.chunk_id) ++ ); ++ if (!fallback) { ++ continue; ++ } ++ selected.push(fallback); ++ byId.add(fallback.chunk_id); ++ selectedDomains.add(domain); ++ } ++ return selected.slice(0, limit); ++ } ++ ++ private createEmptyLaneResults( ++ laneTimeoutMs: Record, ++ degradeReason: string ++ ): Record { ++ return { ++ lexical: { ++ lane: "lexical", ++ status: "degraded", ++ timeout_ms: laneTimeoutMs.lexical, ++ elapsed_ms: 0, ++ degrade_reason: degradeReason, ++ hits: [] ++ }, ++ dense: { ++ lane: "dense", ++ status: "degraded", ++ timeout_ms: laneTimeoutMs.dense, ++ elapsed_ms: 0, ++ degrade_reason: degradeReason, ++ hits: [] ++ }, ++ graph: { ++ lane: "graph", ++ status: "degraded", ++ timeout_ms: laneTimeoutMs.graph, ++ elapsed_ms: 0, ++ degrade_reason: degradeReason, ++ hits: [] ++ } ++ }; ++ } ++ ++ private collectDegradeReasons( ++ laneResults: Record ++ ): string[] { ++ const degradeReasons: string[] = []; ++ for (const lane of RAG_RETRIEVAL_LANES) { ++ const laneResult = laneResults[lane]; ++ if (laneResult.status === "degraded" && laneResult.degrade_reason) { ++ degradeReasons.push(laneResult.degrade_reason); ++ } ++ } ++ return degradeReasons; ++ } ++ ++ private collectSemanticLinkageDegradeReasons( ++ candidates: RagRetrievalCandidate[] ++ ): string[] { ++ const reasons: string[] = []; ++ for (const candidate of candidates) { ++ if (candidate.chunk.metadata.domain !== "semantic_term") { ++ continue; ++ } ++ const sourceMetadata = candidate.chunk.metadata.sourceMetadata; ++ const linkageStatus = ++ this.isRecord(sourceMetadata) && typeof sourceMetadata.linkageStatus === "string" ++ ? sourceMetadata.linkageStatus.trim().toLowerCase() ++ : undefined; ++ if (linkageStatus !== "degraded") { ++ continue; ++ } ++ const degradeReason = ++ this.isRecord(sourceMetadata) && typeof sourceMetadata.linkageDegradeReason === "string" ++ ? sourceMetadata.linkageDegradeReason.trim() ++ : undefined; ++ reasons.push(degradeReason || SEMANTIC_PROMOTED_DEGRADED_REASON); ++ } ++ return reasons; ++ } ++ ++ private decorateSemanticCandidates( ++ candidates: RagRetrievalCandidate[] ++ ): RagRetrievalCandidate[] { ++ return candidates.map((candidate) => { ++ const semanticHitClues = this.readSemanticHitClues(candidate.chunk.metadata.sourceMetadata); ++ if (semanticHitClues.length === 0) { ++ return candidate; ++ } ++ return { ++ ...candidate, ++ evidence: this.unique([...candidate.evidence, ...semanticHitClues]) ++ }; ++ }); ++ } ++ ++ private resolveLaneTimeoutMs( ++ input: RagRetrievalRequest ++ ): Record { ++ return { ++ lexical: this.normalizeTimeout(input.laneTimeoutMs?.lexical, DEFAULT_LANE_TIMEOUT_MS.lexical), ++ dense: this.normalizeTimeout(input.laneTimeoutMs?.dense, DEFAULT_LANE_TIMEOUT_MS.dense), ++ graph: this.normalizeTimeout(input.laneTimeoutMs?.graph, DEFAULT_LANE_TIMEOUT_MS.graph) ++ }; ++ } ++ ++ private normalizeLimit(raw: number | undefined, fallback: number): number { ++ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) { ++ return fallback; ++ } ++ return Math.floor(raw); ++ } ++ ++ private normalizeTimeout(raw: number | undefined, fallback: number): number { ++ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) { ++ return fallback; ++ } ++ return Math.floor(raw); ++ } ++ ++ private extractTokens(query: string): string[] { ++ const tokens = query ++ .toLowerCase() ++ .match(/[a-z0-9_\p{L}\p{N}]+/gu); ++ if (!tokens) { ++ return []; ++ } ++ return this.unique( ++ tokens.map((token) => token.trim()).filter((token) => token.length > 0) ++ ); ++ } ++ ++ private parseDenseVector(value?: string): number[] | undefined { ++ if (!value) { ++ return undefined; ++ } ++ try { ++ const parsed = JSON.parse(value) as unknown; ++ if (!Array.isArray(parsed)) { ++ return undefined; ++ } ++ const vector = parsed ++ .map((item) => Number(item)) ++ .filter((item) => Number.isFinite(item)); ++ return vector.length > 0 ? vector : undefined; ++ } catch { ++ return undefined; ++ } ++ } ++ ++ private buildDenseVector(input: string): number[] { ++ const digest = createHash("sha256").update(input).digest(); ++ const dimensions = 8; ++ return Array.from({ length: dimensions }, (_, index) => { ++ const byte = digest[index] ?? 0; ++ const normalized = byte / 255; ++ return Number((normalized * 2 - 1).toFixed(6)); ++ }); ++ } ++ ++ private cosineSimilarity(left: number[], right: number[]): number { ++ let dot = 0; ++ let leftNorm = 0; ++ let rightNorm = 0; ++ for (let index = 0; index < left.length; index += 1) { ++ const l = left[index] ?? 0; ++ const r = right[index] ?? 0; ++ dot += l * r; ++ leftNorm += l * l; ++ rightNorm += r * r; ++ } ++ if (leftNorm <= 0 || rightNorm <= 0) { ++ return 0; ++ } ++ return dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm)); ++ } ++ ++ private sortHits(hits: RagRetrievalLaneHit[]): RagRetrievalLaneHit[] { ++ return [...hits].sort((left, right) => { ++ if (right.score !== left.score) { ++ return right.score - left.score; ++ } ++ return left.chunk_id.localeCompare(right.chunk_id); ++ }); ++ } ++ ++ private timeout(timeoutMs: number, lane: RagRetrievalLane): Promise { ++ return new Promise((_, reject) => { ++ setTimeout(() => reject(new LaneTimeoutError(lane)), timeoutMs); ++ }); ++ } ++ ++ private sleep(ms: number): Promise { ++ return new Promise((resolve) => setTimeout(resolve, ms)); ++ } ++ ++ private unique(values: string[]): string[] { ++ return Array.from(new Set(values)); ++ } ++ ++ private safeParseJson(value?: string): Record { ++ if (!value || !value.trim()) { ++ return {}; ++ } ++ try { ++ const parsed = JSON.parse(value) as unknown; ++ if (this.isRecord(parsed)) { ++ return parsed; ++ } ++ return {}; ++ } catch { ++ return {}; ++ } ++ } ++ ++ private isRecord(value: unknown): value is Record { ++ return typeof value === "object" && value !== null && !Array.isArray(value); ++ } ++ ++ private readString(value: unknown): string | undefined { ++ if (typeof value !== "string") { ++ return undefined; ++ } ++ const normalized = value.trim(); ++ return normalized ? normalized : undefined; ++ } ++ ++ private readNumber(value: unknown): number | undefined { ++ if (typeof value === "number" && Number.isFinite(value)) { ++ return value; ++ } ++ if (typeof value === "string" && value.trim()) { ++ const parsed = Number(value); ++ if (Number.isFinite(parsed)) { ++ return parsed; ++ } ++ } ++ return undefined; ++ } ++ ++ private readStringArray(value: unknown): string[] { ++ if (!Array.isArray(value)) { ++ return []; ++ } ++ return this.unique( ++ value ++ .map((item) => this.readString(item)) ++ .filter((item): item is string => Boolean(item)) ++ ); ++ } ++ ++ private readSemanticHitClues(value: unknown): string[] { ++ if (!this.isRecord(value)) { ++ return []; ++ } ++ return this.readStringArray(value.semanticHitClues).map((clue) => { ++ if (clue.includes(":")) { ++ return clue; ++ } ++ return `semantic_hit:${clue}`; ++ }); ++ } ++ ++ private async persistReplay(bundle: RagRetrievalResponse["retrieval_bundle"]): Promise { ++ for (const lane of RAG_RETRIEVAL_LANES) { ++ const laneResult = bundle.lane_results[lane]; ++ await this.replayRepository.writeReplay({ ++ runId: bundle.run_id, ++ replayKey: `retrieval:lane:${lane}`, ++ datasourceId: bundle.datasource_id, ++ stage: "retrieval_lane", ++ indexVersionId: bundle.index_version_id, ++ payload: { ++ lane, ++ status: laneResult.status, ++ timeoutMs: laneResult.timeout_ms, ++ elapsedMs: laneResult.elapsed_ms, ++ degradeReason: laneResult.degrade_reason, ++ hits: laneResult.hits.map((item) => ({ ++ chunkId: item.chunk_id, ++ score: item.score, ++ evidence: item.evidence ++ })) ++ } ++ }); ++ } ++ ++ await this.replayRepository.writeReplay({ ++ runId: bundle.run_id, ++ replayKey: "retrieval:fused", ++ datasourceId: bundle.datasource_id, ++ stage: "retrieval_fused", ++ indexVersionId: bundle.index_version_id, ++ payload: { ++ status: bundle.status, ++ degradeReasons: bundle.degrade_reasons, ++ decisionReasons: bundle.decision_reasons ?? [], ++ candidateCount: bundle.candidates.length, ++ skillContext: bundle.skill_context, ++ candidates: bundle.candidates.map((candidate) => ({ ++ chunkId: candidate.chunk_id, ++ sourceLane: candidate.source_lane, ++ score: candidate.score, ++ domain: candidate.chunk.metadata.domain ++ })) ++ } ++ }); ++ } ++ ++ private async writeBudgetReplay(input: { ++ runId: string; ++ datasourceId: string; ++ indexVersionId: string; ++ decisionReasons: string[]; ++ perLaneLimit: number; ++ finalCandidateLimit: number; ++ enabledLanes: RagRetrievalLane[]; ++ }): Promise { ++ await this.replayRepository.writeReplay({ ++ runId: input.runId, ++ replayKey: "retrieval:budget", ++ datasourceId: input.datasourceId, ++ stage: "retrieval_budget", ++ indexVersionId: input.indexVersionId, ++ payload: { ++ decisionReasons: input.decisionReasons, ++ perLaneLimit: input.perLaneLimit, ++ finalCandidateLimit: input.finalCandidateLimit, ++ enabledLanes: input.enabledLanes ++ } ++ }); ++ } ++ ++ private async resolveSkillContext( ++ query: string, ++ candidates: RagRetrievalCandidate[] ++ ): Promise { ++ if (candidates.length === 0) { ++ return { ++ skills: [], ++ context: [] ++ }; ++ } ++ const firstCandidate = candidates.find( ++ (candidate) => candidate.chunk.metadata.domain === "semantic_term" ++ ) ?? candidates[0]; ++ const domain = firstCandidate?.chunk.metadata.domain ?? "semantic_term"; ++ const tableNames = this.unique( ++ candidates.flatMap((candidate) => candidate.chunk.metadata.tableNames).slice(0, 30) ++ ); ++ const columnNames = this.unique( ++ candidates.flatMap((candidate) => candidate.chunk.metadata.columnNames).slice(0, 30) ++ ); ++ ++ try { ++ return await this.skillRegistry.resolveSkills({ ++ domain, ++ term: query, ++ context: { ++ query, ++ tableNames, ++ columnNames, ++ candidateCount: candidates.length ++ } ++ }); ++ } catch { ++ return { ++ skills: [], ++ context: [], ++ degrade_reason: SKILL_REGISTRY_UNAVAILABLE_REASON ++ }; ++ } ++ } ++} +diff --git a/apps/backend/src/modules/rag/retrieval/rag-retrieval.types.ts b/apps/backend/src/modules/rag/retrieval/rag-retrieval.types.ts +new file mode 100644 +index 0000000..71e1c0e +--- /dev/null ++++ b/apps/backend/src/modules/rag/retrieval/rag-retrieval.types.ts +@@ -0,0 +1,126 @@ ++import type { RagChunkIndexEntryRecord } from "../index/rag-index.repository"; ++ ++export const RAG_RETRIEVAL_LANES = ["lexical", "dense", "graph"] as const; ++export type RagRetrievalLane = (typeof RAG_RETRIEVAL_LANES)[number]; ++ ++export interface RagBudgetSignal { ++ tokenPressure?: number; ++ latencyPressure?: number; ++ costPressure?: number; ++} ++ ++export interface RagRetrievalRequest { ++ query: string; ++ datasourceId: string; ++ runId: string; ++ activeIndexVersionId?: string; ++ perLaneLimit?: number; ++ finalCandidateLimit?: number; ++ laneTimeoutMs?: Partial>; ++ laneArtificialDelayMs?: Partial>; ++ budgetSignal?: RagBudgetSignal; ++} ++ ++export interface RagRetrievalChunkMetadata { ++ datasourceId: string; ++ indexVersionId: string; ++ chunkId: string; ++ domain: string; ++ chunkProfile?: string; ++ startOffset?: number; ++ endOffset?: number; ++ tableNames: string[]; ++ columnNames: string[]; ++ sourceMetadata?: Record; ++} ++ ++export interface RagRetrievalChunkPayload { ++ chunk_id: string; ++ content: string; ++ metadata: RagRetrievalChunkMetadata; ++} ++ ++export interface RagRetrievalLaneHit { ++ lane: RagRetrievalLane; ++ chunk_id: string; ++ score: number; ++ evidence: string[]; ++ chunk: RagRetrievalChunkPayload; ++} ++ ++export interface RagRetrievalLaneResult { ++ lane: RagRetrievalLane; ++ status: "ok" | "degraded"; ++ timeout_ms: number; ++ elapsed_ms: number; ++ degrade_reason?: string; ++ hits: RagRetrievalLaneHit[]; ++} ++ ++export interface RagRetrievalCandidate { ++ chunk_id: string; ++ source_lane: RagRetrievalLane; ++ evidence: string[]; ++ score: number; ++ lane_scores: Partial>; ++ lane_ranks: Partial>; ++ chunk: RagRetrievalChunkPayload; ++} ++ ++export interface RagRerankedCandidate extends RagRetrievalCandidate { ++ primary_score: number; ++ secondary_score?: number; ++ final_score: number; ++ rank_reason: string[]; ++} ++ ++export interface RagSkillContextEntry { ++ source: "skill_registry"; ++ domain: string; ++ term: string; ++ matched_by: "term" | "context"; ++} ++ ++export interface RagSkillContext { ++ skills: Array<{ ++ key: string; ++ name: string; ++ }>; ++ context: RagSkillContextEntry[]; ++ degrade_reason?: string; ++} ++ ++export interface RagRetrievalBundle { ++ query: string; ++ run_id: string; ++ datasource_id: string; ++ index_version_id?: string; ++ status: "ready" | "degraded"; ++ degrade_reasons: string[]; ++ lane_results: Record; ++ candidates: RagRetrievalCandidate[]; ++ reranked?: RagRerankedCandidate[]; ++ selected_context?: RagRetrievalChunkPayload[]; ++ risk_tags?: string[]; ++ skill_context?: RagSkillContext; ++ decision_reasons?: string[]; ++} ++ ++export interface RagRetrievalResponse { ++ retrieval_bundle: RagRetrievalBundle; ++} ++ ++export interface RagRetrievalParsedMetadata extends Record { ++ sourceMetadata: Record; ++ tableNames: string[]; ++ columnNames: string[]; ++ chunkProfile?: string; ++ startOffset?: number; ++ endOffset?: number; ++} ++ ++export interface RagRetrievalEntryContext { ++ indexVersionId: string; ++ entry: RagChunkIndexEntryRecord; ++ parsedMetadata: RagRetrievalParsedMetadata; ++} +diff --git a/apps/backend/src/modules/semantic-registry/semantic-registry.module.ts b/apps/backend/src/modules/semantic-registry/semantic-registry.module.ts +new file mode 100644 +index 0000000..cb158e2 +--- /dev/null ++++ b/apps/backend/src/modules/semantic-registry/semantic-registry.module.ts +@@ -0,0 +1,10 @@ ++import { Module } from "@nestjs/common"; ++import { AppConfigModule } from "../config/config.module"; ++import { SemanticRegistryService } from "./semantic-registry.service"; ++ ++@Module({ ++ imports: [AppConfigModule], ++ providers: [SemanticRegistryService], ++ exports: [SemanticRegistryService] ++}) ++export class SemanticRegistryModule {} +diff --git a/apps/backend/src/modules/semantic-registry/semantic-registry.service.ts b/apps/backend/src/modules/semantic-registry/semantic-registry.service.ts +new file mode 100644 +index 0000000..7818941 +--- /dev/null ++++ b/apps/backend/src/modules/semantic-registry/semantic-registry.service.ts +@@ -0,0 +1,778 @@ ++import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; ++import { v4 as uuidv4 } from "uuid"; ++import { DomainError } from "../../common/domain-error"; ++import { AppConfigService } from "../config/app-config.service"; ++ ++export const SEMANTIC_VERSION_NOT_FOUND_REASON = "semantic_version_not_found"; ++export const SEMANTIC_TERM_NOT_FOUND_REASON = "semantic_term_not_found"; ++export const SEMANTIC_REGISTRY_DEGRADED_RISK_TAG = "semantic_registry_degraded"; ++ ++export type SemanticRegistryVersionStatus = "active" | "deprecated"; ++ ++export interface SemanticRegistryTermInput { ++ term: string; ++ canonicalKey: string; ++ definition: string; ++ binding: string; ++ metadata?: string; ++} ++ ++export interface PublishSemanticRegistryVersionInput { ++ domain: string; ++ semanticVersion?: number; ++ releaseSummary: string; ++ auditSummary: string; ++ terms: SemanticRegistryTermInput[]; ++ riskTags?: string[]; ++ publishedByRunId?: string; ++ activatedByRunId?: string; ++ activatedAt?: string; ++ status?: SemanticRegistryVersionStatus; ++} ++ ++export interface SemanticRegistryVersionRecord { ++ id: string; ++ domain: string; ++ semanticVersion: number; ++ status: SemanticRegistryVersionStatus; ++ releaseSummary: string; ++ auditSummary: string; ++ riskTags: string[]; ++ publishedByRunId?: string; ++ activatedByRunId?: string; ++ activatedAt?: string; ++ createdAt: string; ++ updatedAt: string; ++} ++ ++export interface SemanticRegistryTermRecord { ++ id: string; ++ versionId: string; ++ domain: string; ++ term: string; ++ canonicalKey: string; ++ definition: string; ++ binding: string; ++ metadata?: string; ++ createdAt: string; ++ updatedAt: string; ++} ++ ++export interface SemanticRegistryLookupInput { ++ domain: string; ++ term: string; ++ semanticVersion?: number; ++ datasourceId?: string; ++} ++ ++export interface SemanticRegistryLookupResult { ++ status: "ready" | "degraded"; ++ semantic_version?: number; ++ term?: { ++ term: string; ++ canonical_key: string; ++ definition: string; ++ binding: string; ++ metadata?: string; ++ }; ++ release_summary?: string; ++ audit_summary?: string; ++ published_by_run_id?: string; ++ activated_by_run_id?: string; ++ activated_at?: string; ++ degrade_reason?: string; ++ risk_tags: string[]; ++ matched_scope?: "datasource" | "global"; ++ matched_domain?: string; ++} ++ ++export interface PublishGlossaryAnchorSemanticInput { ++ scope: "global" | "datasource"; ++ scopeKey?: string; ++ datasourceId?: string | null; ++ anchorId: string; ++ anchorType?: "release" | "rollback"; ++ glossaryVersion: number; ++ summary?: string | null; ++ rollbackFromAnchorId?: string | null; ++ rollbackReason?: string | null; ++ runId?: string; ++ terms: Array<{ ++ term: string; ++ definition: string; ++ synonyms?: string[]; ++ priority: number; ++ updatedAt: string; ++ }>; ++} ++ ++type PrismaClientLike = { ++ semanticRegistryVersion?: { ++ create?: (args: Record) => Promise; ++ updateMany?: (args: Record) => Promise<{ count: number }>; ++ findMany?: (args: Record) => Promise; ++ findFirst?: (args: Record) => Promise; ++ }; ++ semanticRegistryTerm?: { ++ createMany?: (args: Record) => Promise<{ count: number }>; ++ findMany?: (args: Record) => Promise; ++ }; ++ $transaction?: (fn: (tx: PrismaTransactionClientLike) => Promise) => Promise; ++ $disconnect: () => Promise; ++}; ++ ++type PrismaTransactionClientLike = Omit; ++ ++type SemanticRegistryVersionRow = { ++ id: string; ++ domain: string; ++ semanticVersion: number; ++ status: string; ++ releaseSummary: string; ++ auditSummary: string; ++ riskTags: string[]; ++ publishedByRunId: string | null; ++ activatedByRunId: string | null; ++ activatedAt: Date | null; ++ createdAt: Date; ++ updatedAt: Date; ++}; ++ ++type SemanticRegistryTermRow = { ++ id: string; ++ versionId: string; ++ domain: string; ++ term: string; ++ canonicalKey: string; ++ definition: string; ++ binding: string; ++ metadata: string | null; ++ createdAt: Date; ++ updatedAt: Date; ++}; ++ ++@Injectable() ++export class SemanticRegistryService implements OnModuleInit, OnModuleDestroy { ++ private readonly logger = new Logger(SemanticRegistryService.name); ++ private prisma?: PrismaClientLike; ++ private readonly versionsByDomain = new Map(); ++ private readonly termsByVersion = new Map(); ++ ++ constructor(private readonly appConfig: AppConfigService) {} ++ ++ buildDatasourceScopedDomain(domain: string, datasourceId: string): string { ++ const normalizedDomain = this.normalize(domain); ++ const normalizedDatasourceId = this.normalizeOptional(datasourceId)?.toLowerCase(); ++ if (!normalizedDomain || !normalizedDatasourceId) { ++ return normalizedDomain; ++ } ++ return `${normalizedDomain}::datasource::${normalizedDatasourceId}`; ++ } ++ ++ async onModuleInit(): Promise { ++ if (!this.isPrimaryPersistenceConfigured()) { ++ return; ++ } ++ ++ try { ++ const prismaClientModulePath = "../../../generated/prisma/client"; ++ const prismaModule = (await import(prismaClientModulePath)) as unknown as { ++ PrismaClient?: new (...args: unknown[]) => PrismaClientLike; ++ default?: { ++ PrismaClient?: new (...args: unknown[]) => PrismaClientLike; ++ }; ++ }; ++ const adapterModule = (await import("@prisma/adapter-pg")) as unknown as { ++ PrismaPg?: new (...args: unknown[]) => unknown; ++ default?: { ++ PrismaPg?: new (...args: unknown[]) => unknown; ++ }; ++ }; ++ const PrismaCtor = prismaModule.PrismaClient ?? prismaModule.default?.PrismaClient; ++ const PrismaPgCtor = adapterModule.PrismaPg ?? adapterModule.default?.PrismaPg; ++ if (!PrismaCtor) { ++ throw new Error("PrismaClient 未生成,请先执行 prisma generate"); ++ } ++ if (!PrismaPgCtor) { ++ throw new Error("Prisma PostgreSQL adapter 未安装"); ++ } ++ const adapter = new PrismaPgCtor({ ++ connectionString: this.appConfig.databaseUrl ++ }); ++ this.prisma = new PrismaCtor({ ++ adapter ++ }) as PrismaClientLike; ++ this.logger.log("Semantic Registry 已启用 PostgreSQL 持久化。"); ++ } catch (error) { ++ this.logger.warn( ++ `Semantic Registry 初始化失败,降级为内存模式: ${ ++ error instanceof Error ? error.message : String(error) ++ }` ++ ); ++ } ++ } ++ ++ async onModuleDestroy(): Promise { ++ if (this.prisma) { ++ await this.prisma.$disconnect(); ++ } ++ } ++ ++ async publishVersion( ++ input: PublishSemanticRegistryVersionInput ++ ): Promise { ++ const domain = this.normalize(input.domain); ++ if (!domain) { ++ throw new DomainError("SEMANTIC_REGISTRY_DOMAIN_REQUIRED", "domain 不能为空", 400); ++ } ++ if (input.terms.length === 0) { ++ throw new DomainError("SEMANTIC_REGISTRY_TERMS_REQUIRED", "terms 不能为空", 400); ++ } ++ ++ const latest = await this.getLatestVersion(domain); ++ const semanticVersion = input.semanticVersion ?? (latest?.semanticVersion ?? 0) + 1; ++ if (!Number.isInteger(semanticVersion) || semanticVersion <= 0) { ++ throw new DomainError( ++ "SEMANTIC_REGISTRY_VERSION_INVALID", ++ "semanticVersion 必须是正整数", ++ 400, ++ { semanticVersion } ++ ); ++ } ++ if (latest && semanticVersion <= latest.semanticVersion) { ++ throw new DomainError( ++ "SEMANTIC_REGISTRY_VERSION_NOT_MONOTONIC", ++ "semanticVersion 必须单调递增", ++ 409, ++ { ++ latestSemanticVersion: latest.semanticVersion, ++ requestedSemanticVersion: semanticVersion ++ } ++ ); ++ } ++ ++ const now = new Date().toISOString(); ++ const status = input.status ?? "active"; ++ const version: SemanticRegistryVersionRecord = { ++ id: uuidv4(), ++ domain, ++ semanticVersion, ++ status, ++ releaseSummary: input.releaseSummary.trim(), ++ auditSummary: input.auditSummary.trim(), ++ riskTags: this.unique(input.riskTags ?? []), ++ publishedByRunId: this.normalizeOptional(input.publishedByRunId), ++ activatedByRunId: this.normalizeOptional(input.activatedByRunId), ++ activatedAt: input.activatedAt ? this.toIso(input.activatedAt) : undefined, ++ createdAt: now, ++ updatedAt: now ++ }; ++ ++ const terms = input.terms.map((term) => this.toTermRecord(version, term, now)); ++ this.upsertVersionInMemory(version, terms); ++ ++ const versionModel = this.prisma?.semanticRegistryVersion; ++ const termModel = this.prisma?.semanticRegistryTerm; ++ const tx = this.prisma?.$transaction; ++ if ( ++ this.isPrimaryPersistenceConfigured() && ++ this.prisma && ++ tx && ++ versionModel?.create && ++ termModel?.createMany && ++ versionModel.updateMany ++ ) { ++ await this.tryPrismaWrite(async () => { ++ await tx(async (trx) => { ++ if (status === "active") { ++ await trx.semanticRegistryVersion?.updateMany?.({ ++ where: { ++ domain: version.domain, ++ status: "active" ++ }, ++ data: { ++ status: "deprecated" ++ } ++ }); ++ } ++ await trx.semanticRegistryVersion?.create?.({ ++ data: { ++ id: version.id, ++ domain: version.domain, ++ semanticVersion: version.semanticVersion, ++ status: version.status, ++ releaseSummary: version.releaseSummary, ++ auditSummary: version.auditSummary, ++ riskTags: version.riskTags, ++ publishedByRunId: version.publishedByRunId ?? null, ++ activatedByRunId: version.activatedByRunId ?? null, ++ activatedAt: version.activatedAt ? new Date(version.activatedAt) : null, ++ createdAt: new Date(version.createdAt), ++ updatedAt: new Date(version.updatedAt) ++ } ++ }); ++ await trx.semanticRegistryTerm?.createMany?.({ ++ data: terms.map((term) => ({ ++ id: term.id, ++ versionId: term.versionId, ++ domain: term.domain, ++ term: term.term, ++ canonicalKey: term.canonicalKey, ++ definition: term.definition, ++ binding: term.binding, ++ metadata: term.metadata ?? null, ++ createdAt: new Date(term.createdAt), ++ updatedAt: new Date(term.updatedAt) ++ })) ++ }); ++ }); ++ }); ++ } ++ ++ return { ...version }; ++ } ++ ++ async resolveTerm(input: SemanticRegistryLookupInput): Promise { ++ const domain = this.normalize(input.domain); ++ const term = this.normalize(input.term); ++ if (!domain || !term) { ++ return { ++ status: "degraded", ++ degrade_reason: SEMANTIC_VERSION_NOT_FOUND_REASON, ++ risk_tags: [SEMANTIC_REGISTRY_DEGRADED_RISK_TAG], ++ matched_scope: "global" ++ }; ++ } ++ const lookupDomains = this.buildLookupDomains(domain, input.datasourceId); ++ let lastVersion: SemanticRegistryVersionRecord | undefined; ++ let lastMatchedDomain: string | undefined; ++ let lastMissingReason: "version" | "term" = "version"; ++ ++ for (const lookupDomain of lookupDomains) { ++ const version = ++ typeof input.semanticVersion === "number" ++ ? await this.getVersion(lookupDomain, input.semanticVersion) ++ : await this.getActiveVersion(lookupDomain); ++ if (!version) { ++ continue; ++ } ++ lastVersion = version; ++ lastMatchedDomain = lookupDomain; ++ ++ const terms = await this.listTermsByVersion(version.id); ++ const matchedTerm = terms.find((item) => this.normalize(item.term) === term); ++ if (!matchedTerm) { ++ lastMissingReason = "term"; ++ continue; ++ } ++ ++ return { ++ status: "ready", ++ semantic_version: version.semanticVersion, ++ term: { ++ term: matchedTerm.term, ++ canonical_key: matchedTerm.canonicalKey, ++ definition: matchedTerm.definition, ++ binding: matchedTerm.binding, ++ metadata: matchedTerm.metadata ++ }, ++ release_summary: version.releaseSummary, ++ audit_summary: version.auditSummary, ++ published_by_run_id: version.publishedByRunId, ++ activated_by_run_id: version.activatedByRunId, ++ activated_at: version.activatedAt, ++ risk_tags: version.riskTags, ++ matched_scope: ++ lookupDomain === domain ? "global" : "datasource", ++ matched_domain: lookupDomain ++ }; ++ } ++ ++ if (!lastVersion) { ++ return { ++ status: "degraded", ++ semantic_version: input.semanticVersion, ++ degrade_reason: SEMANTIC_VERSION_NOT_FOUND_REASON, ++ risk_tags: [SEMANTIC_REGISTRY_DEGRADED_RISK_TAG], ++ matched_scope: "global", ++ matched_domain: domain ++ }; ++ } ++ ++ return { ++ status: "degraded", ++ semantic_version: lastVersion.semanticVersion, ++ release_summary: lastVersion.releaseSummary, ++ audit_summary: lastVersion.auditSummary, ++ published_by_run_id: lastVersion.publishedByRunId, ++ activated_by_run_id: lastVersion.activatedByRunId, ++ activated_at: lastVersion.activatedAt, ++ degrade_reason: ++ lastMissingReason === "term" ++ ? SEMANTIC_TERM_NOT_FOUND_REASON ++ : SEMANTIC_VERSION_NOT_FOUND_REASON, ++ risk_tags: [SEMANTIC_REGISTRY_DEGRADED_RISK_TAG], ++ matched_scope: lastMatchedDomain === domain ? "global" : "datasource", ++ matched_domain: lastMatchedDomain ?? domain ++ }; ++ } ++ ++ async publishGlossaryAnchorSemantic( ++ input: PublishGlossaryAnchorSemanticInput ++ ): Promise { ++ const anchorId = this.normalizeOptional(input.anchorId); ++ if (!anchorId) { ++ throw new DomainError("SEMANTIC_REGISTRY_ANCHOR_ID_REQUIRED", "anchorId 不能为空", 400); ++ } ++ if (!Number.isInteger(input.glossaryVersion) || input.glossaryVersion <= 0) { ++ throw new DomainError( ++ "SEMANTIC_REGISTRY_GLOSSARY_VERSION_INVALID", ++ "glossaryVersion 必须是正整数", ++ 400, ++ { ++ glossaryVersion: input.glossaryVersion ++ } ++ ); ++ } ++ ++ if (input.scope === "datasource" && !this.normalizeOptional(input.datasourceId ?? undefined)) { ++ throw new DomainError( ++ "SEMANTIC_REGISTRY_DATASOURCE_REQUIRED", ++ "datasource scope 必须指定 datasourceId", ++ 400 ++ ); ++ } ++ ++ const domain = ++ input.scope === "datasource" ++ ? this.buildDatasourceScopedDomain("semantic_term", input.datasourceId ?? "") ++ : "semantic_term"; ++ if (!domain) { ++ return null; ++ } ++ ++ const terms = input.terms ++ .map((item) => { ++ const normalizedTerm = this.normalizeOptional(item.term); ++ const normalizedDefinition = this.normalizeOptional(item.definition); ++ if (!normalizedTerm || !normalizedDefinition) { ++ return null; ++ } ++ const synonyms = Array.isArray(item.synonyms) ++ ? Array.from( ++ new Set( ++ item.synonyms ++ .map((candidate) => this.normalizeOptional(candidate)) ++ .filter((candidate): candidate is string => Boolean(candidate)) ++ ) ++ ) ++ : []; ++ const scopedKey = ++ input.scope === "datasource" ++ ? `datasource.${(input.datasourceId ?? "unknown").trim().toLowerCase()}` ++ : "global"; ++ ++ return { ++ term: normalizedTerm, ++ canonicalKey: `glossary.${scopedKey}.${normalizedTerm}`, ++ definition: normalizedDefinition, ++ binding: JSON.stringify({ ++ source: "glossary_anchor", ++ glossaryVersion: input.glossaryVersion, ++ anchorId, ++ anchorType: input.anchorType ?? "release", ++ scope: input.scope, ++ scopeKey: input.scopeKey ?? null, ++ datasourceId: input.datasourceId ?? null, ++ priority: Number.isFinite(item.priority) ? Math.floor(item.priority) : 50, ++ updatedAt: item.updatedAt ++ }), ++ metadata: JSON.stringify({ ++ synonyms, ++ summary: input.summary ?? null, ++ anchorType: input.anchorType ?? "release", ++ scopeKey: input.scopeKey ?? null, ++ rollbackFromAnchorId: input.rollbackFromAnchorId ?? null, ++ rollbackReason: input.rollbackReason ?? null ++ }) ++ }; ++ }) ++ .filter((item): item is SemanticRegistryTermInput => item !== null); ++ ++ if (terms.length === 0) { ++ return null; ++ } ++ ++ const isRollbackOperation = ++ input.anchorType === "rollback" || Boolean(input.rollbackFromAnchorId); ++ const scopeTag = ++ input.scope === "datasource" ++ ? `glossary_scope:datasource:${(input.datasourceId ?? "unknown").trim().toLowerCase()}` ++ : "glossary_scope:global"; ++ ++ return this.publishVersion({ ++ domain, ++ releaseSummary: ++ isRollbackOperation ++ ? `rollback glossary anchor ${anchorId} to ${input.rollbackFromAnchorId ?? "unknown"}` ++ : `release glossary anchor ${anchorId}`, ++ auditSummary: `glossary anchor semantic snapshot (anchor=${anchorId}, version=${input.glossaryVersion}, scope=${input.scope}, scopeKey=${input.scopeKey ?? scopeTag})`, ++ riskTags: [ ++ "glossary_anchor_semantic_snapshot", ++ `glossary_anchor_id:${anchorId}`, ++ `glossary_anchor_type:${input.anchorType ?? "release"}`, ++ scopeTag, ++ ...(input.rollbackFromAnchorId ++ ? ["glossary_anchor_rollback", `glossary_rollback_from:${input.rollbackFromAnchorId}`] ++ : []) ++ ], ++ publishedByRunId: this.normalizeOptional(input.runId), ++ activatedByRunId: this.normalizeOptional(input.runId), ++ terms ++ }); ++ } ++ ++ private toTermRecord( ++ version: SemanticRegistryVersionRecord, ++ input: SemanticRegistryTermInput, ++ now: string ++ ): SemanticRegistryTermRecord { ++ const normalizedTerm = this.normalize(input.term); ++ if (!normalizedTerm) { ++ throw new DomainError("SEMANTIC_REGISTRY_TERM_REQUIRED", "term 不能为空", 400); ++ } ++ const canonicalKey = input.canonicalKey.trim(); ++ if (!canonicalKey) { ++ throw new DomainError( ++ "SEMANTIC_REGISTRY_CANONICAL_KEY_REQUIRED", ++ "canonicalKey 不能为空", ++ 400 ++ ); ++ } ++ const definition = input.definition.trim(); ++ if (!definition) { ++ throw new DomainError("SEMANTIC_REGISTRY_DEFINITION_REQUIRED", "definition 不能为空", 400); ++ } ++ const binding = input.binding.trim(); ++ if (!binding) { ++ throw new DomainError("SEMANTIC_REGISTRY_BINDING_REQUIRED", "binding 不能为空", 400); ++ } ++ return { ++ id: uuidv4(), ++ versionId: version.id, ++ domain: version.domain, ++ term: normalizedTerm, ++ canonicalKey, ++ definition, ++ binding, ++ metadata: this.normalizeOptional(input.metadata), ++ createdAt: now, ++ updatedAt: now ++ }; ++ } ++ ++ private upsertVersionInMemory( ++ version: SemanticRegistryVersionRecord, ++ terms: SemanticRegistryTermRecord[] ++ ): void { ++ const versions = this.versionsByDomain.get(version.domain) ?? []; ++ const updated = versions ++ .filter((item) => item.semanticVersion !== version.semanticVersion) ++ .map((item) => ++ version.status === "active" && item.status === "active" ++ ? { ...item, status: "deprecated" as const, updatedAt: version.updatedAt } ++ : item ++ ); ++ updated.push({ ...version }); ++ updated.sort((left, right) => left.semanticVersion - right.semanticVersion); ++ this.versionsByDomain.set(version.domain, updated); ++ this.termsByVersion.set( ++ version.id, ++ terms.map((item) => ({ ...item })) ++ ); ++ } ++ ++ private async listTermsByVersion(versionId: string): Promise { ++ const memory = this.termsByVersion.get(versionId); ++ if (memory) { ++ return memory.map((item) => ({ ...item })); ++ } ++ ++ const termModel = this.prisma?.semanticRegistryTerm; ++ if (!this.isPrimaryPersistenceConfigured() || !this.prisma || !termModel?.findMany) { ++ return []; ++ } ++ ++ const rows = (await this.tryPrismaRead(async () => ++ termModel.findMany?.({ ++ where: { versionId }, ++ orderBy: [{ term: "asc" }] ++ }) ++ )) as SemanticRegistryTermRow[] | null; ++ if (!rows) { ++ return []; ++ } ++ const records = rows.map((row) => this.fromTermRow(row)); ++ this.termsByVersion.set(versionId, records); ++ return records.map((item) => ({ ...item })); ++ } ++ ++ private async getActiveVersion(domain: string): Promise { ++ const versions = await this.listVersions(domain); ++ const active = versions ++ .filter((item) => item.status === "active") ++ .sort((left, right) => right.semanticVersion - left.semanticVersion) ++ .at(0); ++ return active ? { ...active } : undefined; ++ } ++ ++ private async getLatestVersion(domain: string): Promise { ++ const versions = await this.listVersions(domain); ++ if (versions.length === 0) { ++ return undefined; ++ } ++ const sorted = [...versions].sort( ++ (left, right) => right.semanticVersion - left.semanticVersion ++ ); ++ return { ...sorted[0] }; ++ } ++ ++ private async getVersion( ++ domain: string, ++ semanticVersion: number ++ ): Promise { ++ const versions = await this.listVersions(domain); ++ const matched = versions.find((item) => item.semanticVersion === semanticVersion); ++ return matched ? { ...matched } : undefined; ++ } ++ ++ private async listVersions(domain: string): Promise { ++ const memory = this.versionsByDomain.get(domain); ++ if (memory) { ++ return memory.map((item) => ({ ...item })); ++ } ++ ++ const versionModel = this.prisma?.semanticRegistryVersion; ++ if (!this.isPrimaryPersistenceConfigured() || !this.prisma || !versionModel?.findMany) { ++ return []; ++ } ++ ++ const rows = (await this.tryPrismaRead(async () => ++ versionModel.findMany?.({ ++ where: { domain }, ++ orderBy: [{ semanticVersion: "asc" }] ++ }) ++ )) as SemanticRegistryVersionRow[] | null; ++ if (!rows) { ++ return []; ++ } ++ const versions = rows.map((row) => this.fromVersionRow(row)); ++ this.versionsByDomain.set(domain, versions); ++ return versions.map((item) => ({ ...item })); ++ } ++ ++ private fromVersionRow(row: SemanticRegistryVersionRow): SemanticRegistryVersionRecord { ++ return { ++ id: row.id, ++ domain: row.domain, ++ semanticVersion: row.semanticVersion, ++ status: row.status === "active" ? "active" : "deprecated", ++ releaseSummary: row.releaseSummary, ++ auditSummary: row.auditSummary, ++ riskTags: Array.isArray(row.riskTags) ? row.riskTags : [], ++ publishedByRunId: row.publishedByRunId ?? undefined, ++ activatedByRunId: row.activatedByRunId ?? undefined, ++ activatedAt: row.activatedAt?.toISOString(), ++ createdAt: row.createdAt.toISOString(), ++ updatedAt: row.updatedAt.toISOString() ++ }; ++ } ++ ++ private fromTermRow(row: SemanticRegistryTermRow): SemanticRegistryTermRecord { ++ return { ++ id: row.id, ++ versionId: row.versionId, ++ domain: row.domain, ++ term: row.term, ++ canonicalKey: row.canonicalKey, ++ definition: row.definition, ++ binding: row.binding, ++ metadata: row.metadata ?? undefined, ++ createdAt: row.createdAt.toISOString(), ++ updatedAt: row.updatedAt.toISOString() ++ }; ++ } ++ ++ private normalize(value: string): string { ++ return value.trim().toLowerCase(); ++ } ++ ++ private normalizeOptional(value?: string): string | undefined { ++ if (!value) { ++ return undefined; ++ } ++ const normalized = value.trim(); ++ return normalized ? normalized : undefined; ++ } ++ ++ private buildLookupDomains(domain: string, datasourceId?: string): string[] { ++ if (domain.includes("::datasource::")) { ++ return [domain]; ++ } ++ const normalizedDatasourceId = this.normalizeOptional(datasourceId)?.toLowerCase(); ++ if (!normalizedDatasourceId) { ++ return [domain]; ++ } ++ return [this.buildDatasourceScopedDomain(domain, normalizedDatasourceId), domain]; ++ } ++ ++ private toIso(input: string): string { ++ const parsed = Date.parse(input); ++ if (Number.isNaN(parsed)) { ++ throw new DomainError( ++ "SEMANTIC_REGISTRY_INVALID_TIMESTAMP", ++ `非法时间格式: ${input}`, ++ 400 ++ ); ++ } ++ return new Date(parsed).toISOString(); ++ } ++ ++ private unique(values: string[]): string[] { ++ return Array.from( ++ new Set(values.map((item) => item.trim()).filter((item) => item.length > 0)) ++ ); ++ } ++ ++ private isPrimaryPersistenceConfigured(): boolean { ++ return Boolean(this.appConfig.databaseUrl); ++ } ++ ++ private async tryPrismaRead(op: () => Promise): Promise { ++ try { ++ const result = await op(); ++ return result ?? null; ++ } catch (error) { ++ this.logger.warn( ++ `Semantic Registry 读取失败,回退内存模式: ${ ++ error instanceof Error ? error.message : String(error) ++ }` ++ ); ++ return null; ++ } ++ } ++ ++ private async tryPrismaWrite(op: () => Promise): Promise { ++ try { ++ const result = await op(); ++ return result ?? null; ++ } catch (error) { ++ this.logger.warn( ++ `Semantic Registry 写入失败,回退内存模式: ${ ++ error instanceof Error ? error.message : String(error) ++ }` ++ ); ++ return null; ++ } ++ } ++} +diff --git a/apps/backend/src/modules/settings/settings.controller.ts b/apps/backend/src/modules/settings/settings.controller.ts +index 3aaf5dc..33cf6d5 100644 +--- a/apps/backend/src/modules/settings/settings.controller.ts ++++ b/apps/backend/src/modules/settings/settings.controller.ts +@@ -1,29 +1,34 @@ + import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, ++ Query, + Req, ++ Res, + UseGuards + } from "@nestjs/common"; +-import type { Request } from "express"; ++import type { Request, Response } from "express"; + import type { ApiResponse } from "@text2sql/shared-types"; + import { fail, ok } from "../../common/api-response"; + import { DomainError } from "../../common/domain-error"; + import { AdminOnlyGuard } from "../auth/admin-only.guard"; + import { BatchUpdateModelStatusDto } from "./dto/batch-update-model-status.dto"; ++import { CreatePromptTemplateDto } from "./dto/create-prompt-template.dto"; + import { CreateProviderDto } from "./dto/create-provider.dto"; ++import { ListPromptTemplatesQueryDto } from "./dto/list-prompt-templates.query.dto"; + import { RefreshProviderModelsDto } from "./dto/refresh-provider-models.dto"; ++import { UpdatePromptTemplateDto } from "./dto/update-prompt-template.dto"; + import { UpdateModelStatusDto } from "./dto/update-model-status.dto"; + import { SettingsService } from "./settings.service"; + + @Controller("/api/v1/settings") + export class SettingsController { + constructor(private readonly settingsService: SettingsService) {} + + @Get("/models") + async getSettingsView(@Req() req: Request): Promise> { + try { +@@ -37,20 +42,83 @@ export class SettingsController { + @Get("/providers/supported") + async listSupportedProviders(@Req() req: Request): Promise> { + try { + const data = await this.settingsService.listSupportedProviders(); + return ok(req.requestId, data); + } catch (error) { + return this.toError(req.requestId, error); + } + } + ++ @Get("/prompts") ++ async listPromptTemplates( ++ @Query() query: ListPromptTemplatesQueryDto, ++ @Req() req: Request ++ ): Promise> { ++ try { ++ const data = await this.settingsService.listPromptTemplates(query); ++ return ok(req.requestId, data); ++ } catch (error) { ++ return this.toError(req.requestId, error); ++ } ++ } ++ ++ @Post("/prompts") ++ @UseGuards(AdminOnlyGuard) ++ async createPromptTemplate( ++ @Body() body: CreatePromptTemplateDto, ++ @Req() req: Request, ++ @Res({ passthrough: true }) res: Response ++ ): Promise> { ++ try { ++ const data = await this.settingsService.createPromptTemplate(req.actor, body); ++ return ok(req.requestId, data); ++ } catch (error) { ++ return this.toError(req.requestId, error, res); ++ } ++ } ++ ++ @Patch("/prompts/:templateId") ++ @UseGuards(AdminOnlyGuard) ++ async updatePromptTemplate( ++ @Param("templateId") templateId: string, ++ @Body() body: UpdatePromptTemplateDto, ++ @Req() req: Request, ++ @Res({ passthrough: true }) res: Response ++ ): Promise> { ++ try { ++ const data = await this.settingsService.updatePromptTemplate( ++ req.actor, ++ templateId, ++ body ++ ); ++ return ok(req.requestId, data); ++ } catch (error) { ++ return this.toError(req.requestId, error, res); ++ } ++ } ++ ++ @Delete("/prompts/:templateId") ++ @UseGuards(AdminOnlyGuard) ++ async deletePromptTemplate( ++ @Param("templateId") templateId: string, ++ @Req() req: Request, ++ @Res({ passthrough: true }) res: Response ++ ): Promise> { ++ try { ++ const data = await this.settingsService.deletePromptTemplate(req.actor, templateId); ++ return ok(req.requestId, data); ++ } catch (error) { ++ return this.toError(req.requestId, error, res); ++ } ++ } ++ + @Post("/providers") + @UseGuards(AdminOnlyGuard) + async createProvider( + @Body() body: CreateProviderDto, + @Req() req: Request + ): Promise> { + try { + const data = await this.settingsService.createProvider(req.actor, body); + return ok(req.requestId, data); + } catch (error) { +@@ -155,21 +223,31 @@ export class SettingsController { + @Req() req: Request + ): Promise> { + try { + const data = await this.settingsService.setModelEnabled(modelId, body.enabled); + return ok(req.requestId, data); + } catch (error) { + return this.toError(req.requestId, error); + } + } + +- private toError(requestId: string, error: unknown): ApiResponse { ++ private toError( ++ requestId: string, ++ error: unknown, ++ res?: Response ++ ): ApiResponse { + if (error instanceof DomainError) { ++ if (res) { ++ res.status(error.statusCode); ++ } + return fail(requestId, error.code, error.message, error.details); + } ++ if (res) { ++ res.status(500); ++ } + return fail( + requestId, + "INTERNAL_ERROR", + error instanceof Error ? error.message : "未知错误" + ); + } + } +diff --git a/apps/backend/src/modules/settings/settings.module.ts b/apps/backend/src/modules/settings/settings.module.ts +index 7b15f95..dca74dc 100644 +--- a/apps/backend/src/modules/settings/settings.module.ts ++++ b/apps/backend/src/modules/settings/settings.module.ts +@@ -1,13 +1,14 @@ + import { Module } from "@nestjs/common"; + import { LlmModule } from "../llm/llm.module"; + import { AdminOnlyGuard } from "../auth/admin-only.guard"; ++import { PromptTemplateService } from "./prompt-template.service"; + import { SettingsController } from "./settings.controller"; + import { SettingsService } from "./settings.service"; + + @Module({ + imports: [LlmModule], + controllers: [SettingsController], +- providers: [SettingsService, AdminOnlyGuard], +- exports: [SettingsService] ++ providers: [SettingsService, PromptTemplateService, AdminOnlyGuard], ++ exports: [SettingsService, PromptTemplateService] + }) + export class SettingsModule {} +diff --git a/apps/backend/src/modules/settings/settings.service.ts b/apps/backend/src/modules/settings/settings.service.ts +index 490e0f1..465b698 100644 +--- a/apps/backend/src/modules/settings/settings.service.ts ++++ b/apps/backend/src/modules/settings/settings.service.ts +@@ -1,18 +1,25 @@ + import { Injectable } from "@nestjs/common"; + import type { SettingsActor } from "@text2sql/shared-types"; + import { ProviderCatalogService } from "../llm/provider-catalog.service"; + import { CreateProviderDto } from "./dto/create-provider.dto"; ++import { CreatePromptTemplateDto } from "./dto/create-prompt-template.dto"; ++import { ListPromptTemplatesQueryDto } from "./dto/list-prompt-templates.query.dto"; ++import { UpdatePromptTemplateDto } from "./dto/update-prompt-template.dto"; ++import { PromptTemplateService } from "./prompt-template.service"; + + @Injectable() + export class SettingsService { +- constructor(private readonly providerCatalog: ProviderCatalogService) {} ++ constructor( ++ private readonly providerCatalog: ProviderCatalogService, ++ private readonly promptTemplateService: PromptTemplateService ++ ) {} + + async getSettingsView(actor: SettingsActor) { + return this.providerCatalog.listSettingsView(actor); + } + + async listSupportedProviders() { + return this.providerCatalog.listSupportedProviders(); + } + + async createProvider(actor: SettingsActor, body: CreateProviderDto) { +@@ -62,11 +69,34 @@ export class SettingsService { + return this.providerCatalog.listModelStatuses(); + } + + async batchSetModelsEnabled(modelIds: string[], enabled: boolean) { + return this.providerCatalog.batchSetModelsEnabled(modelIds, enabled); + } + + async setModelEnabled(modelId: string, enabled: boolean) { + return this.providerCatalog.setModelEnabled(modelId, enabled); + } ++ ++ async listPromptTemplates(query: ListPromptTemplatesQueryDto) { ++ return this.promptTemplateService.listTemplates(query); ++ } ++ ++ async createPromptTemplate( ++ actor: SettingsActor | undefined, ++ body: CreatePromptTemplateDto ++ ) { ++ return this.promptTemplateService.createTemplate(body, actor); ++ } ++ ++ async updatePromptTemplate( ++ actor: SettingsActor | undefined, ++ templateId: string, ++ body: UpdatePromptTemplateDto ++ ) { ++ return this.promptTemplateService.updateTemplate(templateId, body, actor); ++ } ++ ++ async deletePromptTemplate(actor: SettingsActor | undefined, templateId: string) { ++ return this.promptTemplateService.softDeleteTemplate(templateId, actor); ++ } + } +diff --git a/apps/backend/src/modules/skill-registry/skill-registry.module.ts b/apps/backend/src/modules/skill-registry/skill-registry.module.ts +new file mode 100644 +index 0000000..baba330 +--- /dev/null ++++ b/apps/backend/src/modules/skill-registry/skill-registry.module.ts +@@ -0,0 +1,8 @@ ++import { Module } from "@nestjs/common"; ++import { SkillRegistryService } from "./skill-registry.service"; ++ ++@Module({ ++ providers: [SkillRegistryService], ++ exports: [SkillRegistryService] ++}) ++export class SkillRegistryModule {} +diff --git a/apps/backend/src/modules/skill-registry/skill-registry.service.ts b/apps/backend/src/modules/skill-registry/skill-registry.service.ts +new file mode 100644 +index 0000000..9d29f38 +--- /dev/null ++++ b/apps/backend/src/modules/skill-registry/skill-registry.service.ts +@@ -0,0 +1,235 @@ ++import { Injectable } from "@nestjs/common"; ++ ++export const SKILL_REGISTRY_UNAVAILABLE_REASON = "skill_registry_unavailable"; ++ ++export interface SkillRegistryLookupInput { ++ domain: string; ++ term: string; ++ context?: Record; ++} ++ ++export interface SkillRegistrySkill { ++ key: string; ++ name: string; ++} ++ ++export interface SkillRegistryContextEntry { ++ source: "skill_registry"; ++ domain: string; ++ term: string; ++ matched_by: "term" | "context"; ++} ++ ++export interface SkillRegistryLookupResult { ++ skills: SkillRegistrySkill[]; ++ context: SkillRegistryContextEntry[]; ++ degrade_reason?: string; ++} ++ ++interface SkillRegistryBinding { ++ domain: string; ++ term: string; ++ term_aliases: string[]; ++ context_keywords: string[]; ++ skills: SkillRegistrySkill[]; ++} ++ ++interface SkillRegistryBindingMatch { ++ binding: SkillRegistryBinding; ++ matchedBy: "term" | "context"; ++} ++ ++interface SkillRegistryLookupContext { ++ domain: string; ++ term: string; ++ contextTokens: Set; ++} ++ ++const SKILL_REGISTRY_SOURCE: SkillRegistryContextEntry["source"] = "skill_registry"; ++ ++const DEFAULT_BINDINGS: readonly SkillRegistryBinding[] = [ ++ { ++ domain: "semantic_term", ++ term: "gmv", ++ term_aliases: ["gross merchandise volume", "交易额"], ++ context_keywords: ["gmv", "gross merchandise", "交易额"], ++ skills: [ ++ { key: "metric_term_resolution", name: "指标术语解析" }, ++ { key: "aggregation_sql_builder", name: "聚合 SQL 构建" } ++ ] ++ }, ++ { ++ domain: "schema", ++ term: "join_path", ++ term_aliases: ["join", "关系路径", "外键路径"], ++ context_keywords: ["join", "foreign key", "关联", "外键"], ++ skills: [ ++ { key: "schema_join_planning", name: "Schema Join 规划" }, ++ { key: "risk_join_validation", name: "Join 风险校验" } ++ ] ++ }, ++ { ++ domain: "sql_example", ++ term: "time_series", ++ term_aliases: ["趋势", "同比", "环比"], ++ context_keywords: ["trend", "同比", "环比", "date_trunc"], ++ skills: [ ++ { key: "sql_example_recall", name: "SQL 示例召回" }, ++ { key: "time_bucket_inference", name: "时间粒度推断" } ++ ] ++ } ++]; ++ ++@Injectable() ++export class SkillRegistryService { ++ async resolveSkills(input: SkillRegistryLookupInput): Promise { ++ const domain = this.normalize(input.domain); ++ const term = this.normalize(input.term); ++ if (!domain || !term) { ++ return this.emptyResult(); ++ } ++ const lookupContext: SkillRegistryLookupContext = { ++ domain, ++ term, ++ contextTokens: this.collectContextTokens(input.context) ++ }; ++ ++ try { ++ const matches = await this.lookupBindings(lookupContext); ++ if (matches.length === 0) { ++ return this.emptyResult(); ++ } ++ return { ++ skills: this.uniqueSkills(matches.flatMap((match) => match.binding.skills)), ++ context: this.uniqueContext(matches) ++ }; ++ } catch (error) { ++ return { ++ ...this.emptyResult(), ++ degrade_reason: SKILL_REGISTRY_UNAVAILABLE_REASON ++ }; ++ } ++ } ++ ++ protected async lookupBindings( ++ input: SkillRegistryLookupContext ++ ): Promise { ++ const matches: SkillRegistryBindingMatch[] = []; ++ for (const binding of DEFAULT_BINDINGS) { ++ if (this.normalize(binding.domain) !== input.domain) { ++ continue; ++ } ++ if (this.matchesTerm(binding, input.term)) { ++ matches.push({ binding, matchedBy: "term" }); ++ continue; ++ } ++ if (this.matchesContext(binding, input.contextTokens)) { ++ matches.push({ binding, matchedBy: "context" }); ++ } ++ } ++ return matches; ++ } ++ ++ private matchesTerm(binding: SkillRegistryBinding, term: string): boolean { ++ if (this.normalize(binding.term) === term) { ++ return true; ++ } ++ return binding.term_aliases.some((alias) => this.normalize(alias) === term); ++ } ++ ++ private matchesContext(binding: SkillRegistryBinding, contextTokens: Set): boolean { ++ if (contextTokens.size === 0) { ++ return false; ++ } ++ for (const rawKeyword of binding.context_keywords) { ++ const keyword = this.normalize(rawKeyword); ++ if (!keyword) { ++ continue; ++ } ++ if (contextTokens.has(keyword)) { ++ return true; ++ } ++ for (const token of contextTokens) { ++ if (token.includes(keyword) || keyword.includes(token)) { ++ return true; ++ } ++ } ++ } ++ return false; ++ } ++ ++ private uniqueSkills(skills: SkillRegistrySkill[]): SkillRegistrySkill[] { ++ const unique = new Map(); ++ for (const skill of skills) { ++ if (!unique.has(skill.key)) { ++ unique.set(skill.key, skill); ++ } ++ } ++ return [...unique.values()]; ++ } ++ ++ private uniqueContext(matches: SkillRegistryBindingMatch[]): SkillRegistryContextEntry[] { ++ const unique = new Map(); ++ for (const match of matches) { ++ const key = `${match.binding.domain}|${match.binding.term}|${match.matchedBy}`; ++ if (unique.has(key)) { ++ continue; ++ } ++ unique.set(key, { ++ source: SKILL_REGISTRY_SOURCE, ++ domain: match.binding.domain, ++ term: match.binding.term, ++ matched_by: match.matchedBy ++ }); ++ } ++ return [...unique.values()]; ++ } ++ ++ private collectContextTokens(context?: Record): Set { ++ const tokens = new Set(); ++ if (!context) { ++ return tokens; ++ } ++ ++ const queue: unknown[] = [context]; ++ while (queue.length > 0) { ++ const value = queue.shift(); ++ if (typeof value === "string") { ++ const normalized = this.normalize(value); ++ if (normalized) { ++ tokens.add(normalized); ++ } ++ for (const segment of value.split(/[\s,;|]+/g)) { ++ const normalizedSegment = this.normalize(segment); ++ if (normalizedSegment) { ++ tokens.add(normalizedSegment); ++ } ++ } ++ continue; ++ } ++ if (Array.isArray(value)) { ++ queue.push(...value); ++ continue; ++ } ++ if (this.isRecord(value)) { ++ queue.push(...Object.values(value)); ++ } ++ } ++ return tokens; ++ } ++ ++ private normalize(value: string): string { ++ return value.trim().toLowerCase(); ++ } ++ ++ private isRecord(value: unknown): value is Record { ++ return Boolean(value) && typeof value === "object" && !Array.isArray(value); ++ } ++ ++ private emptyResult(): SkillRegistryLookupResult { ++ return { ++ skills: [], ++ context: [] ++ }; ++ } ++} +diff --git a/apps/backend/src/modules/system/health.controller.ts b/apps/backend/src/modules/system/health.controller.ts +index 5cb696c..8dd7ce4 100644 +--- a/apps/backend/src/modules/system/health.controller.ts ++++ b/apps/backend/src/modules/system/health.controller.ts +@@ -2,42 +2,46 @@ import { Controller, Get, Req } from "@nestjs/common"; + import type { Request } from "express"; + import type { ApiResponse } from "@text2sql/shared-types"; + import { ok } from "../../common/api-response"; + import { AppConfigService } from "../config/app-config.service"; + import { RedisBufferService } from "../data/cache/redis-buffer.service"; + import { ChatRepository } from "../data/persistence/chat.repository"; + import { SqliteQueryService } from "../data/sqlite/sqlite-query.service"; + import { DatasourceService } from "../datasource/datasource.service"; + import { DatasourceRegistryService } from "../datasource/datasource-registry.service"; + import { GateMetricsService } from "../observability/gate-metrics.service"; ++import { RagIngestionMetricsService } from "../rag/observability/rag-ingestion-metrics.service"; ++import { RagQualityService } from "../rag/quality/rag-quality.service"; + + @Controller() + export class HealthController { + constructor( + private readonly config: AppConfigService, + private readonly sqlite: SqliteQueryService, + private readonly redis: RedisBufferService, + private readonly repository: ChatRepository, + private readonly datasourceService: DatasourceService, + private readonly datasourceRegistry: DatasourceRegistryService, +- private readonly gateMetrics: GateMetricsService ++ private readonly gateMetrics: GateMetricsService, ++ private readonly ragIngestionMetrics: RagIngestionMetricsService, ++ private readonly ragQuality: RagQualityService + ) {} + +- @Get("/health") +- async health(@Req() req: Request): Promise> { ++ private async buildHealthResponse(req: Request): Promise> { + const sqliteReady = await this.sqlite.healthCheck(); + const redisReady = await this.redis.healthCheck(); + const sessionSyncStats = await this.repository.getSessionSyncStats(); + const datasources = await this.datasourceService.listDatasources({ + includeUnavailable: true + }); + const postgresEnabled = Boolean(this.config.databaseUrl); ++ const ragQualityGate = this.ragQuality.snapshot(); + return ok(req.requestId, { + status: sqliteReady ? "ok" : "degraded", + runtime: { + nodeEnv: this.config.nodeEnv, + port: this.config.port + }, + dependencies: { + sqlite: { + ready: sqliteReady, + path: this.config.sqlitePath +@@ -68,19 +72,36 @@ export class HealthController { + configured: this.config.langsmithConfigured, + ready: this.config.langsmithReady, + project: this.config.langsmithProject, + endpoint: this.config.langsmithEndpoint + }, + sessions: { + sync: sessionSyncStats + }, + gateMetrics: { + acceptance: this.gateMetrics.snapshot() ++ }, ++ ragIngestionMetrics: { ++ foundation: this.ragIngestionMetrics.snapshot() ++ }, ++ ragQuality: { ++ gate: ragQualityGate, ++ r6: ragQualityGate.r6 + } + }, + cors: { + allowedOrigins: this.config.corsAllowedOrigins + }, + datasources + }); + } ++ ++ @Get("/health") ++ async health(@Req() req: Request): Promise> { ++ return this.buildHealthResponse(req); ++ } ++ ++ @Get("/api/health") ++ async healthApi(@Req() req: Request): Promise> { ++ return this.buildHealthResponse(req); ++ } + } +diff --git a/apps/backend/src/modules/system/system.module.ts b/apps/backend/src/modules/system/system.module.ts +index 308afff..fc25cc0 100644 +--- a/apps/backend/src/modules/system/system.module.ts ++++ b/apps/backend/src/modules/system/system.module.ts +@@ -1,12 +1,19 @@ + import { Module } from "@nestjs/common"; + import { AppConfigModule } from "../config/config.module"; + import { DataModule } from "../data/data.module"; + import { DatasourceModule } from "../datasource/datasource.module"; + import { ObservabilityModule } from "../observability/observability.module"; ++import { RagModule } from "../rag/rag.module"; + import { HealthController } from "./health.controller"; + + @Module({ +- imports: [AppConfigModule, DataModule, DatasourceModule, ObservabilityModule], ++ imports: [ ++ AppConfigModule, ++ DataModule, ++ DatasourceModule, ++ ObservabilityModule, ++ RagModule ++ ], + controllers: [HealthController] + }) + export class SystemModule {} +diff --git a/apps/backend/test/e2e/chat-api.spec.ts b/apps/backend/test/e2e/chat-api.spec.ts +index eaaf850..4a8b1d1 100644 +--- a/apps/backend/test/e2e/chat-api.spec.ts ++++ b/apps/backend/test/e2e/chat-api.spec.ts +@@ -360,12 +360,19 @@ describe("chat api (e2e)", () => { + expect(res.body.status).toBe("success"); + expect(res.body.data.cors.allowedOrigins).toContain("http://localhost:3000"); + expect(res.body.data.dependencies.sessions.sync.total).toBeGreaterThanOrEqual(0); + expect(res.body.data.dependencies.gateMetrics.acceptance).toEqual( + expect.objectContaining({ + observedRuns: expect.any(Number), + sampleReady: expect.any(Boolean), + gatePass: expect.any(Boolean) + }) + ); ++ ++ const apiHealthRes = await request(app.getHttpServer()) ++ .get("/api/health") ++ .send(); ++ expect(apiHealthRes.status).toBe(200); ++ expect(apiHealthRes.body.status).toBe("success"); ++ expect(apiHealthRes.body.data.status).toBe(res.body.data.status); + }); + }); +diff --git a/apps/backend/test/e2e/graph-acceleration-chaos.e2e-spec.ts b/apps/backend/test/e2e/graph-acceleration-chaos.e2e-spec.ts +new file mode 100644 +index 0000000..b097799 +--- /dev/null ++++ b/apps/backend/test/e2e/graph-acceleration-chaos.e2e-spec.ts +@@ -0,0 +1,131 @@ ++import { Test, type TestingModule } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { RagIndexBuilderService } from "../../src/modules/rag/index/rag-index-builder.service"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++import { RagRetrievalService } from "../../src/modules/rag/retrieval/rag-retrieval.service"; ++import { createSeededSqliteFixture } from "../support/sqlite-fixture"; ++ ++describe("graph acceleration chaos e2e", () => { ++ let moduleRef: TestingModule; ++ let retrievalService: RagRetrievalService; ++ let indexBuilder: RagIndexBuilderService; ++ let indexRepository: RagIndexRepository; ++ let cleanupFixture: (() => Promise) | undefined; ++ ++ beforeEach(async () => { ++ const fixture = await createSeededSqliteFixture("graph-acceleration-chaos"); ++ cleanupFixture = fixture.cleanup; ++ ++ process.env.SQLITE_PATH = fixture.dbPath; ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ process.env.GRAPH_ACCELERATION_ENABLED = "true"; ++ process.env.GRAPH_ACCELERATION_FORCE_FAILURE = "timeout"; ++ process.env.GRAPH_ACCELERATION_BREAKER_FAILURE_THRESHOLD = "1"; ++ process.env.GRAPH_ACCELERATION_BREAKER_OPEN_MS = "80"; ++ process.env.GRAPH_ACCELERATION_FORCE_POSTGRES_ONLY = "false"; ++ process.env.GRAPH_ACCELERATION_LATENCY_DEGRADE_THRESHOLD_MS = "5000"; ++ process.env.GRAPH_ACCELERATION_TIMEOUT_RATE_THRESHOLD = "1"; ++ process.env.GRAPH_ACCELERATION_TIMEOUT_RATE_MIN_SAMPLES = "100"; ++ ++ moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ ++ retrievalService = moduleRef.get(RagRetrievalService, { ++ strict: false ++ }); ++ indexBuilder = moduleRef.get(RagIndexBuilderService, { ++ strict: false ++ }); ++ indexRepository = moduleRef.get(RagIndexRepository, { ++ strict: false ++ }); ++ }); ++ ++ afterEach(async () => { ++ await moduleRef.close(); ++ if (cleanupFixture) { ++ await cleanupFixture(); ++ } ++ delete process.env.GRAPH_ACCELERATION_ENABLED; ++ delete process.env.GRAPH_ACCELERATION_FORCE_FAILURE; ++ delete process.env.GRAPH_ACCELERATION_BREAKER_FAILURE_THRESHOLD; ++ delete process.env.GRAPH_ACCELERATION_BREAKER_OPEN_MS; ++ delete process.env.GRAPH_ACCELERATION_FORCE_POSTGRES_ONLY; ++ delete process.env.GRAPH_ACCELERATION_LATENCY_DEGRADE_THRESHOLD_MS; ++ delete process.env.GRAPH_ACCELERATION_TIMEOUT_RATE_THRESHOLD; ++ delete process.env.GRAPH_ACCELERATION_TIMEOUT_RATE_MIN_SAMPLES; ++ }); ++ ++ it("falls back during chaos, then auto-recovers after breaker cooldown", async () => { ++ const datasourceId = "ds-graph-chaos"; ++ indexRepository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-chaos-orders", ++ datasourceId, ++ domain: "schema", ++ content: "table orders(id, user_id, amount)", ++ metadata: JSON.stringify({ ++ chunkProfile: "schema_table", ++ tableNames: ["orders"], ++ columnNames: ["id", "user_id", "amount"] ++ }) ++ }, ++ { ++ id: "chunk-chaos-semantic", ++ datasourceId, ++ domain: "semantic_term", ++ content: "GMV references order amount", ++ metadata: JSON.stringify({ ++ chunkProfile: "semantic_term", ++ tableNames: ["orders"], ++ columnNames: ["amount"] ++ }) ++ } ++ ]); ++ await indexBuilder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-graph-chaos-v1", ++ createdByRunId: "run-graph-chaos-build-v1", ++ activatedByRunId: "run-graph-chaos-build-v1" ++ }); ++ ++ const first = await retrievalService.retrieve({ ++ query: "orders relationship chaos first", ++ datasourceId, ++ runId: "run-graph-chaos-v1" ++ }); ++ expect(first.retrieval_bundle.lane_results.graph.status).toBe("degraded"); ++ expect(first.retrieval_bundle.lane_results.graph.degrade_reason).toContain( ++ "graph_acceleration_breaker_open_consecutive_failures" ++ ); ++ ++ delete process.env.GRAPH_ACCELERATION_FORCE_FAILURE; ++ ++ const second = await retrievalService.retrieve({ ++ query: "orders relationship chaos second", ++ datasourceId, ++ runId: "run-graph-chaos-v2" ++ }); ++ expect(second.retrieval_bundle.lane_results.graph.status).toBe("degraded"); ++ expect(second.retrieval_bundle.lane_results.graph.degrade_reason).toContain( ++ "graph_acceleration_breaker_open_consecutive_failures" ++ ); ++ ++ await new Promise((resolve) => setTimeout(resolve, 100)); ++ ++ const third = await retrievalService.retrieve({ ++ query: "orders relationship chaos third", ++ datasourceId, ++ runId: "run-graph-chaos-v3" ++ }); ++ expect(third.retrieval_bundle.lane_results.graph.status).toBe("ok"); ++ expect(third.retrieval_bundle.lane_results.graph.hits.length).toBeGreaterThan(0); ++ expect(third.retrieval_bundle.lane_results.graph.hits[0]?.evidence).toContain( ++ "graph:accelerated" ++ ); ++ }); ++}); +diff --git a/apps/backend/test/e2e/r6-release-rollback-rehearsal.e2e-spec.ts b/apps/backend/test/e2e/r6-release-rollback-rehearsal.e2e-spec.ts +new file mode 100644 +index 0000000..cb21a84 +--- /dev/null ++++ b/apps/backend/test/e2e/r6-release-rollback-rehearsal.e2e-spec.ts +@@ -0,0 +1,140 @@ ++import { Test, type TestingModule } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { RagIndexBuilderService } from "../../src/modules/rag/index/rag-index-builder.service"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++import { RagQualityService } from "../../src/modules/rag/quality/rag-quality.service"; ++import { RagRetrievalService } from "../../src/modules/rag/retrieval/rag-retrieval.service"; ++import { createSeededSqliteFixture } from "../support/sqlite-fixture"; ++ ++describe("r6 release rollback rehearsal e2e", () => { ++ let moduleRef: TestingModule; ++ let retrievalService: RagRetrievalService; ++ let qualityService: RagQualityService; ++ let indexBuilder: RagIndexBuilderService; ++ let indexRepository: RagIndexRepository; ++ let cleanupFixture: (() => Promise) | undefined; ++ ++ beforeEach(async () => { ++ const fixture = await createSeededSqliteFixture("r6-release-rollback-rehearsal"); ++ cleanupFixture = fixture.cleanup; ++ process.env.SQLITE_PATH = fixture.dbPath; ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ process.env.GRAPH_ACCELERATION_ENABLED = "true"; ++ process.env.GRAPH_ACCELERATION_FORCE_FAILURE = "timeout"; ++ process.env.GRAPH_ACCELERATION_BREAKER_FAILURE_THRESHOLD = "1"; ++ process.env.GRAPH_ACCELERATION_BREAKER_OPEN_MS = "1000"; ++ process.env.RELEASE_CANDIDATE = "r6-rollback-rehearsal-ci"; ++ ++ moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ retrievalService = moduleRef.get(RagRetrievalService, { ++ strict: false ++ }); ++ qualityService = moduleRef.get(RagQualityService, { ++ strict: false ++ }); ++ indexBuilder = moduleRef.get(RagIndexBuilderService, { ++ strict: false ++ }); ++ indexRepository = moduleRef.get(RagIndexRepository, { ++ strict: false ++ }); ++ qualityService.reset(); ++ }); ++ ++ afterEach(async () => { ++ await moduleRef.close(); ++ if (cleanupFixture) { ++ await cleanupFixture(); ++ } ++ delete process.env.GRAPH_ACCELERATION_ENABLED; ++ delete process.env.GRAPH_ACCELERATION_FORCE_FAILURE; ++ delete process.env.GRAPH_ACCELERATION_BREAKER_FAILURE_THRESHOLD; ++ delete process.env.GRAPH_ACCELERATION_BREAKER_OPEN_MS; ++ delete process.env.RELEASE_CANDIDATE; ++ }); ++ ++ it("rehearses rollback decision under graph fault, budget breach and build-failure pressure", async () => { ++ const datasourceId = "ds-r6-release-rollback-rehearsal"; ++ indexRepository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-r6-rollback-schema-orders", ++ datasourceId, ++ domain: "schema", ++ content: "table orders(id, user_id, amount, paid_at)" ++ }, ++ { ++ id: "chunk-r6-rollback-schema-users", ++ datasourceId, ++ domain: "schema", ++ content: "table users(id, email, city)" ++ }, ++ { ++ id: "chunk-r6-rollback-semantic", ++ datasourceId, ++ domain: "semantic_term", ++ content: "daily revenue tracks paid order amount" ++ } ++ ]); ++ await indexBuilder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-r6-rollback-rehearsal-v1", ++ createdByRunId: "run-r6-rollback-rehearsal-build-v1", ++ activatedByRunId: "run-r6-rollback-rehearsal-build-v1" ++ }); ++ ++ const retrieval = await retrievalService.retrieve({ ++ query: "orders users relationship for rollback rehearsal", ++ datasourceId, ++ runId: "run-r6-rollback-rehearsal-query-v1" ++ }); ++ expect(retrieval.retrieval_bundle.candidates.length).toBeGreaterThan(0); ++ expect(retrieval.retrieval_bundle.lane_results.graph.status).toBe("degraded"); ++ expect(retrieval.retrieval_bundle.lane_results.graph.degrade_reason).toContain( ++ "graph_acceleration" ++ ); ++ ++ for (let index = 0; index < 520; index += 1) { ++ qualityService.recordDatasourceOrchestration({ ++ datasourceId: `ds-r6-rollback-build-${index % 6}`, ++ workspaceId: "workspace-r6-release-rollback", ++ queueWaitMs: 3600, ++ isolationViolation: index < 16 ++ }); ++ } ++ ++ for (let index = 0; index < 1000; index += 1) { ++ qualityService.recordCacheBudget({ ++ cacheEligible: true, ++ cacheHit: index % 3 !== 0, ++ budgetDegraded: index < 150 ++ }); ++ } ++ ++ qualityService.recordEvaluation({ ++ runId: "run-r6-rollback-gate-v1", ++ datasourceId, ++ sampleSize: 1200, ++ recallAt20: 0.85, ++ mrrAt10: 0.7, ++ retrievalRerankP95Ms: 820, ++ degradeRate: 0.04 ++ }); ++ ++ const snapshot = qualityService.snapshot(); ++ expect(snapshot.r6.sampleReady).toBe(true); ++ expect(snapshot.r6.gateDecision).toBe("block"); ++ expect(snapshot.r6.gatePass).toBe(false); ++ expect(snapshot.r6.blockReasons).toEqual( ++ expect.arrayContaining(["indexBuildSuccessRate_breach", "retrievalP95Ms_breach"]) ++ ); ++ expect(snapshot.r6.freezeReasons).toEqual([]); ++ expect(snapshot.r6.rollbackReasons).toEqual( ++ expect.arrayContaining(["budgetDegradeRate_breach"]) ++ ); ++ }); ++}); +diff --git a/apps/backend/test/e2e/rag-multi-datasource-isolation.spec.ts b/apps/backend/test/e2e/rag-multi-datasource-isolation.spec.ts +new file mode 100644 +index 0000000..f7fcd75 +--- /dev/null ++++ b/apps/backend/test/e2e/rag-multi-datasource-isolation.spec.ts +@@ -0,0 +1,83 @@ ++import { INestApplication } from "@nestjs/common"; ++import { Test } from "@nestjs/testing"; ++import request from "supertest"; ++import { AppModule } from "../../src/app.module"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++import { RagDatasourceOrchestratorService } from "../../src/modules/rag/orchestration/rag-datasource-orchestrator.service"; ++import { createSeededSqliteFixture } from "../support/sqlite-fixture"; ++ ++describe("rag multi datasource isolation e2e", () => { ++ let app: INestApplication; ++ let orchestrator: RagDatasourceOrchestratorService; ++ let indexRepository: RagIndexRepository; ++ let cleanupFixture: (() => Promise) | undefined; ++ ++ beforeAll(async () => { ++ const fixture = await createSeededSqliteFixture("rag-orchestrator-e2e"); ++ cleanupFixture = fixture.cleanup; ++ process.env.SQLITE_PATH = fixture.dbPath; ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ ++ app = moduleRef.createNestApplication(); ++ await app.init(); ++ ++ orchestrator = app.get(RagDatasourceOrchestratorService); ++ indexRepository = app.get(RagIndexRepository); ++ }); ++ ++ afterAll(async () => { ++ await app.close(); ++ if (cleanupFixture) { ++ await cleanupFixture(); ++ } ++ }); ++ ++ it("keeps datasource boundaries and exposes orchestration metrics through /health", async () => { ++ indexRepository.seedChunksForDatasource("ds-r6-e2e-a", [ ++ { ++ id: "chunk-r6-e2e-a-1", ++ datasourceId: "ds-r6-e2e-a", ++ domain: "schema", ++ content: "table product(id, category)" ++ } ++ ]); ++ indexRepository.seedChunksForDatasource("ds-r6-e2e-b", [ ++ { ++ id: "chunk-r6-e2e-b-1", ++ datasourceId: "ds-r6-e2e-b", ++ domain: "sql_example", ++ content: "SELECT category, COUNT(*) FROM product GROUP BY category" ++ } ++ ]); ++ ++ await Promise.all([ ++ orchestrator.enqueueBuild({ ++ datasourceId: "ds-r6-e2e-a", ++ workspaceId: "workspace-r6-e2e", ++ sourceVersion: "source-v1" ++ }), ++ orchestrator.enqueueBuild({ ++ datasourceId: "ds-r6-e2e-b", ++ workspaceId: "workspace-r6-e2e", ++ sourceVersion: "source-v1" ++ }) ++ ]); ++ ++ const response = await request(app.getHttpServer()).get("/health"); ++ expect(response.status).toBe(200); ++ expect(response.body.status).toBe("success"); ++ ++ const gate = response.body.data.dependencies.ragQuality.gate; ++ expect(gate.datasourceOrchestration).toBeTruthy(); ++ expect(gate.datasourceOrchestration.sampleSize24h).toBeGreaterThanOrEqual(2); ++ expect(gate.datasourceOrchestration.datasourceIsolationViolationCount).toBe(0); ++ }); ++}); ++ +diff --git a/apps/backend/test/integration/agent-rag-degrade-flow.spec.ts b/apps/backend/test/integration/agent-rag-degrade-flow.spec.ts +new file mode 100644 +index 0000000..6c3c1ad +--- /dev/null ++++ b/apps/backend/test/integration/agent-rag-degrade-flow.spec.ts +@@ -0,0 +1,45 @@ ++import { resolve } from "node:path"; ++import { Test } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { GraphBuilderService } from "../../src/modules/agent/graph/graph.builder"; ++ ++describe("agent rag degrade flow integration", () => { ++ beforeAll(() => { ++ process.env.SQLITE_PATH = resolve( ++ __dirname, ++ "../../../../data/sqlite/text2sql.db" ++ ); ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_MOCK_MODE = "true"; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.AGENT_PLANNING_SCAFFOLD_ENABLED = "true"; ++ }); ++ ++ it("keeps main flow available when retrieval degrades due to missing active index", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ const graph = moduleRef.get(GraphBuilderService); ++ ++ const run = await graph.run({ ++ runId: "run-agent-rag-degrade-v1", ++ sessionId: "session-agent-rag-degrade-v1", ++ question: "按状态统计订单数量", ++ datasourceId: "sqlite_main", ++ datasourceType: "sqlite", ++ planningScaffoldEnabled: true ++ }); ++ ++ const retrieveStep = run.trace.steps.find((step) => step.node === "retrieve-knowledge"); ++ expect(retrieveStep?.detail).toContain("降级"); ++ ++ const generateStep = run.trace.steps.find((step) => step.node === "generate-sql"); ++ expect(generateStep?.inputSummary).toContain("retrievalStatus"); ++ expect(generateStep?.inputSummary).toContain("selectedContextCount"); ++ ++ expect(["executionResult", "failed", "rejected"]).toContain(run.status); ++ ++ await moduleRef.close(); ++ }); ++}); +diff --git a/apps/backend/test/integration/agent-rag-main-flow.spec.ts b/apps/backend/test/integration/agent-rag-main-flow.spec.ts +new file mode 100644 +index 0000000..cdcc76f +--- /dev/null ++++ b/apps/backend/test/integration/agent-rag-main-flow.spec.ts +@@ -0,0 +1,134 @@ ++import { resolve } from "node:path"; ++import { Test } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { GraphBuilderService } from "../../src/modules/agent/graph/graph.builder"; ++import { RagEventConsumerService } from "../../src/modules/rag/events/rag-event-consumer.service"; ++import { RagIndexBuilderService } from "../../src/modules/rag/index/rag-index-builder.service"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++ ++describe("agent rag main flow integration", () => { ++ beforeAll(() => { ++ process.env.SQLITE_PATH = resolve( ++ __dirname, ++ "../../../../data/sqlite/text2sql.db" ++ ); ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_MOCK_MODE = "true"; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.AGENT_PLANNING_SCAFFOLD_ENABLED = "true"; ++ }); ++ ++ it("runs retrieve -> rerank pipeline and passes selected_context to SQL generation", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ ++ const graph = moduleRef.get(GraphBuilderService); ++ const repository = moduleRef.get(RagIndexRepository); ++ const builder = moduleRef.get(RagIndexBuilderService); ++ const eventConsumer = moduleRef.get(RagEventConsumerService); ++ ++ repository.seedChunksForDatasource("sqlite_main", [ ++ { ++ id: "chunk-agent-rag-schema", ++ datasourceId: "sqlite_main", ++ domain: "schema", ++ content: "table orders(id, amount, status)", ++ metadata: JSON.stringify({ ++ tableNames: ["orders"], ++ columnNames: ["id", "amount", "status"] ++ }) ++ }, ++ { ++ id: "chunk-agent-rag-sql", ++ datasourceId: "sqlite_main", ++ domain: "sql_example", ++ content: "SELECT status, SUM(amount) FROM orders GROUP BY status" ++ }, ++ { ++ id: "chunk-agent-rag-semantic", ++ datasourceId: "sqlite_main", ++ domain: "semantic_term", ++ content: "GMV maps to total order amount." ++ } ++ ]); ++ await builder.buildAndActivate({ ++ datasourceId: "sqlite_main", ++ sourceVersion: "source-agent-rag-main-v1", ++ createdByRunId: "run-agent-rag-main-build-v1", ++ activatedByRunId: "run-agent-rag-main-build-v1" ++ }); ++ ++ const run = await graph.run({ ++ runId: "run-agent-rag-main-v1", ++ sessionId: "session-agent-rag-main-v1", ++ question: "统计订单 GMV", ++ datasourceId: "sqlite_main", ++ datasourceType: "sqlite", ++ planningScaffoldEnabled: true ++ }); ++ ++ const stepNames = run.trace.steps.map((step) => step.node); ++ expect(stepNames).toEqual( ++ expect.arrayContaining([ ++ "retrieve-knowledge", ++ "build-intent-plan", ++ "build-semantic-query", ++ "generate-sql", ++ "safety-check" ++ ]) ++ ); ++ ++ const generateStep = run.trace.steps.find((step) => step.node === "generate-sql"); ++ expect(generateStep?.inputSummary).toContain("selectedContextCount"); ++ ++ const safetyStep = run.trace.steps.find((step) => step.node === "safety-check"); ++ expect(safetyStep?.outputSummary).toContain("riskTags"); ++ ++ await eventConsumer.consumeEvent({ ++ eventId: "evt-agent-rag-main-linkage-degraded-1", ++ datasourceId: "sqlite_main", ++ sourceVersion: "source-agent-rag-main-v2", ++ eventType: "semantic_promoted", ++ runId: "run-agent-rag-main-linkage-degraded-build-v2", ++ payload: { ++ linkageStatus: "degraded", ++ degradeReason: "semantic_promoted_linkage_degraded", ++ glossaryTerms: [ ++ { ++ id: "gterm-agent-main-1", ++ term: "GMV", ++ definition: "GMV means gross merchandise volume", ++ scope: "datasource", ++ datasourceId: "sqlite_main", ++ priority: 90, ++ updatedAt: "2026-04-18T02:30:00.000Z" ++ } ++ ] ++ } ++ }); ++ ++ const degradedRun = await graph.run({ ++ runId: "run-agent-rag-main-v2", ++ sessionId: "session-agent-rag-main-v2", ++ question: "统计订单 GMV", ++ datasourceId: "sqlite_main", ++ datasourceType: "sqlite", ++ planningScaffoldEnabled: true ++ }); ++ ++ const degradedRetrieveStep = degradedRun.trace.steps.find( ++ (step) => step.node === "retrieve-knowledge" ++ ); ++ const degradedGenerateStep = degradedRun.trace.steps.find( ++ (step) => step.node === "generate-sql" ++ ); ++ ++ expect(degradedRetrieveStep?.outputSummary).toContain("semantic_promoted_linkage_degraded"); ++ expect(degradedGenerateStep?.inputSummary).toContain("retrievalDegradeReasons"); ++ expect(degradedGenerateStep).toBeDefined(); ++ ++ await moduleRef.close(); ++ }); ++}); +diff --git a/apps/backend/test/integration/delivery-contract.spec.ts b/apps/backend/test/integration/delivery-contract.spec.ts +new file mode 100644 +index 0000000..15d1997 +--- /dev/null ++++ b/apps/backend/test/integration/delivery-contract.spec.ts +@@ -0,0 +1,168 @@ ++import { INestApplication } from "@nestjs/common"; ++import type { ChatStreamEvent } from "@text2sql/shared-types"; ++import { Test } from "@nestjs/testing"; ++import request from "supertest"; ++import { AppModule } from "../../src/app.module"; ++import { DeliveryContractMapper } from "../../src/modules/delivery/delivery-contract.mapper"; ++import { createSeededSqliteFixture } from "../support/sqlite-fixture"; ++ ++interface ParsedSseEvent { ++ eventType: string; ++ event: ChatStreamEvent; ++} ++ ++function parseSseEvents(payload: string): ParsedSseEvent[] { ++ const blocks = payload ++ .split(/\n\n+/) ++ .map((block) => block.trim()) ++ .filter(Boolean); ++ ++ const events: ParsedSseEvent[] = []; ++ for (const block of blocks) { ++ const lines = block ++ .split("\n") ++ .map((line) => line.trim()) ++ .filter(Boolean); ++ const eventLine = lines.find((line) => line.startsWith("event:")); ++ const dataLines = lines.filter((line) => line.startsWith("data:")); ++ if (!eventLine || dataLines.length === 0) { ++ continue; ++ } ++ const eventType = eventLine.replace(/^event:\s*/, "").trim(); ++ const dataText = dataLines ++ .map((line) => line.replace(/^data:\s*/, "")) ++ .join("\n"); ++ const event = JSON.parse(dataText) as ChatStreamEvent; ++ events.push({ ++ eventType, ++ event ++ }); ++ } ++ return events; ++} ++ ++describe("delivery contract integration", () => { ++ let app: INestApplication; ++ let cleanupFixture: (() => Promise) | undefined; ++ ++ beforeAll(async () => { ++ const fixture = await createSeededSqliteFixture("delivery-contract"); ++ cleanupFixture = fixture.cleanup; ++ process.env.SQLITE_PATH = fixture.dbPath; ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ app = moduleRef.createNestApplication(); ++ await app.init(); ++ }); ++ ++ afterAll(async () => { ++ await app.close(); ++ if (cleanupFixture) { ++ await cleanupFixture(); ++ } ++ }); ++ ++ it("keeps sync response and stream finish payload contract-consistent", async () => { ++ const sessionRes = await request(app.getHttpServer()) ++ .post("/api/v1/sessions") ++ .send({ datasource: "sqlite_main" }); ++ expect(sessionRes.status).toBe(201); ++ const sessionId = sessionRes.body.data.id as string; ++ ++ const syncRes = await request(app.getHttpServer()) ++ .post(`/api/v1/sessions/${sessionId}/messages`) ++ .send({ message: "统计订单状态分布" }); ++ expect(syncRes.status).toBe(201); ++ expect(syncRes.body.data.delivery).toBeTruthy(); ++ expect(syncRes.body.data.run.delivery).toEqual(syncRes.body.data.delivery); ++ expect(syncRes.body.data.delivery.answer).toBeTruthy(); ++ expect(syncRes.body.data.delivery.evidence.runId).toBe( ++ syncRes.body.data.run.runId as string ++ ); ++ ++ const streamRes = await request(app.getHttpServer()) ++ .post(`/api/v1/sessions/${sessionId}/messages/stream`) ++ .send({ message: "统计订单状态分布" }); ++ expect(streamRes.status).toBe(200); ++ const events = parseSseEvents(streamRes.text); ++ const finish = [...events].reverse().find((item) => item.eventType === "finish"); ++ expect(finish).toBeDefined(); ++ ++ const finishData = (finish?.event.data ?? {}) as { ++ status: string; ++ rowCount: number; ++ delivery?: Record; ++ }; ++ expect(typeof finishData.status).toBe("string"); ++ expect(typeof finishData.rowCount).toBe("number"); ++ expect(finishData.delivery).toBeTruthy(); ++ expect(finishData.delivery).toHaveProperty("answer"); ++ expect(finishData.delivery).toHaveProperty("evidence"); ++ ++ const messagesRes = await request(app.getHttpServer()) ++ .get(`/api/v1/sessions/${sessionId}/messages`) ++ .send(); ++ expect(messagesRes.status).toBe(200); ++ expect(messagesRes.body.data.latestRun).toBeTruthy(); ++ expect(messagesRes.body.data.latestRun.runId).toBe(finish?.event.runId); ++ expect(messagesRes.body.data.latestRun.delivery.answer).toEqual( ++ (finishData.delivery as { answer?: unknown })?.answer ++ ); ++ expect(messagesRes.body.data.latestRun.delivery.artifact).toEqual( ++ (finishData.delivery as { artifact?: unknown })?.artifact ++ ); ++ expect(messagesRes.body.data.latestRun.delivery.evidence.runId).toBe( ++ finish?.event.runId ++ ); ++ }); ++ ++ it("falls back with delivery_mapper_failed when mapper throws", async () => { ++ const mapper = app.get(DeliveryContractMapper); ++ const mapSpy = jest.spyOn(mapper, "map").mockImplementation(() => { ++ throw new Error("mapper exploded"); ++ }); ++ ++ const sessionRes = await request(app.getHttpServer()) ++ .post("/api/v1/sessions") ++ .send({ datasource: "sqlite_main" }); ++ expect(sessionRes.status).toBe(201); ++ const sessionId = sessionRes.body.data.id as string; ++ ++ const syncRes = await request(app.getHttpServer()) ++ .post(`/api/v1/sessions/${sessionId}/messages`) ++ .send({ message: "统计订单状态分布" }); ++ expect(syncRes.status).toBe(201); ++ expect(syncRes.body.data.delivery.evidence.riskTags).toEqual( ++ expect.arrayContaining(["delivery_mapper_failed"]) ++ ); ++ expect(syncRes.body.data.run.delivery.evidence.riskTags).toEqual( ++ expect.arrayContaining(["delivery_mapper_failed"]) ++ ); ++ ++ const streamRes = await request(app.getHttpServer()) ++ .post(`/api/v1/sessions/${sessionId}/messages/stream`) ++ .send({ message: "统计订单状态分布" }); ++ expect(streamRes.status).toBe(200); ++ const events = parseSseEvents(streamRes.text); ++ const finish = [...events].reverse().find((item) => item.eventType === "finish"); ++ expect(finish).toBeDefined(); ++ const finishData = (finish?.event.data ?? {}) as { ++ delivery?: { ++ evidence?: { ++ riskTags?: string[]; ++ }; ++ }; ++ }; ++ expect(finishData.delivery?.evidence?.riskTags).toEqual( ++ expect.arrayContaining(["delivery_mapper_failed"]) ++ ); ++ ++ mapSpy.mockRestore(); ++ }); ++}); +diff --git a/apps/backend/test/integration/graph-acceleration-circuit-breaker.spec.ts b/apps/backend/test/integration/graph-acceleration-circuit-breaker.spec.ts +new file mode 100644 +index 0000000..82f06fb +--- /dev/null ++++ b/apps/backend/test/integration/graph-acceleration-circuit-breaker.spec.ts +@@ -0,0 +1,112 @@ ++import { Test, type TestingModule } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { RagIndexBuilderService } from "../../src/modules/rag/index/rag-index-builder.service"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++import { RagRetrievalService } from "../../src/modules/rag/retrieval/rag-retrieval.service"; ++import { createSeededSqliteFixture } from "../support/sqlite-fixture"; ++ ++describe("graph acceleration circuit breaker integration", () => { ++ let moduleRef: TestingModule; ++ let retrievalService: RagRetrievalService; ++ let indexBuilder: RagIndexBuilderService; ++ let indexRepository: RagIndexRepository; ++ let cleanupFixture: (() => Promise) | undefined; ++ ++ beforeEach(async () => { ++ const fixture = await createSeededSqliteFixture("graph-acceleration-breaker"); ++ cleanupFixture = fixture.cleanup; ++ ++ process.env.SQLITE_PATH = fixture.dbPath; ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ process.env.GRAPH_ACCELERATION_ENABLED = "true"; ++ process.env.GRAPH_ACCELERATION_FORCE_FAILURE = "error"; ++ process.env.GRAPH_ACCELERATION_BREAKER_FAILURE_THRESHOLD = "2"; ++ process.env.GRAPH_ACCELERATION_BREAKER_OPEN_MS = "120000"; ++ process.env.GRAPH_ACCELERATION_FORCE_POSTGRES_ONLY = "false"; ++ process.env.GRAPH_ACCELERATION_TIMEOUT_RATE_THRESHOLD = "1"; ++ process.env.GRAPH_ACCELERATION_TIMEOUT_RATE_MIN_SAMPLES = "100"; ++ ++ moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ ++ retrievalService = moduleRef.get(RagRetrievalService, { ++ strict: false ++ }); ++ indexBuilder = moduleRef.get(RagIndexBuilderService, { ++ strict: false ++ }); ++ indexRepository = moduleRef.get(RagIndexRepository, { ++ strict: false ++ }); ++ }); ++ ++ afterEach(async () => { ++ await moduleRef.close(); ++ if (cleanupFixture) { ++ await cleanupFixture(); ++ } ++ delete process.env.GRAPH_ACCELERATION_ENABLED; ++ delete process.env.GRAPH_ACCELERATION_FORCE_FAILURE; ++ delete process.env.GRAPH_ACCELERATION_BREAKER_FAILURE_THRESHOLD; ++ delete process.env.GRAPH_ACCELERATION_BREAKER_OPEN_MS; ++ delete process.env.GRAPH_ACCELERATION_FORCE_POSTGRES_ONLY; ++ delete process.env.GRAPH_ACCELERATION_TIMEOUT_RATE_THRESHOLD; ++ delete process.env.GRAPH_ACCELERATION_TIMEOUT_RATE_MIN_SAMPLES; ++ }); ++ ++ it("opens breaker after consecutive failures and keeps graph lane on postgres fallback", async () => { ++ const datasourceId = "ds-graph-breaker"; ++ indexRepository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-breaker-orders", ++ datasourceId, ++ domain: "schema", ++ content: "table orders(id, user_id, amount)", ++ metadata: JSON.stringify({ ++ chunkProfile: "schema_table", ++ tableNames: ["orders"], ++ columnNames: ["id", "user_id", "amount"] ++ }) ++ } ++ ]); ++ await indexBuilder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-graph-breaker-v1", ++ createdByRunId: "run-graph-breaker-build-v1", ++ activatedByRunId: "run-graph-breaker-build-v1" ++ }); ++ ++ const first = await retrievalService.retrieve({ ++ query: "orders relationship breaker first", ++ datasourceId, ++ runId: "run-graph-breaker-v1" ++ }); ++ const second = await retrievalService.retrieve({ ++ query: "orders relationship breaker second", ++ datasourceId, ++ runId: "run-graph-breaker-v2" ++ }); ++ ++ expect(first.retrieval_bundle.lane_results.graph.status).toBe("degraded"); ++ expect(second.retrieval_bundle.lane_results.graph.status).toBe("degraded"); ++ ++ delete process.env.GRAPH_ACCELERATION_FORCE_FAILURE; ++ ++ const third = await retrievalService.retrieve({ ++ query: "orders relationship breaker third", ++ datasourceId, ++ runId: "run-graph-breaker-v3" ++ }); ++ ++ expect(third.retrieval_bundle.candidates.length).toBeGreaterThan(0); ++ expect(third.retrieval_bundle.lane_results.graph.status).toBe("degraded"); ++ expect(third.retrieval_bundle.lane_results.graph.degrade_reason).toContain( ++ "graph_acceleration_breaker_open_consecutive_failures" ++ ); ++ expect(third.retrieval_bundle.lane_results.graph.hits.length).toBeGreaterThan(0); ++ }); ++}); +diff --git a/apps/backend/test/integration/graph-acceleration-fallback.spec.ts b/apps/backend/test/integration/graph-acceleration-fallback.spec.ts +new file mode 100644 +index 0000000..3c4cff5 +--- /dev/null ++++ b/apps/backend/test/integration/graph-acceleration-fallback.spec.ts +@@ -0,0 +1,159 @@ ++import { Test, type TestingModule } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { RagIndexBuilderService } from "../../src/modules/rag/index/rag-index-builder.service"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++import { RagRetrievalService } from "../../src/modules/rag/retrieval/rag-retrieval.service"; ++import { createSeededSqliteFixture } from "../support/sqlite-fixture"; ++ ++describe("graph acceleration fallback integration", () => { ++ let moduleRef: TestingModule; ++ let retrievalService: RagRetrievalService; ++ let indexBuilder: RagIndexBuilderService; ++ let indexRepository: RagIndexRepository; ++ let cleanupFixture: (() => Promise) | undefined; ++ ++ beforeEach(async () => { ++ const fixture = await createSeededSqliteFixture("graph-acceleration-fallback"); ++ cleanupFixture = fixture.cleanup; ++ ++ process.env.SQLITE_PATH = fixture.dbPath; ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ process.env.GRAPH_ACCELERATION_ENABLED = "true"; ++ process.env.GRAPH_ACCELERATION_FORCE_FAILURE = "error"; ++ process.env.GRAPH_ACCELERATION_BREAKER_FAILURE_THRESHOLD = "8"; ++ process.env.GRAPH_ACCELERATION_BREAKER_OPEN_MS = "5000"; ++ process.env.GRAPH_ACCELERATION_FORCE_POSTGRES_ONLY = "false"; ++ ++ moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ ++ retrievalService = moduleRef.get(RagRetrievalService, { ++ strict: false ++ }); ++ indexBuilder = moduleRef.get(RagIndexBuilderService, { ++ strict: false ++ }); ++ indexRepository = moduleRef.get(RagIndexRepository, { ++ strict: false ++ }); ++ }); ++ ++ afterEach(async () => { ++ await moduleRef.close(); ++ if (cleanupFixture) { ++ await cleanupFixture(); ++ } ++ delete process.env.GRAPH_ACCELERATION_ENABLED; ++ delete process.env.GRAPH_ACCELERATION_FORCE_FAILURE; ++ delete process.env.GRAPH_ACCELERATION_BREAKER_FAILURE_THRESHOLD; ++ delete process.env.GRAPH_ACCELERATION_BREAKER_OPEN_MS; ++ delete process.env.GRAPH_ACCELERATION_FORCE_POSTGRES_ONLY; ++ }); ++ ++ it("falls back to heuristic graph lane when acceleration adapter errors", async () => { ++ const datasourceId = "ds-graph-acceleration-fallback"; ++ indexRepository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-fallback-schema-orders", ++ datasourceId, ++ domain: "schema", ++ content: "table orders(id, user_id, amount)", ++ metadata: JSON.stringify({ ++ chunkProfile: "schema_table", ++ tableNames: ["orders"], ++ columnNames: ["id", "user_id", "amount"] ++ }) ++ }, ++ { ++ id: "chunk-fallback-schema-users", ++ datasourceId, ++ domain: "schema", ++ content: "table users(id, email)", ++ metadata: JSON.stringify({ ++ chunkProfile: "schema_table", ++ tableNames: ["users"], ++ columnNames: ["id", "email"] ++ }) ++ } ++ ]); ++ await indexBuilder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-graph-fallback-v1", ++ createdByRunId: "run-graph-fallback-build-v1", ++ activatedByRunId: "run-graph-fallback-build-v1" ++ }); ++ ++ const response = await retrievalService.retrieve({ ++ query: "orders users relationship foreign key", ++ datasourceId, ++ runId: "run-graph-fallback-v1" ++ }); ++ ++ expect(response.retrieval_bundle.candidates.length).toBeGreaterThan(0); ++ expect(response.retrieval_bundle.lane_results.graph.status).toBe("degraded"); ++ expect(response.retrieval_bundle.lane_results.graph.degrade_reason).toBe( ++ "graph_acceleration_error_fallback" ++ ); ++ expect(response.retrieval_bundle.lane_results.graph.hits.length).toBeGreaterThan(0); ++ expect(response.retrieval_bundle.degrade_reasons).toEqual( ++ expect.arrayContaining(["graph_acceleration_error_fallback"]) ++ ); ++ }); ++ ++ it("keeps postgres/mainline graph heuristic behavior when acceleration is disabled by default", async () => { ++ process.env.GRAPH_ACCELERATION_ENABLED = "false"; ++ process.env.GRAPH_ACCELERATION_FORCE_FAILURE = "timeout"; ++ ++ const datasourceId = "ds-graph-acceleration-default-disabled"; ++ indexRepository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-default-disabled-orders", ++ datasourceId, ++ domain: "schema", ++ content: "table orders(id, user_id, amount)", ++ metadata: JSON.stringify({ ++ chunkProfile: "schema_table", ++ tableNames: ["orders"], ++ columnNames: ["id", "user_id", "amount"] ++ }) ++ }, ++ { ++ id: "chunk-default-disabled-semantic", ++ datasourceId, ++ domain: "semantic_term", ++ content: "GMV references order amount", ++ metadata: JSON.stringify({ ++ chunkProfile: "semantic_term", ++ tableNames: ["orders"], ++ columnNames: ["amount"] ++ }) ++ } ++ ]); ++ await indexBuilder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-graph-default-disabled-v1", ++ createdByRunId: "run-graph-default-disabled-build-v1", ++ activatedByRunId: "run-graph-default-disabled-build-v1" ++ }); ++ ++ const response = await retrievalService.retrieve({ ++ query: "orders amount relationship", ++ datasourceId, ++ runId: "run-graph-default-disabled-v1" ++ }); ++ ++ expect(response.retrieval_bundle.candidates.length).toBeGreaterThan(0); ++ expect(response.retrieval_bundle.lane_results.graph.status).toBe("ok"); ++ expect(response.retrieval_bundle.lane_results.graph.degrade_reason).toBeUndefined(); ++ expect(response.retrieval_bundle.degrade_reasons).not.toEqual( ++ expect.arrayContaining([ ++ "graph_acceleration_error_fallback", ++ "graph_acceleration_timeout_fallback" ++ ]) ++ ); ++ }); ++}); +diff --git a/apps/backend/test/integration/memory-feedback-api.spec.ts b/apps/backend/test/integration/memory-feedback-api.spec.ts +new file mode 100644 +index 0000000..e50fdd4 +--- /dev/null ++++ b/apps/backend/test/integration/memory-feedback-api.spec.ts +@@ -0,0 +1,132 @@ ++import { INestApplication } from "@nestjs/common"; ++import { Test } from "@nestjs/testing"; ++import request from "supertest"; ++import { AppModule } from "../../src/app.module"; ++import { createSeededSqliteFixture } from "../support/sqlite-fixture"; ++ ++function withActor( ++ req: request.Test, ++ role: "admin" | "user" ++): request.Test { ++ return req.set("x-user-role", role).set("x-user-id", `itest-${role}`); ++} ++ ++describe("memory feedback api integration", () => { ++ let app: INestApplication; ++ let cleanupFixture: (() => Promise) | undefined; ++ ++ const createRunId = async (): Promise => { ++ const sessionRes = await withActor( ++ request(app.getHttpServer()).post("/api/v1/sessions"), ++ "admin" ++ ).send({ ++ datasource: "sqlite_main" ++ }); ++ expect(sessionRes.status).toBe(201); ++ const sessionId = sessionRes.body.data.id as string; ++ ++ const messageRes = await withActor( ++ request(app.getHttpServer()).post(`/api/v1/sessions/${sessionId}/messages`), ++ "admin" ++ ).send({ ++ message: "统计订单状态分布" ++ }); ++ expect(messageRes.status).toBe(201); ++ return messageRes.body.data.run.runId as string; ++ }; ++ ++ beforeAll(async () => { ++ const fixture = await createSeededSqliteFixture("memory-feedback-api"); ++ cleanupFixture = fixture.cleanup; ++ process.env.SQLITE_PATH = fixture.dbPath; ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ app = moduleRef.createNestApplication(); ++ await app.init(); ++ }); ++ ++ afterAll(async () => { ++ await app.close(); ++ if (cleanupFixture) { ++ await cleanupFixture(); ++ } ++ }); ++ ++ it("allows admin to submit memory feedback", async () => { ++ const runId = await createRunId(); ++ ++ const res = await withActor( ++ request(app.getHttpServer()).post("/api/v1/rag/memory/feedback"), ++ "admin" ++ ).send({ ++ runId, ++ targetStatus: "verified", ++ note: "manual verify" ++ }); ++ ++ expect(res.status).toBe(201); ++ expect(res.body.status).toBe("success"); ++ expect(res.body.data.runId).toBe(runId); ++ expect(res.body.data.afterStatus).toBe("verified"); ++ }); ++ ++ it("returns 403 for non-admin feedback requests", async () => { ++ const runId = await createRunId(); ++ ++ const res = await withActor( ++ request(app.getHttpServer()).post("/api/v1/rag/memory/feedback"), ++ "user" ++ ).send({ ++ runId, ++ targetStatus: "verified" ++ }); ++ ++ expect(res.status).toBe(403); ++ expect(res.body.statusCode).toBe(403); ++ expect(res.body.message).toContain("仅管理员可执行该操作"); ++ }); ++ ++ it("returns 404 for unknown run ids", async () => { ++ const res = await withActor( ++ request(app.getHttpServer()).post("/api/v1/rag/memory/feedback"), ++ "admin" ++ ).send({ ++ runId: "run-not-exist", ++ targetStatus: "verified" ++ }); ++ ++ expect(res.status).toBe(404); ++ expect(res.body.status).toBe("error"); ++ expect(res.body.error.code).toBe("RUN_NOT_FOUND"); ++ }); ++ ++ it("returns 409 for downgrade conflicts", async () => { ++ const runId = await createRunId(); ++ const promoteRes = await withActor( ++ request(app.getHttpServer()).post("/api/v1/rag/memory/feedback"), ++ "admin" ++ ).send({ ++ runId, ++ targetStatus: "production" ++ }); ++ expect(promoteRes.status).toBe(201); ++ ++ const conflictRes = await withActor( ++ request(app.getHttpServer()).post("/api/v1/rag/memory/feedback"), ++ "admin" ++ ).send({ ++ runId, ++ targetStatus: "candidate" ++ }); ++ ++ expect(conflictRes.status).toBe(409); ++ expect(conflictRes.body.status).toBe("error"); ++ expect(conflictRes.body.error.code).toBe("MEMORY_FEEDBACK_CONFLICT"); ++ }); ++}); +diff --git a/apps/backend/test/integration/memory-promotion-audit.spec.ts b/apps/backend/test/integration/memory-promotion-audit.spec.ts +new file mode 100644 +index 0000000..d7ac2a6 +--- /dev/null ++++ b/apps/backend/test/integration/memory-promotion-audit.spec.ts +@@ -0,0 +1,197 @@ ++import { Test, type TestingModule } from "@nestjs/testing"; ++import type { SqlRun } from "@text2sql/shared-types"; ++import { AppModule } from "../../src/app.module"; ++import { AuditLogRepository } from "../../src/modules/data/persistence/audit-log.repository"; ++import { MemoryPromotionService } from "../../src/modules/memory/memory-promotion.service"; ++import { RagReplayRepository } from "../../src/modules/rag/observability/rag-replay.repository"; ++import { createSeededSqliteFixture } from "../support/sqlite-fixture"; ++ ++function createRun(runId: string): SqlRun { ++ return { ++ runId, ++ sessionId: "session-memory-audit-int", ++ question: "统计订单状态分布", ++ status: "executionResult", ++ provider: "volcengine", ++ model: "mock-model", ++ sql: "SELECT status, COUNT(*) AS count FROM orders GROUP BY status", ++ answer: "已统计订单状态分布", ++ rows: [{ status: "paid", count: 4 }], ++ columns: ["status", "count"], ++ trace: { ++ runId, ++ provider: "volcengine", ++ retryCount: 0, ++ steps: [] ++ }, ++ llmRaw: null, ++ createdAt: "2026-04-18T00:00:00.000Z" ++ }; ++} ++ ++function parsePayload(payload: string): Record { ++ return JSON.parse(payload) as Record; ++} ++ ++describe("memory promotion audit integration", () => { ++ let moduleRef: TestingModule; ++ let promotionService: MemoryPromotionService; ++ let auditRepository: AuditLogRepository; ++ let ragReplayRepository: RagReplayRepository; ++ let cleanupFixture: (() => Promise) | undefined; ++ ++ beforeEach(async () => { ++ const fixture = await createSeededSqliteFixture("memory-promotion-audit"); ++ cleanupFixture = fixture.cleanup; ++ process.env.SQLITE_PATH = fixture.dbPath; ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ ++ moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ promotionService = moduleRef.get(MemoryPromotionService, { ++ strict: false ++ }); ++ auditRepository = moduleRef.get(AuditLogRepository, { ++ strict: false ++ }); ++ ragReplayRepository = moduleRef.get(RagReplayRepository, { ++ strict: false ++ }); ++ }); ++ ++ afterEach(async () => { ++ await moduleRef.close(); ++ if (cleanupFixture) { ++ await cleanupFixture(); ++ } ++ }); ++ ++ it("writes auditable promotion transition and replay linkage", async () => { ++ const run = createRun("run-memory-audit-success-1"); ++ const result = await promotionService.promoteFromRun({ ++ run, ++ datasourceId: "sqlite_main", ++ requestId: "req-memory-audit-success" ++ }); ++ ++ expect(result.state).toBe("promoted"); ++ const events = await auditRepository.listEvents({ ++ runId: run.runId, ++ eventType: "memory.promotion.transition.applied", ++ limit: 20 ++ }); ++ expect(events).toHaveLength(1); ++ expect(events[0]?.metadata?.candidateId).toBe(result.candidateId); ++ expect(events[0]?.metadata?.idempotencyKey).toBe(result.idempotencyKey); ++ expect(events[0]?.metadata?.beforeStatus).toBe("candidate"); ++ expect(events[0]?.metadata?.afterStatus).toBe("verified"); ++ expect(events[0]?.metadata?.replayKey).toBe( ++ `memory:promotion:${result.candidateId}:v1` ++ ); ++ ++ const replay = await ragReplayRepository.listByRunId(run.runId); ++ const promotionReplay = replay.find((item) => item.stage === "memory_promotion"); ++ expect(promotionReplay).toBeDefined(); ++ expect(promotionReplay?.replayKey).toContain(`memory:promotion:${result.candidateId}`); ++ const payload = parsePayload(promotionReplay?.payload ?? "{}"); ++ expect(payload.auditEventId).toBe(events[0]?.id); ++ expect(payload.replayKey).toBe(promotionReplay?.replayKey); ++ }); ++ ++ it("rolls back state on write failure and emits compensating audit signal", async () => { ++ const run = createRun("run-memory-audit-rollback-1"); ++ const candidateId = promotionService.buildCandidateIdForRun({ ++ run, ++ datasourceId: "sqlite_main" ++ }); ++ const writeReplaySpy = jest ++ .spyOn(ragReplayRepository, "writeReplay") ++ .mockRejectedValue(new Error("forced replay failure")); ++ ++ const result = await promotionService.promoteFromRun({ ++ run, ++ datasourceId: "sqlite_main", ++ requestId: "req-memory-audit-rollback" ++ }); ++ ++ expect(result.state).toBe("rolled_back"); ++ expect(result.afterStatus).toBe("candidate"); ++ expect(result.rejectionReasons).toEqual(["promotion_write_failed"]); ++ ++ const record = promotionService.getRecord(candidateId); ++ expect(record?.status).toBe("candidate"); ++ expect(record?.version).toBe(0); ++ ++ const compensationEvents = await auditRepository.listEvents({ ++ runId: run.runId, ++ eventType: "memory.promotion.compensated", ++ limit: 20 ++ }); ++ expect(compensationEvents.length).toBeGreaterThan(0); ++ expect(compensationEvents[0]?.metadata?.rollbackTo).toBe("candidate"); ++ ++ const compensations = promotionService.listCompensations(); ++ expect(compensations.length).toBeGreaterThan(0); ++ expect(compensations[0]?.state).toBe("pending_retry"); ++ expect(compensations[0]?.attempts).toBe(1); ++ ++ writeReplaySpy.mockRestore(); ++ }); ++ ++ it("escalates compensation to manual review after repeated write failures", async () => { ++ const run = createRun("run-memory-audit-compensation-manual-review"); ++ const writeReplaySpy = jest ++ .spyOn(ragReplayRepository, "writeReplay") ++ .mockRejectedValue(new Error("forced replay failure")); ++ ++ await promotionService.promoteFromRun({ ++ run, ++ datasourceId: "sqlite_main", ++ requestId: "req-memory-audit-compensation-1" ++ }); ++ await promotionService.promoteFromRun({ ++ run, ++ datasourceId: "sqlite_main", ++ requestId: "req-memory-audit-compensation-2" ++ }); ++ const thirdAttempt = await promotionService.promoteFromRun({ ++ run, ++ datasourceId: "sqlite_main", ++ requestId: "req-memory-audit-compensation-3" ++ }); ++ ++ expect(thirdAttempt.state).toBe("rolled_back"); ++ expect(thirdAttempt.compensation?.state).toBe("manual_review"); ++ expect(thirdAttempt.compensation?.attempts).toBe(3); ++ ++ const compensations = promotionService.listCompensations(); ++ expect(compensations).toHaveLength(1); ++ expect(compensations[0]?.state).toBe("manual_review"); ++ expect(compensations[0]?.attempts).toBe(3); ++ ++ const compensationEvents = await auditRepository.listEvents({ ++ runId: run.runId, ++ eventType: "memory.promotion.compensated", ++ limit: 20 ++ }); ++ expect( ++ compensationEvents.some( ++ (event) => ++ event.eventCode === "PROMOTION_COMPENSATED_MANUAL_REVIEW" && ++ event.metadata?.compensationState === "manual_review" ++ ) ++ ).toBe(true); ++ expect( ++ compensationEvents.some( ++ (event) => event.eventCode === "PROMOTION_COMPENSATED" ++ ) ++ ).toBe(true); ++ expect(compensationEvents.length).toBeGreaterThanOrEqual(3); ++ ++ writeReplaySpy.mockRestore(); ++ }); ++}); +diff --git a/apps/backend/test/integration/memory-promotion.spec.ts b/apps/backend/test/integration/memory-promotion.spec.ts +new file mode 100644 +index 0000000..0959314 +--- /dev/null ++++ b/apps/backend/test/integration/memory-promotion.spec.ts +@@ -0,0 +1,148 @@ ++import { Test, type TestingModule } from "@nestjs/testing"; ++import type { SqlRun } from "@text2sql/shared-types"; ++import { AppModule } from "../../src/app.module"; ++import { MemoryPromotionService } from "../../src/modules/memory/memory-promotion.service"; ++import { createSeededSqliteFixture } from "../support/sqlite-fixture"; ++ ++function createRun(runId: string, override: Partial = {}): SqlRun { ++ return { ++ runId, ++ sessionId: "session-memory-promotion-int", ++ question: "统计订单状态分布", ++ status: "executionResult", ++ provider: "volcengine", ++ model: "mock-model", ++ sql: "SELECT status, COUNT(*) AS count FROM orders GROUP BY status", ++ answer: "已统计订单状态分布", ++ rows: [{ status: "paid", count: 4 }], ++ columns: ["status", "count"], ++ trace: { ++ runId, ++ provider: "volcengine", ++ retryCount: 0, ++ steps: [] ++ }, ++ llmRaw: null, ++ createdAt: "2026-04-18T00:00:00.000Z", ++ ...override ++ }; ++} ++ ++describe("memory promotion integration", () => { ++ let moduleRef: TestingModule; ++ let promotionService: MemoryPromotionService; ++ let cleanupFixture: (() => Promise) | undefined; ++ ++ beforeEach(async () => { ++ const fixture = await createSeededSqliteFixture("memory-promotion"); ++ cleanupFixture = fixture.cleanup; ++ process.env.SQLITE_PATH = fixture.dbPath; ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ ++ moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ promotionService = moduleRef.get(MemoryPromotionService, { ++ strict: false ++ }); ++ }); ++ ++ afterEach(async () => { ++ await moduleRef.close(); ++ if (cleanupFixture) { ++ await cleanupFixture(); ++ } ++ }); ++ ++ it("promotes candidate -> verified -> production and ignores duplicate trigger", async () => { ++ const run1 = createRun("run-memory-integration-1"); ++ const result1 = await promotionService.promoteFromRun({ ++ run: run1, ++ datasourceId: "sqlite_main" ++ }); ++ ++ expect(result1.state).toBe("promoted"); ++ expect(result1.beforeStatus).toBe("candidate"); ++ expect(result1.afterStatus).toBe("verified"); ++ expect(result1.transitionKey).toBeDefined(); ++ ++ const duplicate = await promotionService.promoteFromRun({ ++ run: run1, ++ datasourceId: "sqlite_main" ++ }); ++ expect(duplicate.state).toBe("duplicate"); ++ expect(duplicate.rejectionReasons).toEqual(["duplicate_trigger_ignored"]); ++ ++ const run2 = createRun("run-memory-integration-2"); ++ const result2 = await promotionService.promoteFromRun({ ++ run: run2, ++ datasourceId: "sqlite_main" ++ }); ++ expect(result2.state).toBe("held"); ++ expect(result2.beforeStatus).toBe("verified"); ++ expect(result2.afterStatus).toBe("verified"); ++ expect(result2.rejectionReasons).toEqual( ++ expect.arrayContaining(["insufficient_samples_for_production"]) ++ ); ++ ++ const run3 = createRun("run-memory-integration-3"); ++ const result3 = await promotionService.promoteFromRun({ ++ run: run3, ++ datasourceId: "sqlite_main" ++ }); ++ expect(result3.state).toBe("promoted"); ++ expect(result3.beforeStatus).toBe("verified"); ++ expect(result3.afterStatus).toBe("production"); ++ ++ const candidateId = promotionService.buildCandidateIdForRun({ ++ run: run1, ++ datasourceId: "sqlite_main" ++ }); ++ const record = promotionService.getRecord(candidateId); ++ expect(record?.status).toBe("production"); ++ expect(record?.evidence.sampleCount).toBe(3); ++ expect(record?.evidence.successCount).toBe(3); ++ expect(record?.lastTransitionKey).toBe( ++ `${candidateId}:verified->production` ++ ); ++ }); ++ ++ it("holds candidate when risk tags breach threshold and keeps structured rejection reasons", async () => { ++ const run = createRun("run-memory-integration-risk-1", { ++ delivery: { ++ answer: { ++ text: "已统计订单状态分布", ++ status: "executionResult", ++ provider: "volcengine" ++ }, ++ evidence: { ++ runId: "run-memory-integration-risk-1", ++ riskTags: ["sandbox_denied"] ++ } ++ } ++ }); ++ ++ const result = await promotionService.promoteFromRun({ ++ run, ++ datasourceId: "sqlite_main" ++ }); ++ ++ expect(result.state).toBe("held"); ++ expect(result.beforeStatus).toBe("candidate"); ++ expect(result.afterStatus).toBe("candidate"); ++ expect(result.rejectionReasons).toEqual( ++ expect.arrayContaining(["risk_threshold_exceeded_for_verified"]) ++ ); ++ ++ const candidateId = promotionService.buildCandidateIdForRun({ ++ run, ++ datasourceId: "sqlite_main" ++ }); ++ const record = promotionService.getRecord(candidateId); ++ expect(record?.status).toBe("candidate"); ++ expect(record?.evidence.riskFlagCount).toBe(1); ++ }); ++}); +diff --git a/apps/backend/test/integration/planner-cache-replay.spec.ts b/apps/backend/test/integration/planner-cache-replay.spec.ts +new file mode 100644 +index 0000000..112c3b6 +--- /dev/null ++++ b/apps/backend/test/integration/planner-cache-replay.spec.ts +@@ -0,0 +1,104 @@ ++import { resolve } from "node:path"; ++import { Test } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { BuildPhysicalPlanNode } from "../../src/modules/agent/nodes/build-physical-plan.node"; ++import { PlannerCacheService } from "../../src/modules/agent/planner/planner-cache.service"; ++ ++describe("planner cache replay integration", () => { ++ beforeAll(() => { ++ process.env.SQLITE_PATH = resolve( ++ __dirname, ++ "../../../../data/sqlite/text2sql.db" ++ ); ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ }); ++ ++ it("uses planner cache on repeated same-input same-version requests", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ const physicalNode = moduleRef.get(BuildPhysicalPlanNode); ++ ++ const semanticPlan = { ++ status: "ready" as const, ++ semanticHints: ["use_metric_aliases"], ++ semanticVersion: 1, ++ lockStatus: "locked" as const, ++ fallbackApplied: false, ++ riskTags: [], ++ summary: "semantic ready" ++ }; ++ ++ const first = await physicalNode.run({ ++ semanticPlan, ++ question: "gmv trend", ++ datasourceId: "ds-planner-cache-hit" ++ }); ++ const second = await physicalNode.run({ ++ semanticPlan, ++ question: "gmv trend", ++ datasourceId: "ds-planner-cache-hit" ++ }); ++ ++ expect(first.cacheStatus).toBe("miss"); ++ expect(second.cacheStatus).toBe("hit"); ++ expect(second.strategy).toBe("direct_sql"); ++ ++ await moduleRef.close(); ++ }); ++ ++ it("invalidates old semantic-version cache entries on version switch", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ const physicalNode = moduleRef.get(BuildPhysicalPlanNode); ++ const cacheService = moduleRef.get(PlannerCacheService); ++ ++ await physicalNode.run({ ++ semanticPlan: { ++ status: "ready", ++ semanticHints: [], ++ semanticVersion: 1, ++ lockStatus: "locked", ++ fallbackApplied: false, ++ riskTags: [], ++ summary: "v1" ++ }, ++ question: "orders amount", ++ datasourceId: "ds-planner-cache-invalidate" ++ }); ++ ++ await physicalNode.run({ ++ semanticPlan: { ++ status: "ready", ++ semanticHints: [], ++ semanticVersion: 2, ++ lockStatus: "locked", ++ fallbackApplied: false, ++ riskTags: [], ++ summary: "v2" ++ }, ++ question: "orders amount", ++ datasourceId: "ds-planner-cache-invalidate" ++ }); ++ ++ const v1 = cacheService.lookup({ ++ datasourceId: "ds-planner-cache-invalidate", ++ question: "orders amount", ++ semanticVersion: 1 ++ }); ++ const v2 = cacheService.lookup({ ++ datasourceId: "ds-planner-cache-invalidate", ++ question: "orders amount", ++ semanticVersion: 2 ++ }); ++ ++ expect(v1.status).toBe("miss"); ++ expect(v2.status).toBe("hit"); ++ ++ await moduleRef.close(); ++ }); ++}); +diff --git a/apps/backend/test/integration/planner-version-lock.spec.ts b/apps/backend/test/integration/planner-version-lock.spec.ts +new file mode 100644 +index 0000000..dcbf7ec +--- /dev/null ++++ b/apps/backend/test/integration/planner-version-lock.spec.ts +@@ -0,0 +1,113 @@ ++import { resolve } from "node:path"; ++import { Test } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { BuildPhysicalPlanNode } from "../../src/modules/agent/nodes/build-physical-plan.node"; ++import { BuildSemanticQueryNode } from "../../src/modules/agent/nodes/build-semantic-query.node"; ++import { SemanticRegistryService } from "../../src/modules/semantic-registry/semantic-registry.service"; ++ ++describe("planner version lock integration", () => { ++ beforeAll(() => { ++ process.env.SQLITE_PATH = resolve( ++ __dirname, ++ "../../../../data/sqlite/text2sql.db" ++ ); ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ }); ++ ++ it("locks requested semantic version and keeps direct strategy", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ const registry = moduleRef.get(SemanticRegistryService); ++ const semanticNode = moduleRef.get(BuildSemanticQueryNode); ++ const physicalNode = moduleRef.get(BuildPhysicalPlanNode); ++ ++ await registry.publishVersion({ ++ domain: "semantic_term", ++ semanticVersion: 1, ++ releaseSummary: "v1", ++ auditSummary: "planner lock ready", ++ terms: [ ++ { ++ term: "gmv", ++ canonicalKey: "metric.gmv", ++ definition: "Gross merchandise volume", ++ binding: "{\"metric\":\"sum(order_amount)\"}" ++ } ++ ] ++ }); ++ ++ const semanticPlan = await semanticNode.run({ ++ intentPlan: { ++ status: "ready", ++ intent: "aggregate", ++ constraints: [], ++ summary: "intent ready" ++ }, ++ question: "gmv" ++ }); ++ const physicalPlan = await physicalNode.run({ ++ semanticPlan, ++ question: "gmv", ++ datasourceId: "ds-planner-lock-ready" ++ }); ++ ++ expect(semanticPlan.lockStatus).toBe("locked"); ++ expect(semanticPlan.semanticVersion).toBe(1); ++ expect(physicalPlan.strategy).toBe("direct_sql"); ++ expect(physicalPlan.status).toBe("ready"); ++ ++ await moduleRef.close(); ++ }); ++ ++ it("falls back to stable semantic version when requested version is missing", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ const registry = moduleRef.get(SemanticRegistryService); ++ const semanticNode = moduleRef.get(BuildSemanticQueryNode); ++ const physicalNode = moduleRef.get(BuildPhysicalPlanNode); ++ ++ await registry.publishVersion({ ++ domain: "semantic_term", ++ semanticVersion: 1, ++ releaseSummary: "v1", ++ auditSummary: "planner lock fallback", ++ terms: [ ++ { ++ term: "gmv", ++ canonicalKey: "metric.gmv", ++ definition: "Gross merchandise volume", ++ binding: "{\"metric\":\"sum(order_amount)\"}" ++ } ++ ] ++ }); ++ ++ const semanticPlan = await semanticNode.run({ ++ intentPlan: { ++ status: "ready", ++ intent: "aggregate", ++ constraints: [], ++ summary: "intent ready" ++ }, ++ question: "gmv", ++ requestedSemanticVersion: 9 ++ }); ++ const physicalPlan = await physicalNode.run({ ++ semanticPlan, ++ question: "gmv", ++ datasourceId: "ds-planner-lock-fallback" ++ }); ++ ++ expect(semanticPlan.lockStatus).toBe("fallback"); ++ expect(semanticPlan.fallbackApplied).toBe(true); ++ expect(semanticPlan.semanticVersion).toBe(1); ++ expect(physicalPlan.strategy).toBe("fallback_sql"); ++ expect(physicalPlan.status).toBe("degraded"); ++ ++ await moduleRef.close(); ++ }); ++}); +diff --git a/apps/backend/test/integration/prisma-graph-baseline.spec.ts b/apps/backend/test/integration/prisma-graph-baseline.spec.ts +index 2b3d155..f986e6b 100644 +--- a/apps/backend/test/integration/prisma-graph-baseline.spec.ts ++++ b/apps/backend/test/integration/prisma-graph-baseline.spec.ts +@@ -30,11 +30,60 @@ describe("prisma graph semantic baseline migration", () => { + + expect(sql).toContain('CREATE TABLE "graph_snapshots"'); + expect(sql).toContain('CREATE TABLE "semantic_memories"'); + expect(sql).toContain('CREATE TABLE "semantic_edges"'); + expect(sql).toContain('CREATE TABLE "agent_audit_logs"'); + + expect(sql).not.toContain('DROP TABLE "sessions"'); + expect(sql).not.toContain('DROP TABLE "messages"'); + expect(sql).not.toContain('DROP TABLE "sql_runs"'); + }); ++ ++ it("should include glossary linkage models and migration tables", async () => { ++ const schemaPath = resolve(process.cwd(), "prisma/schema.prisma"); ++ const schema = await readFile(schemaPath, "utf8"); ++ const migrationsDir = resolve(process.cwd(), "prisma/migrations"); ++ const migrationFolders = await readdir(migrationsDir, { withFileTypes: true }); ++ const targetFolder = migrationFolders ++ .filter((entry) => entry.isDirectory()) ++ .map((entry) => entry.name) ++ .find((name) => name.endsWith("_glossary_rag_linkage")); ++ ++ expect(schema).toContain("model GlossaryTerm"); ++ expect(schema).toContain("model GlossaryAnchor"); ++ expect(targetFolder).toBeDefined(); ++ ++ const migrationSqlPath = resolve(migrationsDir, targetFolder!, "migration.sql"); ++ const sql = await readFile(migrationSqlPath, "utf8"); ++ ++ expect(sql).toContain('CREATE TABLE "glossary_terms"'); ++ expect(sql).toContain('CREATE TABLE "glossary_anchors"'); ++ expect(sql).toContain( ++ 'CREATE UNIQUE INDEX "glossary_terms_version_scopeKey_normalizedTerm_key"' ++ ); ++ expect(sql).toContain('ALTER TABLE "glossary_terms" ADD CONSTRAINT'); ++ }); ++ ++ it("should include prompt template model and foundation migration", async () => { ++ const schemaPath = resolve(process.cwd(), "prisma/schema.prisma"); ++ const schema = await readFile(schemaPath, "utf8"); ++ const migrationsDir = resolve(process.cwd(), "prisma/migrations"); ++ const migrationFolders = await readdir(migrationsDir, { withFileTypes: true }); ++ const targetFolder = migrationFolders ++ .filter((entry) => entry.isDirectory()) ++ .map((entry) => entry.name) ++ .find((name) => name.endsWith("_prompt_templates_foundation")); ++ ++ expect(schema).toContain("model PromptTemplate"); ++ expect(schema).toContain("enum PromptTemplateScope"); ++ expect(schema).toContain("enum PromptTemplateScene"); ++ expect(schema).toContain("enum PromptTemplateStatus"); ++ expect(targetFolder).toBeDefined(); ++ ++ const migrationSqlPath = resolve(migrationsDir, targetFolder!, "migration.sql"); ++ const sql = await readFile(migrationSqlPath, "utf8"); ++ ++ expect(sql).toContain('CREATE TABLE "prompt_templates"'); ++ expect(sql).toContain('CREATE TYPE "PromptTemplateScope"'); ++ expect(sql).toContain('CREATE UNIQUE INDEX "prompt_templates_scene_scope_scopeKey_name_key"'); ++ }); + }); +diff --git a/apps/backend/test/integration/r3-gate-rehearsal.spec.ts b/apps/backend/test/integration/r3-gate-rehearsal.spec.ts +new file mode 100644 +index 0000000..7b06800 +--- /dev/null ++++ b/apps/backend/test/integration/r3-gate-rehearsal.spec.ts +@@ -0,0 +1,100 @@ ++import { resolve } from "node:path"; ++import { Test } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { BuildPhysicalPlanNode } from "../../src/modules/agent/nodes/build-physical-plan.node"; ++import { BuildSemanticQueryNode } from "../../src/modules/agent/nodes/build-semantic-query.node"; ++import { SemanticRegistryService } from "../../src/modules/semantic-registry/semantic-registry.service"; ++ ++describe("r3 gate rehearsal", () => { ++ beforeAll(() => { ++ process.env.SQLITE_PATH = resolve( ++ __dirname, ++ "../../../../data/sqlite/text2sql.db" ++ ); ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ }); ++ ++ it("replays fallback path with semantic rollback and planner cache stabilization", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ const registry = moduleRef.get(SemanticRegistryService); ++ const semanticNode = moduleRef.get(BuildSemanticQueryNode); ++ const physicalNode = moduleRef.get(BuildPhysicalPlanNode); ++ ++ await registry.publishVersion({ ++ domain: "semantic_term", ++ semanticVersion: 1, ++ releaseSummary: "R3 release v1", ++ auditSummary: "r3 gate rehearsal", ++ terms: [ ++ { ++ term: "gmv", ++ canonicalKey: "metric.gmv", ++ definition: "Gross merchandise volume", ++ binding: "{\"metric\":\"sum(order_amount)\"}" ++ } ++ ], ++ riskTags: ["r3_rehearsal"] ++ }); ++ ++ const semanticPlan = await semanticNode.run({ ++ intentPlan: { ++ status: "ready", ++ intent: "aggregate", ++ constraints: ["must_use_selected_context"], ++ summary: "intent ready" ++ }, ++ question: "gmv trend", ++ requestedSemanticVersion: 999, ++ retrievalBundle: { ++ query: "gmv trend", ++ run_id: "run-r3-rehearsal", ++ datasource_id: "ds-r3-rehearsal", ++ status: "ready", ++ degrade_reasons: [], ++ lane_results: { ++ lexical: { lane: "lexical", status: "ok", timeout_ms: 200, elapsed_ms: 20, hits: [] }, ++ dense: { lane: "dense", status: "ok", timeout_ms: 200, elapsed_ms: 25, hits: [] }, ++ graph: { lane: "graph", status: "ok", timeout_ms: 200, elapsed_ms: 22, hits: [] } ++ }, ++ candidates: [], ++ skill_context: { ++ skills: [], ++ context: [ ++ { ++ source: "skill_registry", ++ domain: "semantic_term", ++ term: "gmv", ++ matched_by: "term" ++ } ++ ] ++ } ++ } ++ }); ++ ++ const firstPhysical = await physicalNode.run({ ++ semanticPlan, ++ question: "gmv trend", ++ datasourceId: "ds-r3-rehearsal" ++ }); ++ const secondPhysical = await physicalNode.run({ ++ semanticPlan, ++ question: "gmv trend", ++ datasourceId: "ds-r3-rehearsal" ++ }); ++ ++ expect(semanticPlan.lockStatus).toBe("fallback"); ++ expect(semanticPlan.fallbackApplied).toBe(true); ++ expect(semanticPlan.semanticVersion).toBe(1); ++ expect(semanticPlan.riskTags).toEqual(expect.arrayContaining(["r3_rehearsal"])); ++ expect(firstPhysical.cacheStatus).toBe("miss"); ++ expect(secondPhysical.cacheStatus).toBe("hit"); ++ expect(secondPhysical.strategy).toBe("fallback_sql"); ++ ++ await moduleRef.close(); ++ }); ++}); +diff --git a/apps/backend/test/integration/r3-semantic-registry-migration.spec.ts b/apps/backend/test/integration/r3-semantic-registry-migration.spec.ts +new file mode 100644 +index 0000000..71dc99c +--- /dev/null ++++ b/apps/backend/test/integration/r3-semantic-registry-migration.spec.ts +@@ -0,0 +1,31 @@ ++import { readFileSync, readdirSync } from "node:fs"; ++import { resolve } from "node:path"; ++ ++describe("r3 semantic registry migration", () => { ++ it("contains expected tables and constraints for semantic registry", () => { ++ const migrationsDir = resolve(__dirname, "../../prisma/migrations"); ++ const migrationFolder = readdirSync(migrationsDir).find((name) => ++ name.includes("_r3_semantic_registry") ++ ); ++ ++ expect(migrationFolder).toBeDefined(); ++ const migrationSqlPath = resolve( ++ migrationsDir, ++ migrationFolder ?? "", ++ "migration.sql" ++ ); ++ const sql = readFileSync(migrationSqlPath, "utf8"); ++ ++ expect(sql).toContain('CREATE TABLE "semantic_registry_versions"'); ++ expect(sql).toContain('CREATE TABLE "semantic_registry_terms"'); ++ expect(sql).toContain( ++ 'CREATE UNIQUE INDEX "semantic_registry_versions_domain_semanticVersion_key"' ++ ); ++ expect(sql).toContain( ++ 'CREATE UNIQUE INDEX "semantic_registry_terms_versionId_term_key"' ++ ); ++ expect(sql).toContain( ++ 'ALTER TABLE "semantic_registry_terms" ADD CONSTRAINT "semantic_registry_terms_versionId_fkey"' ++ ); ++ }); ++}); +diff --git a/apps/backend/test/integration/r6-evidence-pipeline.spec.ts b/apps/backend/test/integration/r6-evidence-pipeline.spec.ts +new file mode 100644 +index 0000000..66d265a +--- /dev/null ++++ b/apps/backend/test/integration/r6-evidence-pipeline.spec.ts +@@ -0,0 +1,238 @@ ++import { spawnSync } from "node:child_process"; ++import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; ++import { tmpdir } from "node:os"; ++import { join, resolve } from "node:path"; ++ ++const EVIDENCE_REF_PREFIX = "data/reports/r6"; ++const EVIDENCE_FILE_NAMES = [ ++ "gate-summary.json", ++ "cache-budget-validation.json", ++ "graph-fallback-chaos.json", ++ "release-checklist.md", ++ "rollback-rehearsal.md" ++]; ++ ++describe("r6 evidence pipeline integration", () => { ++ const temporaryRoots: string[] = []; ++ const backendRoot = resolve(__dirname, "../.."); ++ const collectorScriptPath = resolve(backendRoot, "scripts/collect-r6-evidence.mjs"); ++ ++ afterAll(async () => { ++ for (const temporaryRoot of temporaryRoots) { ++ await rm(temporaryRoot, { recursive: true, force: true }); ++ } ++ }); ++ ++ it("fails fast when required evidence is missing", async () => { ++ const fixtureRoot = await mkdtemp(join(tmpdir(), "r6-evidence-missing-")); ++ temporaryRoots.push(fixtureRoot); ++ const reportDir = join(fixtureRoot, "reports"); ++ await mkdir(reportDir, { recursive: true }); ++ await writeGateSummary({ ++ reportDir, ++ releaseCandidate: "r6-missing-evidence" ++ }); ++ ++ const result = spawnSync("node", [collectorScriptPath], { ++ cwd: backendRoot, ++ env: { ++ ...process.env, ++ RELEASE_CANDIDATE: "r6-missing-evidence", ++ R6_REPORT_DIR: reportDir, ++ R6_EVIDENCE_ARCHIVE_DIR: join(fixtureRoot, "archive"), ++ R6_EVIDENCE_TIMESTAMP: "2026-04-18T03:31:00.000Z" ++ }, ++ encoding: "utf8" ++ }); ++ ++ expect(result.status).toBe(1); ++ expect(result.stderr).toContain("missing_evidence:data/reports/r6/cache-budget-validation.json"); ++ expect(result.stderr).toContain("missing_evidence:data/reports/r6/graph-fallback-chaos.json"); ++ expect(result.stderr).toContain("missing_evidence:data/reports/r6/release-checklist.md"); ++ expect(result.stderr).toContain("missing_evidence:data/reports/r6/rollback-rehearsal.md"); ++ }); ++ ++ it("archives complete evidence bundle and emits deterministic manifest", async () => { ++ const fixtureRoot = await mkdtemp(join(tmpdir(), "r6-evidence-complete-")); ++ temporaryRoots.push(fixtureRoot); ++ const reportDir = join(fixtureRoot, "reports"); ++ const archiveRoot = join(fixtureRoot, "archive"); ++ await writeCompleteEvidenceBundle({ ++ reportDir, ++ releaseCandidate: "r6-evidence-complete" ++ }); ++ ++ const result = spawnSync("node", [collectorScriptPath], { ++ cwd: backendRoot, ++ env: { ++ ...process.env, ++ RELEASE_CANDIDATE: "r6-evidence-complete", ++ R6_REPORT_DIR: reportDir, ++ R6_EVIDENCE_ARCHIVE_DIR: archiveRoot, ++ R6_EVIDENCE_TIMESTAMP: "2026-04-18T03:32:00.000Z" ++ }, ++ encoding: "utf8" ++ }); ++ ++ expect(result.status).toBe(0); ++ const payload = JSON.parse(result.stdout) as { ++ status: string; ++ archiveDir: string; ++ manifestPath: string; ++ }; ++ expect(payload.status).toBe("ok"); ++ ++ const manifestRaw = await readFile(payload.manifestPath, "utf8"); ++ const manifest = JSON.parse(manifestRaw) as { ++ incomplete: boolean; ++ artifacts: Array<{ fileName: string; archivePath: string; sizeBytes: number }>; ++ }; ++ expect(manifest.incomplete).toBe(false); ++ expect(manifest.artifacts).toHaveLength(EVIDENCE_FILE_NAMES.length); ++ for (const fileName of EVIDENCE_FILE_NAMES) { ++ expect( ++ manifest.artifacts.some( ++ (artifact) => artifact.fileName === fileName && artifact.sizeBytes > 0 ++ ) ++ ).toBe(true); ++ } ++ ++ const latestRaw = await readFile(join(reportDir, "release-evidence-latest.json"), "utf8"); ++ const latest = JSON.parse(latestRaw) as { incomplete: boolean }; ++ expect(latest.incomplete).toBe(false); ++ }); ++ ++ it("materializes release evidence fixture at R6_REPORT_DIR when requested by CI", async () => { ++ const reportDir = process.env.R6_REPORT_DIR; ++ if (!reportDir) { ++ expect(reportDir).toBeUndefined(); ++ return; ++ } ++ ++ await rm(reportDir, { recursive: true, force: true }); ++ await writeCompleteEvidenceBundle({ ++ reportDir, ++ releaseCandidate: process.env.RELEASE_CANDIDATE || "r6-ci-local" ++ }); ++ ++ const summaryRaw = await readFile(join(reportDir, "gate-summary.json"), "utf8"); ++ const summary = JSON.parse(summaryRaw) as { ++ gateDecision: string; ++ evidenceRefs: string[]; ++ }; ++ expect(summary.gateDecision).toBe("pass"); ++ expect(summary.evidenceRefs).toEqual( ++ expect.arrayContaining(EVIDENCE_FILE_NAMES.map((name) => `${EVIDENCE_REF_PREFIX}/${name}`)) ++ ); ++ }); ++}); ++ ++async function writeCompleteEvidenceBundle(input: { ++ reportDir: string; ++ releaseCandidate: string; ++}): Promise { ++ await mkdir(input.reportDir, { recursive: true }); ++ await writeGateSummary(input); ++ await writeFile( ++ join(input.reportDir, "cache-budget-validation.json"), ++ JSON.stringify( ++ { ++ generatedAt: "2026-04-18T03:30:00.000Z", ++ cacheEligibleHitRate: 0.71, ++ budgetDegradeRate: 0.04, ++ sampleSize1h: 1200 ++ }, ++ null, ++ 2 ++ ), ++ "utf8" ++ ); ++ await writeFile( ++ join(input.reportDir, "graph-fallback-chaos.json"), ++ JSON.stringify( ++ { ++ generatedAt: "2026-04-18T03:30:30.000Z", ++ scenario: "graph_acceleration_fault", ++ fallbackActivated: true, ++ recoveredAfterMs: 120 ++ }, ++ null, ++ 2 ++ ), ++ "utf8" ++ ); ++ await writeFile( ++ join(input.reportDir, "release-checklist.md"), ++ [ ++ "# R6 Release Checklist", ++ "", ++ "- [x] gate-summary.json generated", ++ "- [x] cache-budget-validation.json generated", ++ "- [x] graph-fallback-chaos.json generated", ++ "- [x] rollback-rehearsal.md generated" ++ ].join("\n"), ++ "utf8" ++ ); ++ await writeFile( ++ join(input.reportDir, "rollback-rehearsal.md"), ++ [ ++ "# R6 Rollback Rehearsal", ++ "", ++ "- Trigger: graph acceleration fault + budget pressure", ++ "- Action: route to postgres-only and hold rollout", ++ "- Result: rollback path remains available" ++ ].join("\n"), ++ "utf8" ++ ); ++} ++ ++async function writeGateSummary(input: { ++ reportDir: string; ++ releaseCandidate: string; ++}): Promise { ++ await writeFile( ++ join(input.reportDir, "gate-summary.json"), ++ JSON.stringify( ++ { ++ generatedAt: "2026-04-18T03:30:00.000Z", ++ releaseCandidate: input.releaseCandidate, ++ sampleReady: true, ++ gatePass: true, ++ gateDecision: "pass", ++ metrics: [ ++ { ++ name: "retrievalP95Ms", ++ value: 645, ++ threshold: "<= 700", ++ window: "1h", ++ minSamples: 1000, ++ samples: 1200, ++ met: true, ++ action: "block", ++ state: "met" ++ }, ++ { ++ name: "budgetDegradeRate", ++ value: 0.04, ++ threshold: "<= 0.08", ++ window: "1h", ++ minSamples: 500, ++ samples: 1200, ++ met: true, ++ action: "rollback-observe", ++ state: "met" ++ } ++ ], ++ blockReasons: [], ++ freezeReasons: [], ++ rollbackReasons: [], ++ evidenceRefs: EVIDENCE_FILE_NAMES.map( ++ (fileName) => `${EVIDENCE_REF_PREFIX}/${fileName}` ++ ) ++ }, ++ null, ++ 2 ++ ), ++ "utf8" ++ ); ++} +diff --git a/apps/backend/test/integration/r6-gate-readiness.spec.ts b/apps/backend/test/integration/r6-gate-readiness.spec.ts +new file mode 100644 +index 0000000..39eea4f +--- /dev/null ++++ b/apps/backend/test/integration/r6-gate-readiness.spec.ts +@@ -0,0 +1,134 @@ ++import { INestApplication } from "@nestjs/common"; ++import { Test } from "@nestjs/testing"; ++import request from "supertest"; ++import { AppModule } from "../../src/app.module"; ++import { RagQualityService } from "../../src/modules/rag/quality/rag-quality.service"; ++import { createSeededSqliteFixture } from "../support/sqlite-fixture"; ++ ++describe("r6 gate readiness integration", () => { ++ let app: INestApplication; ++ let quality: RagQualityService; ++ let cleanupFixture: (() => Promise) | undefined; ++ ++ beforeAll(async () => { ++ const fixture = await createSeededSqliteFixture("r6-gate-readiness"); ++ cleanupFixture = fixture.cleanup; ++ process.env.SQLITE_PATH = fixture.dbPath; ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ app = moduleRef.createNestApplication(); ++ await app.init(); ++ quality = app.get(RagQualityService); ++ }); ++ ++ afterAll(async () => { ++ await app.close(); ++ if (cleanupFixture) { ++ await cleanupFixture(); ++ } ++ }); ++ ++ beforeEach(() => { ++ quality.reset(); ++ }); ++ ++ it("signals block when R6 samples are not ready", async () => { ++ quality.recordEvaluation({ ++ runId: "run-r6-sample-not-ready", ++ datasourceId: "ds-r6-sample-not-ready", ++ sampleSize: 32, ++ recallAt20: 0.9, ++ mrrAt10: 0.8, ++ retrievalRerankP95Ms: 640, ++ degradeRate: 0.01 ++ }); ++ ++ const qualityRes = await request(app.getHttpServer()) ++ .get("/api/v1/rag/quality/report") ++ .send(); ++ expect(qualityRes.status).toBe(200); ++ expect(qualityRes.body.status).toBe("success"); ++ expect(qualityRes.body.data.r6.sampleReady).toBe(false); ++ expect(qualityRes.body.data.r6.gatePass).toBe(false); ++ expect(qualityRes.body.data.r6.gateDecision).toBe("block"); ++ expect(qualityRes.body.data.r6.blockReasons).toEqual( ++ expect.arrayContaining(["sample_not_ready"]) ++ ); ++ expect(qualityRes.body.data.r6.metrics).toEqual( ++ expect.arrayContaining([ ++ expect.objectContaining({ ++ name: "cacheEligibleHitRate" ++ }), ++ expect.objectContaining({ ++ name: "securityGatePass" ++ }) ++ ]) ++ ); ++ ++ const healthRes = await request(app.getHttpServer()).get("/health").send(); ++ expect(healthRes.status).toBe(200); ++ expect(healthRes.body.status).toBe("success"); ++ expect(healthRes.body.data.dependencies.ragQuality.r6.gateDecision).toBe("block"); ++ expect(healthRes.body.data.dependencies.ragQuality.r6.blockReasons).toEqual( ++ expect.arrayContaining(["sample_not_ready"]) ++ ); ++ }); ++ ++ it("signals freeze when cache hit rate breaches with ready samples", async () => { ++ for (let i = 0; i < 500; i += 1) { ++ quality.recordDatasourceOrchestration({ ++ datasourceId: `ds-r6-freeze-${i % 5}`, ++ workspaceId: "workspace-r6-freeze", ++ queueWaitMs: 1200, ++ isolationViolation: false ++ }); ++ } ++ ++ for (let i = 0; i < 1000; i += 1) { ++ quality.recordCacheBudget({ ++ cacheEligible: true, ++ cacheHit: i < 400, ++ budgetDegraded: false ++ }); ++ } ++ ++ quality.recordEvaluation({ ++ runId: "run-r6-freeze-cache-hit", ++ datasourceId: "ds-r6-freeze", ++ sampleSize: 1200, ++ recallAt20: 0.92, ++ mrrAt10: 0.83, ++ retrievalRerankP95Ms: 620, ++ degradeRate: 0.02 ++ }); ++ ++ const qualityRes = await request(app.getHttpServer()) ++ .get("/api/v1/rag/quality/report") ++ .send(); ++ expect(qualityRes.status).toBe(200); ++ expect(qualityRes.body.status).toBe("success"); ++ expect(qualityRes.body.data.r6.sampleReady).toBe(true); ++ expect(qualityRes.body.data.r6.gatePass).toBe(false); ++ expect(qualityRes.body.data.r6.gateDecision).toBe("freeze"); ++ expect(qualityRes.body.data.r6.blockReasons).toEqual([]); ++ expect(qualityRes.body.data.r6.freezeReasons).toEqual( ++ expect.arrayContaining(["cacheEligibleHitRate_breach"]) ++ ); ++ expect(qualityRes.body.data.r6.rollbackReasons).toEqual([]); ++ ++ const healthRes = await request(app.getHttpServer()).get("/health").send(); ++ expect(healthRes.status).toBe(200); ++ expect(healthRes.body.status).toBe("success"); ++ expect(healthRes.body.data.dependencies.ragQuality.r6.sampleReady).toBe(true); ++ expect(healthRes.body.data.dependencies.ragQuality.r6.gateDecision).toBe("freeze"); ++ expect(healthRes.body.data.dependencies.ragQuality.r6.freezeReasons).toEqual( ++ expect.arrayContaining(["cacheEligibleHitRate_breach"]) ++ ); ++ }); ++}); +diff --git a/apps/backend/test/integration/rag-audit-replay.spec.ts b/apps/backend/test/integration/rag-audit-replay.spec.ts +new file mode 100644 +index 0000000..f6edb76 +--- /dev/null ++++ b/apps/backend/test/integration/rag-audit-replay.spec.ts +@@ -0,0 +1,166 @@ ++import { Test, type TestingModule } from "@nestjs/testing"; ++import type { SqlRun } from "@text2sql/shared-types"; ++import { AppModule } from "../../src/app.module"; ++import { ChatRepository } from "../../src/modules/data/persistence/chat.repository"; ++import { RagAuditReplayService } from "../../src/modules/rag/audit/rag-audit-replay.service"; ++import { RagEventConsumerService } from "../../src/modules/rag/events/rag-event-consumer.service"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++import { createSeededSqliteFixture } from "../support/sqlite-fixture"; ++ ++function createRun(runId: string): SqlRun { ++ return { ++ runId, ++ sessionId: "session-rag-audit-1", ++ question: "按状态统计订单数量", ++ status: "executionResult", ++ provider: "volcengine", ++ model: "mock-model", ++ sql: "SELECT status, COUNT(*) AS total FROM orders GROUP BY status", ++ answer: "按状态统计完成", ++ rows: [{ status: "paid", total: 4 }], ++ columns: ["status", "total"], ++ trace: { ++ runId, ++ provider: "volcengine", ++ retryCount: 0, ++ steps: [ ++ { ++ node: "retrieve_context", ++ status: "success", ++ detail: "retrieval completed", ++ at: "2026-04-18T03:00:00.000Z" ++ } ++ ] ++ }, ++ llmRaw: null, ++ createdAt: "2026-04-18T03:00:00.000Z" ++ }; ++} ++ ++describe("rag audit replay integration", () => { ++ let moduleRef: TestingModule; ++ let eventConsumer: RagEventConsumerService; ++ let auditReplayService: RagAuditReplayService; ++ let indexRepository: RagIndexRepository; ++ let chatRepository: ChatRepository; ++ let cleanupFixture: (() => Promise) | undefined; ++ ++ beforeEach(async () => { ++ const fixture = await createSeededSqliteFixture("rag-audit-replay"); ++ cleanupFixture = fixture.cleanup; ++ process.env.SQLITE_PATH = fixture.dbPath; ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ ++ moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ ++ eventConsumer = moduleRef.get(RagEventConsumerService, { ++ strict: false ++ }); ++ auditReplayService = moduleRef.get(RagAuditReplayService, { ++ strict: false ++ }); ++ indexRepository = moduleRef.get(RagIndexRepository, { ++ strict: false ++ }); ++ chatRepository = moduleRef.get(ChatRepository, { ++ strict: false ++ }); ++ }); ++ ++ afterEach(async () => { ++ await moduleRef.close(); ++ if (cleanupFixture) { ++ await cleanupFixture(); ++ } ++ }); ++ ++ it("returns event -> index_version -> run trace chain for processed incremental event", async () => { ++ const runId = "run-rag-audit-chain-1"; ++ await chatRepository.persistRun(createRun(runId)); ++ ++ indexRepository.seedChunksForDatasource("ds-rag-audit", [ ++ { ++ id: "chunk-rag-audit-1", ++ datasourceId: "ds-rag-audit", ++ domain: "schema", ++ content: "table orders(id, status, amount)" ++ } ++ ]); ++ ++ const consumeResult = await eventConsumer.consumeEvent({ ++ eventId: "evt-rag-audit-1", ++ datasourceId: "ds-rag-audit", ++ sourceVersion: "schema-v3", ++ eventType: "sql_feedback_positive", ++ runId, ++ sessionId: "session-rag-audit-1", ++ occurredAt: "2026-04-18T03:01:00.000Z" ++ }); ++ ++ expect(consumeResult.status).toBe("processed"); ++ ++ const chain = await auditReplayService.queryChain({ runId }); ++ expect(chain.runId).toBe(runId); ++ expect(chain.runTrace?.runId).toBe(runId); ++ expect(chain.events).toHaveLength(1); ++ ++ const event = chain.events[0]; ++ expect(event?.eventId).toBe("evt-rag-audit-1"); ++ expect(event?.status).toBe("processed"); ++ expect(event?.indexVersion?.id).toBe(consumeResult.indexVersionId); ++ expect(event?.replayToken).toMatch(/^replay-/); ++ expect(event?.audits.some((item) => item.eventType === "rag.incremental-refresh.processed")).toBe( ++ true ++ ); ++ expect(event?.replay.some((item) => item.stage === "incremental_event_processed")).toBe( ++ true ++ ); ++ }); ++ ++ it("returns dlq status and failure reason for unrecoverable event", async () => { ++ const runId = "run-rag-audit-chain-dlq"; ++ await chatRepository.persistRun(createRun(runId)); ++ ++ await eventConsumer.consumeEvent({ ++ eventId: "evt-rag-audit-dlq-1", ++ datasourceId: "ds-rag-audit-dlq", ++ sourceVersion: "schema-v4", ++ eventType: "semantic_promoted", ++ runId ++ }); ++ await eventConsumer.consumeEvent({ ++ eventId: "evt-rag-audit-dlq-1", ++ datasourceId: "ds-rag-audit-dlq", ++ sourceVersion: "schema-v4", ++ eventType: "semantic_promoted", ++ runId ++ }); ++ await eventConsumer.consumeEvent({ ++ eventId: "evt-rag-audit-dlq-1", ++ datasourceId: "ds-rag-audit-dlq", ++ sourceVersion: "schema-v4", ++ eventType: "semantic_promoted", ++ runId ++ }); ++ ++ const chain = await auditReplayService.queryChain({ runId }); ++ expect(chain.events).toHaveLength(1); ++ const event = chain.events[0]; ++ expect(event?.status).toBe("dlq"); ++ expect(event?.failureReason).toEqual( ++ expect.stringMatching(/索引构建输入为空|semantic_promoted_linkage_failed/) ++ ); ++ expect(event?.indexVersion).toBeUndefined(); ++ ++ const windowed = await auditReplayService.queryChain({ ++ runId, ++ fromAt: "2100-01-01T00:00:00.000Z" ++ }); ++ expect(windowed.events).toHaveLength(0); ++ }); ++}); +diff --git a/apps/backend/test/integration/rag-cache-version-invalidation.spec.ts b/apps/backend/test/integration/rag-cache-version-invalidation.spec.ts +new file mode 100644 +index 0000000..f6cb701 +--- /dev/null ++++ b/apps/backend/test/integration/rag-cache-version-invalidation.spec.ts +@@ -0,0 +1,162 @@ ++import { Test, type TestingModule } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { RagIndexBuilderService } from "../../src/modules/rag/index/rag-index-builder.service"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++import { RagCacheKeyFactory } from "../../src/modules/rag/perf/rag-cache-key.factory"; ++import { RagQueryCacheService } from "../../src/modules/rag/perf/rag-query-cache.service"; ++import { RagRetrievalService } from "../../src/modules/rag/retrieval/rag-retrieval.service"; ++import { createSeededSqliteFixture } from "../support/sqlite-fixture"; ++ ++describe("rag cache version invalidation integration", () => { ++ let moduleRef: TestingModule; ++ let retrievalService: RagRetrievalService; ++ let indexBuilder: RagIndexBuilderService; ++ let indexRepository: RagIndexRepository; ++ let cacheKeyFactory: RagCacheKeyFactory; ++ let queryCache: RagQueryCacheService; ++ let cleanupFixture: (() => Promise) | undefined; ++ ++ beforeEach(async () => { ++ const fixture = await createSeededSqliteFixture("rag-cache-invalidation"); ++ cleanupFixture = fixture.cleanup; ++ process.env.SQLITE_PATH = fixture.dbPath; ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ ++ moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ retrievalService = moduleRef.get(RagRetrievalService, { ++ strict: false ++ }); ++ indexBuilder = moduleRef.get(RagIndexBuilderService, { ++ strict: false ++ }); ++ indexRepository = moduleRef.get(RagIndexRepository, { ++ strict: false ++ }); ++ cacheKeyFactory = moduleRef.get(RagCacheKeyFactory, { ++ strict: false ++ }); ++ queryCache = moduleRef.get(RagQueryCacheService, { ++ strict: false ++ }); ++ queryCache.reset(); ++ }); ++ ++ afterEach(async () => { ++ await moduleRef.close(); ++ if (cleanupFixture) { ++ await cleanupFixture(); ++ } ++ }); ++ ++ it("does not serve stale retrieval bundle across index version switch", async () => { ++ const datasourceId = "ds-rag-cache-version"; ++ indexRepository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-v1-orders", ++ datasourceId, ++ domain: "schema", ++ content: "table orders(id, amount, status)" ++ } ++ ]); ++ const v1 = await indexBuilder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-v1", ++ createdByRunId: "run-cache-v1-build", ++ activatedByRunId: "run-cache-v1-build" ++ }); ++ ++ const first = await retrievalService.retrieve({ ++ query: "orders amount", ++ datasourceId, ++ runId: "run-cache-v1-query" ++ }); ++ expect(first.retrieval_bundle.index_version_id).toBe(v1.indexVersionId); ++ const statsAfterFirst = queryCache.snapshotStats(); ++ expect(statsAfterFirst.misses).toBeGreaterThanOrEqual(1); ++ ++ indexRepository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-v2-payments", ++ datasourceId, ++ domain: "schema", ++ content: "table payments(id, channel, settled_at)" ++ } ++ ]); ++ const v2 = await indexBuilder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-v2", ++ createdByRunId: "run-cache-v2-build", ++ activatedByRunId: "run-cache-v2-build" ++ }); ++ ++ const second = await retrievalService.retrieve({ ++ query: "orders amount", ++ datasourceId, ++ runId: "run-cache-v2-query" ++ }); ++ ++ expect(v2.indexVersionId).not.toBe(v1.indexVersionId); ++ expect(second.retrieval_bundle.index_version_id).toBe(v2.indexVersionId); ++ const candidateIds = second.retrieval_bundle.candidates.map((item) => item.chunk_id); ++ expect(candidateIds).not.toContain("chunk-v1-orders"); ++ expect(candidateIds).toContain("chunk-v2-payments"); ++ ++ const statsAfterSecond = queryCache.snapshotStats(); ++ expect(statsAfterSecond.evictions).toBeGreaterThanOrEqual(1); ++ ++ const v1Key = cacheKeyFactory.build({ ++ stage: "retrieval_bundle", ++ datasourceId, ++ indexVersionId: v1.indexVersionId, ++ query: "orders amount" ++ }); ++ const v2Key = cacheKeyFactory.build({ ++ stage: "retrieval_bundle", ++ datasourceId, ++ indexVersionId: v2.indexVersionId, ++ query: "orders amount" ++ }); ++ expect(v1Key).toContain("ds=ds-rag-cache-version"); ++ expect(v1Key).toMatch(/\|q=[a-f0-9]{24}/); ++ expect(v2Key).not.toBe(v1Key); ++ }); ++ ++ it("promotes L2 entry after L1 expires without stale read", async () => { ++ const datasourceId = "ds-rag-cache-layer"; ++ const indexVersionId = "idx-layer-v1"; ++ const key = cacheKeyFactory.build({ ++ stage: "retrieval_bundle", ++ datasourceId, ++ indexVersionId, ++ query: "orders amount" ++ }); ++ queryCache.set({ ++ key, ++ stage: "retrieval_bundle", ++ datasourceId, ++ indexVersionId, ++ value: { ++ marker: "l2-fallback-hit" ++ }, ++ l1TtlMs: 5, ++ l2TtlMs: 120 ++ }); ++ ++ await new Promise((resolve) => { ++ setTimeout(resolve, 20); ++ }); ++ ++ const read = queryCache.get<{ marker: string }>(key); ++ expect(read.hit).toBe(true); ++ expect(read.value?.marker).toBe("l2-fallback-hit"); ++ ++ const stats = queryCache.snapshotStats(); ++ expect(stats.hits).toBeGreaterThanOrEqual(1); ++ expect(stats.misses).toBe(0); ++ }); ++}); +diff --git a/apps/backend/test/integration/rag-datasource-gate-metrics.spec.ts b/apps/backend/test/integration/rag-datasource-gate-metrics.spec.ts +new file mode 100644 +index 0000000..8d61aa0 +--- /dev/null ++++ b/apps/backend/test/integration/rag-datasource-gate-metrics.spec.ts +@@ -0,0 +1,133 @@ ++import { Test, type TestingModule } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { BuildRagIndexJob } from "../../src/modules/rag/jobs/build-rag-index.job"; ++import { RagDatasourceOrchestratorService } from "../../src/modules/rag/orchestration/rag-datasource-orchestrator.service"; ++import { RagQualityService } from "../../src/modules/rag/quality/rag-quality.service"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++import { createSeededSqliteFixture } from "../support/sqlite-fixture"; ++ ++function sleep(ms: number): Promise { ++ return new Promise((resolve) => { ++ setTimeout(resolve, ms); ++ }); ++} ++ ++describe("rag datasource gate metrics integration", () => { ++ let moduleRef: TestingModule; ++ let orchestrator: RagDatasourceOrchestratorService; ++ let qualityService: RagQualityService; ++ let buildJob: BuildRagIndexJob; ++ let indexRepository: RagIndexRepository; ++ let cleanupFixture: (() => Promise) | undefined; ++ ++ beforeEach(async () => { ++ const fixture = await createSeededSqliteFixture("rag-gate-metrics"); ++ cleanupFixture = fixture.cleanup; ++ process.env.SQLITE_PATH = fixture.dbPath; ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ ++ moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ orchestrator = moduleRef.get(RagDatasourceOrchestratorService, { ++ strict: false ++ }); ++ qualityService = moduleRef.get(RagQualityService, { ++ strict: false ++ }); ++ buildJob = moduleRef.get(BuildRagIndexJob, { ++ strict: false ++ }); ++ indexRepository = moduleRef.get(RagIndexRepository, { ++ strict: false ++ }); ++ qualityService.reset(); ++ }); ++ ++ afterEach(async () => { ++ await moduleRef.close(); ++ if (cleanupFixture) { ++ await cleanupFixture(); ++ } ++ }); ++ ++ it("reports queue wait p95 and zero isolation violations for healthy multi-build flow", async () => { ++ indexRepository.seedChunksForDatasource("ds-r6-metrics", [ ++ { ++ id: "chunk-r6-metrics-1", ++ datasourceId: "ds-r6-metrics", ++ domain: "schema", ++ content: "table customers(id, level)" ++ } ++ ]); ++ ++ const originalRun = buildJob.run.bind(buildJob); ++ const runSpy = jest ++ .spyOn(buildJob, "run") ++ .mockImplementation(async (input) => { ++ if (input.datasourceId === "ds-r6-metrics" && input.sourceVersion === "source-v1") { ++ await sleep(35); ++ } ++ return originalRun(input); ++ }); ++ ++ await Promise.all([ ++ orchestrator.enqueueBuild({ ++ datasourceId: "ds-r6-metrics", ++ workspaceId: "workspace-r6-metrics", ++ sourceVersion: "source-v1" ++ }), ++ orchestrator.enqueueBuild({ ++ datasourceId: "ds-r6-metrics", ++ workspaceId: "workspace-r6-metrics", ++ sourceVersion: "source-v2" ++ }) ++ ]); ++ ++ const snapshot = qualityService.snapshot(); ++ expect(snapshot.datasourceOrchestration.sampleSize24h).toBeGreaterThanOrEqual(2); ++ expect(snapshot.datasourceOrchestration.orchestratorQueueWaitP95Ms).toBeGreaterThan(0); ++ expect(snapshot.datasourceOrchestration.datasourceIsolationViolationCount).toBe(0); ++ ++ runSpy.mockRestore(); ++ }); ++ ++ it("increments isolation violation metric when build result datasource mismatches request", async () => { ++ indexRepository.seedChunksForDatasource("ds-r6-metrics-mismatch", [ ++ { ++ id: "chunk-r6-metrics-mismatch-1", ++ datasourceId: "ds-r6-metrics-mismatch", ++ domain: "schema", ++ content: "table returns(id, reason)" ++ } ++ ]); ++ ++ const originalRun = buildJob.run.bind(buildJob); ++ const runSpy = jest ++ .spyOn(buildJob, "run") ++ .mockImplementationOnce(async (input) => { ++ const result = await originalRun(input); ++ return { ++ ...result, ++ datasourceId: "mismatch-datasource" ++ }; ++ }); ++ ++ await orchestrator.enqueueBuild({ ++ datasourceId: "ds-r6-metrics-mismatch", ++ workspaceId: "workspace-r6-metrics", ++ sourceVersion: "source-v1" ++ }); ++ ++ const snapshot = qualityService.snapshot(); ++ expect(snapshot.datasourceOrchestration.datasourceIsolationViolationCount).toBeGreaterThanOrEqual( ++ 1 ++ ); ++ ++ runSpy.mockRestore(); ++ }); ++}); ++ +diff --git a/apps/backend/test/integration/rag-document-factory.spec.ts b/apps/backend/test/integration/rag-document-factory.spec.ts +new file mode 100644 +index 0000000..1fdd0e7 +--- /dev/null ++++ b/apps/backend/test/integration/rag-document-factory.spec.ts +@@ -0,0 +1,101 @@ ++import { DomainError } from "../../src/common/domain-error"; ++import { RagDocumentFactory } from "../../src/modules/rag/ingestion/rag-document.factory"; ++import type { IngestionSourceInput } from "../../src/modules/rag/ingestion/ingestion-source.adapter"; ++ ++describe("RagDocumentFactory integration", () => { ++ const factory = new RagDocumentFactory(); ++ ++ it("maps schema/sql_example/semantic_term sources into normalized document + chunks", () => { ++ const schemaResult = factory.create({ ++ sourceType: "schema", ++ datasourceId: "ds-main", ++ sourceVersion: "schema-v1", ++ contentChecksum: "schema-checksum", ++ tableName: "orders", ++ columnName: "amount", ++ ddl: "amount DECIMAL(10,2) NOT NULL", ++ description: "订单金额列" ++ }); ++ const sqlExampleResult = factory.create({ ++ sourceType: "sql_example", ++ datasourceId: "ds-main", ++ sourceVersion: "sql-v1", ++ contentChecksum: "sql-checksum", ++ exampleId: "ex-1", ++ question: "统计支付订单总额", ++ sql: "SELECT SUM(amount) FROM orders WHERE status = 'paid'", ++ tableNames: ["orders"], ++ columnNames: ["amount", "status"] ++ }); ++ const semanticTermResult = factory.create({ ++ sourceType: "semantic_term", ++ datasourceId: "ds-main", ++ sourceVersion: "term-v1", ++ contentChecksum: "term-checksum", ++ term: "GMV", ++ definition: "一定周期内成交总额", ++ synonyms: ["交易额", "销售额"], ++ tableNames: ["orders"], ++ columnNames: ["amount"] ++ }); ++ ++ expect(schemaResult.document.domain).toBe("schema"); ++ expect(schemaResult.chunks[0]?.chunkProfile).toBe("schema_column"); ++ expect(sqlExampleResult.document.domain).toBe("sql_example"); ++ expect(sqlExampleResult.chunks[0]?.chunkProfile).toBe("sql_example"); ++ expect(semanticTermResult.document.domain).toBe("semantic_term"); ++ expect(semanticTermResult.chunks[0]?.chunkProfile).toBe("semantic_term"); ++ ++ expect(sqlExampleResult.document.tableNames).toEqual(["orders"]); ++ expect(sqlExampleResult.document.columnNames).toEqual(["amount", "status"]); ++ expect(schemaResult.chunks.length).toBeGreaterThan(0); ++ expect(sqlExampleResult.chunks.length).toBeGreaterThan(0); ++ expect(semanticTermResult.chunks.length).toBeGreaterThan(0); ++ }); ++ ++ it("rejects inputs missing source_version or content_checksum", () => { ++ const missingSourceVersion: IngestionSourceInput = { ++ sourceType: "sql_example", ++ datasourceId: "ds-main", ++ sourceVersion: "", ++ contentChecksum: "sql-checksum", ++ question: "q", ++ sql: "SELECT 1" ++ }; ++ const missingContentChecksum: IngestionSourceInput = { ++ sourceType: "semantic_term", ++ datasourceId: "ds-main", ++ sourceVersion: "term-v1", ++ contentChecksum: "", ++ term: "ROI", ++ definition: "投资回报率" ++ }; ++ ++ expect(() => factory.create(missingSourceVersion)).toThrow(DomainError); ++ expect(() => factory.create(missingContentChecksum)).toThrow(DomainError); ++ }); ++ ++ it("produces stable chunk id ordering for the same input", () => { ++ const input: IngestionSourceInput = { ++ sourceType: "sql_example", ++ datasourceId: "ds-main", ++ sourceVersion: "sql-v2", ++ contentChecksum: "sql-checksum-long", ++ question: "列出分区销售额", ++ sql: Array.from( ++ { length: 220 }, ++ (_, index) => `SELECT region, SUM(amount) AS gmv_${index} FROM orders GROUP BY region` ++ ).join("\n"), ++ tableNames: ["orders"], ++ columnNames: ["region", "amount"] ++ }; ++ ++ const first = factory.create(input); ++ const second = factory.create(input); ++ ++ expect(first.chunks.map((item) => item.id)).toEqual(second.chunks.map((item) => item.id)); ++ expect(first.chunks.map((item) => item.chunkOrder)).toEqual( ++ second.chunks.map((item) => item.chunkOrder) ++ ); ++ }); ++}); +diff --git a/apps/backend/test/integration/rag-foundation-constraints.spec.ts b/apps/backend/test/integration/rag-foundation-constraints.spec.ts +new file mode 100644 +index 0000000..2751e8f +--- /dev/null ++++ b/apps/backend/test/integration/rag-foundation-constraints.spec.ts +@@ -0,0 +1,73 @@ ++import { readdirSync, readFileSync } from "node:fs"; ++import { resolve } from "node:path"; ++ ++const readRagFoundationMigrationSql = () => { ++ const migrationsDir = resolve(__dirname, "../../prisma/migrations"); ++ const target = readdirSync(migrationsDir, { withFileTypes: true }) ++ .filter((entry) => entry.isDirectory()) ++ .map((entry) => entry.name) ++ .find((name) => name.endsWith("_r2_rag_foundation")); ++ ++ if (!target) { ++ throw new Error("Expected *_r2_rag_foundation migration directory"); ++ } ++ ++ const migrationPath = resolve(migrationsDir, target, "migration.sql"); ++ return readFileSync(migrationPath, "utf-8"); ++}; ++ ++describe("rag foundation constraints", () => { ++ it("enforces idempotency and chunk/index uniqueness constraints", () => { ++ const sql = readRagFoundationMigrationSql(); ++ ++ expect(sql).toContain( ++ 'CREATE UNIQUE INDEX "rag_documents_datasourceId_sourceVersion_contentChecksum_key"' ++ ); ++ expect(sql).toContain( ++ 'CREATE UNIQUE INDEX "rag_chunks_documentId_chunkProfile_chunkOrder_key"' ++ ); ++ expect(sql).toContain( ++ 'CREATE UNIQUE INDEX "rag_chunk_index_entries_indexVersionId_chunkId_key"' ++ ); ++ }); ++ ++ it("declares expected foreign keys for run/document/chunk/index lineage", () => { ++ const sql = readRagFoundationMigrationSql(); ++ ++ expect(sql).toContain( ++ 'ADD CONSTRAINT "rag_chunks_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "rag_documents"("id")' ++ ); ++ expect(sql).toContain( ++ 'ADD CONSTRAINT "rag_chunk_index_entries_chunkId_fkey" FOREIGN KEY ("chunkId") REFERENCES "rag_chunks"("id")' ++ ); ++ expect(sql).toContain( ++ 'ADD CONSTRAINT "rag_chunk_index_entries_indexVersionId_fkey" FOREIGN KEY ("indexVersionId") REFERENCES "rag_index_versions"("id")' ++ ); ++ expect(sql).toContain( ++ 'ADD CONSTRAINT "rag_run_replays_runId_fkey" FOREIGN KEY ("runId") REFERENCES "sql_runs"("runId")' ++ ); ++ }); ++ ++ it("indexes datasource/domain/tableNames/columnNames retrieval metadata fields", () => { ++ const sql = readRagFoundationMigrationSql(); ++ ++ expect(sql).toContain( ++ 'CREATE INDEX "rag_documents_datasourceId_domain_idx" ON "rag_documents"("datasourceId", "domain")' ++ ); ++ expect(sql).toContain( ++ 'CREATE INDEX "rag_documents_tableNames_idx" ON "rag_documents"("tableNames")' ++ ); ++ expect(sql).toContain( ++ 'CREATE INDEX "rag_documents_columnNames_idx" ON "rag_documents"("columnNames")' ++ ); ++ expect(sql).toContain( ++ 'CREATE INDEX "rag_chunks_datasourceId_domain_chunkProfile_idx" ON "rag_chunks"("datasourceId", "domain", "chunkProfile")' ++ ); ++ expect(sql).toContain( ++ 'CREATE INDEX "rag_chunks_tableNames_idx" ON "rag_chunks"("tableNames")' ++ ); ++ expect(sql).toContain( ++ 'CREATE INDEX "rag_chunks_columnNames_idx" ON "rag_chunks"("columnNames")' ++ ); ++ }); ++}); +diff --git a/apps/backend/test/integration/rag-foundation-observability.spec.ts b/apps/backend/test/integration/rag-foundation-observability.spec.ts +new file mode 100644 +index 0000000..826aa98 +--- /dev/null ++++ b/apps/backend/test/integration/rag-foundation-observability.spec.ts +@@ -0,0 +1,173 @@ ++import { resolve } from "node:path"; ++import type { Request } from "express"; ++import { Test } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { ++ RagIngestionMetricsService, ++ type RagIngestionMetricsSnapshot ++} from "../../src/modules/rag/observability/rag-ingestion-metrics.service"; ++import { BuildRagIndexJob } from "../../src/modules/rag/jobs/build-rag-index.job"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++import { HealthController } from "../../src/modules/system/health.controller"; ++ ++type HealthPayload = { ++ dependencies: { ++ ragIngestionMetrics: { ++ foundation: RagIngestionMetricsSnapshot; ++ }; ++ }; ++}; ++ ++describe("rag foundation observability integration", () => { ++ beforeAll(() => { ++ process.env.SQLITE_PATH = resolve( ++ __dirname, ++ "../../../../data/sqlite/text2sql.db" ++ ); ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ }); ++ ++ it("returns degraded reason when active index is missing", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ ++ const controller = moduleRef.get(HealthController); ++ const ingestionMetrics = moduleRef.get(RagIngestionMetricsService); ++ ingestionMetrics.reset(); ++ const response = await controller.health({ ++ requestId: "req-rag-foundation-observability-empty" ++ } as unknown as Request); ++ expect(response.status).toBe("success"); ++ if (response.status !== "success") { ++ throw new Error("health endpoint returned unexpected error response"); ++ } ++ ++ const payload = response.data as HealthPayload; ++ const summary = payload.dependencies.ragIngestionMetrics.foundation; ++ expect(summary.observedBuilds).toBe(0); ++ expect(summary.activeIndexSummary.total).toBe(0); ++ expect(summary.degradedReason).toBe("no_active_index"); ++ ++ await moduleRef.close(); ++ }); ++ ++ it("aggregates active index summary, build counters/rate and activation latency stats", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ ++ const controller = moduleRef.get(HealthController); ++ const ingestionMetrics = moduleRef.get(RagIngestionMetricsService); ++ ingestionMetrics.reset(); ++ ingestionMetrics.recordBuild({ ++ datasourceId: "ds-rag-foundation-a", ++ status: "failure", ++ failureReason: "activation_timeout" ++ }); ++ ingestionMetrics.recordBuild({ ++ datasourceId: "ds-rag-foundation-a", ++ status: "success", ++ indexVersionId: "idx-rag-foundation-a-v2", ++ sourceVersion: "source-v2", ++ activationLatencyMs: 120 ++ }); ++ ingestionMetrics.recordBuild({ ++ datasourceId: "ds-rag-foundation-b", ++ status: "success", ++ indexVersionId: "idx-rag-foundation-b-v3", ++ sourceVersion: "source-v3", ++ activationLatencyMs: 320 ++ }); ++ const response = await controller.health({ ++ requestId: "req-rag-foundation-observability-aggregated" ++ } as unknown as Request); ++ expect(response.status).toBe("success"); ++ if (response.status !== "success") { ++ throw new Error("health endpoint returned unexpected error response"); ++ } ++ ++ const payload = response.data as HealthPayload; ++ const summary = payload.dependencies.ragIngestionMetrics.foundation; ++ expect(summary.observedBuilds).toBe(3); ++ expect(summary.buildSuccessCount).toBe(2); ++ expect(summary.buildFailureCount).toBe(1); ++ expect(summary.buildSuccessRate).toBeCloseTo(2 / 3); ++ expect(summary.buildFailureRate).toBeCloseTo(1 / 3); ++ expect(summary.failureReasons.activation_timeout).toBe(1); ++ expect(summary.activationLatencyMs.count).toBe(2); ++ expect(summary.activationLatencyMs.min).toBe(120); ++ expect(summary.activationLatencyMs.max).toBe(320); ++ expect(summary.activationLatencyMs.p50).toBe(120); ++ expect(summary.activationLatencyMs.p95).toBe(320); ++ expect(summary.activeIndexSummary.total).toBe(2); ++ expect(summary.activeIndexSummary.items).toEqual( ++ expect.arrayContaining([ ++ expect.objectContaining({ ++ datasourceId: "ds-rag-foundation-a", ++ indexVersionId: "idx-rag-foundation-a-v2", ++ sourceVersion: "source-v2" ++ }), ++ expect.objectContaining({ ++ datasourceId: "ds-rag-foundation-b", ++ indexVersionId: "idx-rag-foundation-b-v3", ++ sourceVersion: "source-v3" ++ }) ++ ]) ++ ); ++ expect(summary.degradedReason).toBeUndefined(); ++ ++ await moduleRef.close(); ++ }); ++ ++ it("updates health summary from real build job events", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ ++ const controller = moduleRef.get(HealthController); ++ const ingestionMetrics = moduleRef.get(RagIngestionMetricsService); ++ const indexRepository = moduleRef.get(RagIndexRepository); ++ const buildJob = moduleRef.get(BuildRagIndexJob); ++ ingestionMetrics.reset(); ++ ++ indexRepository.seedChunksForDatasource("ds-rag-observability-job", [ ++ { ++ id: "chunk-observability-job-1", ++ datasourceId: "ds-rag-observability-job", ++ domain: "schema", ++ content: "table users(id, email)" ++ } ++ ]); ++ ++ await buildJob.run({ ++ datasourceId: "ds-rag-observability-job", ++ sourceVersion: "source-observability-v1", ++ runId: "run-rag-observability-job" ++ }); ++ ++ const response = await controller.health({ ++ requestId: "req-rag-foundation-observability-from-job" ++ } as unknown as Request); ++ expect(response.status).toBe("success"); ++ if (response.status !== "success") { ++ throw new Error("health endpoint returned unexpected error response"); ++ } ++ const payload = response.data as HealthPayload; ++ const summary = payload.dependencies.ragIngestionMetrics.foundation; ++ ++ expect(summary.observedBuilds).toBe(1); ++ expect(summary.buildSuccessCount).toBe(1); ++ expect(summary.buildFailureCount).toBe(0); ++ expect(summary.activeIndexSummary.total).toBe(1); ++ expect(summary.activeIndexSummary.items[0]?.datasourceId).toBe( ++ "ds-rag-observability-job" ++ ); ++ expect(summary.degradedReason).toBeUndefined(); ++ ++ await moduleRef.close(); ++ }); ++}); +diff --git a/apps/backend/test/integration/rag-foundation-replay.spec.ts b/apps/backend/test/integration/rag-foundation-replay.spec.ts +new file mode 100644 +index 0000000..68cdb8d +--- /dev/null ++++ b/apps/backend/test/integration/rag-foundation-replay.spec.ts +@@ -0,0 +1,189 @@ ++import { resolve } from "node:path"; ++import { Test } from "@nestjs/testing"; ++import { AppConfigModule } from "../../src/modules/config/config.module"; ++import { AppModule } from "../../src/app.module"; ++import { BuildRagIndexJob } from "../../src/modules/rag/jobs/build-rag-index.job"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++import { RagReplayRepository } from "../../src/modules/rag/observability/rag-replay.repository"; ++ ++describe("rag foundation replay repository", () => { ++ beforeAll(() => { ++ process.env.SQLITE_PATH = resolve( ++ __dirname, ++ "../../../../data/sqlite/text2sql.db" ++ ); ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ }); ++ ++ it("writes replay events and reconstructs run-level index/document/chunk lineage", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppConfigModule], ++ providers: [RagReplayRepository] ++ }).compile(); ++ const repository = moduleRef.get(RagReplayRepository); ++ ++ const runId = "run-rag-replay-1"; ++ const datasourceId = "ds-rag-replay"; ++ const indexVersionId = "idx-rag-v1"; ++ const documentId = "doc-orders-v1"; ++ const chunkId = "chunk-orders-v1-001"; ++ ++ await repository.writeReplay({ ++ runId, ++ replayKey: "index:build:completed", ++ datasourceId, ++ stage: "index_build_completed", ++ indexVersionId, ++ payload: { ++ sourceVersion: "source-v1", ++ chunkCount: 32 ++ }, ++ createdAt: "2026-04-17T00:00:01.000Z" ++ }); ++ await repository.writeReplay({ ++ runId, ++ replayKey: "document:indexed:orders", ++ datasourceId, ++ stage: "document_indexed", ++ indexVersionId, ++ documentId, ++ payload: { ++ domain: "schema", ++ tableNames: ["orders"] ++ }, ++ createdAt: "2026-04-17T00:00:02.000Z" ++ }); ++ await repository.writeReplay({ ++ runId, ++ replayKey: "chunk:indexed:orders:001", ++ datasourceId, ++ stage: "chunk_indexed", ++ indexVersionId, ++ documentId, ++ chunkId, ++ payload: { ++ chunkOrder: 1, ++ lexicalMode: "plain_text" ++ }, ++ createdAt: "2026-04-17T00:00:03.000Z" ++ }); ++ ++ const replayEvents = await repository.listByRunId(runId); ++ ++ expect(replayEvents).toHaveLength(3); ++ expect(replayEvents.map((item) => item.replayKey)).toEqual([ ++ "index:build:completed", ++ "document:indexed:orders", ++ "chunk:indexed:orders:001" ++ ]); ++ ++ const eventByKey = new Map(replayEvents.map((item) => [item.replayKey, item])); ++ expect(eventByKey.get("index:build:completed")?.indexVersionId).toBe(indexVersionId); ++ expect(eventByKey.get("document:indexed:orders")?.documentId).toBe(documentId); ++ expect(eventByKey.get("chunk:indexed:orders:001")?.chunkId).toBe(chunkId); ++ ++ const chunkPayload = JSON.parse( ++ eventByKey.get("chunk:indexed:orders:001")?.payload ?? "{}" ++ ) as Record; ++ expect(chunkPayload.chunkOrder).toBe(1); ++ expect(chunkPayload.lexicalMode).toBe("plain_text"); ++ ++ await moduleRef.close(); ++ }); ++ ++ it("upserts same replay key and keeps latest payload for the run", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppConfigModule], ++ providers: [RagReplayRepository] ++ }).compile(); ++ const repository = moduleRef.get(RagReplayRepository); ++ ++ await repository.writeReplay({ ++ runId: "run-rag-replay-upsert", ++ replayKey: "index:build:completed", ++ datasourceId: "ds-rag-replay", ++ stage: "index_build_completed", ++ indexVersionId: "idx-rag-v1", ++ payload: { ++ chunkCount: 10, ++ attempt: 1 ++ }, ++ createdAt: "2026-04-17T00:10:00.000Z" ++ }); ++ await repository.writeReplay({ ++ runId: "run-rag-replay-upsert", ++ replayKey: "index:build:completed", ++ datasourceId: "ds-rag-replay", ++ stage: "index_build_completed", ++ indexVersionId: "idx-rag-v2", ++ payload: { ++ chunkCount: 12, ++ attempt: 2 ++ }, ++ createdAt: "2026-04-17T00:10:01.000Z" ++ }); ++ ++ const replayEvents = await repository.listByRunId("run-rag-replay-upsert"); ++ ++ expect(replayEvents).toHaveLength(1); ++ expect(replayEvents[0]?.indexVersionId).toBe("idx-rag-v2"); ++ expect(JSON.parse(replayEvents[0]?.payload ?? "{}")).toMatchObject({ ++ chunkCount: 12, ++ attempt: 2 ++ }); ++ ++ const single = await repository.getReplay( ++ "run-rag-replay-upsert", ++ "index:build:completed" ++ ); ++ expect(single?.indexVersionId).toBe("idx-rag-v2"); ++ ++ await moduleRef.close(); ++ }); ++ ++ it("writes build + chunk replay events from the real index build job", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ const indexRepository = moduleRef.get(RagIndexRepository); ++ const buildJob = moduleRef.get(BuildRagIndexJob); ++ const replayRepository = moduleRef.get(RagReplayRepository); ++ ++ indexRepository.seedChunksForDatasource("ds-rag-replay-job", [ ++ { ++ id: "chunk-replay-job-1", ++ datasourceId: "ds-rag-replay-job", ++ domain: "schema", ++ content: "table orders(id, amount, status)" ++ }, ++ { ++ id: "chunk-replay-job-2", ++ datasourceId: "ds-rag-replay-job", ++ domain: "sql_example", ++ content: "SELECT SUM(amount) FROM orders WHERE status = 'paid'" ++ } ++ ]); ++ ++ const result = await buildJob.run({ ++ datasourceId: "ds-rag-replay-job", ++ sourceVersion: "source-replay-job-v1", ++ runId: "run-rag-replay-job" ++ }); ++ ++ const replayEvents = await replayRepository.listByRunId("run-rag-replay-job"); ++ expect(result.status).toBe("active"); ++ expect(replayEvents.some((item) => item.stage === "index_build_completed")).toBe(true); ++ expect(replayEvents.filter((item) => item.stage === "chunk_indexed").length).toBe(2); ++ expect( ++ replayEvents.some((item) => item.replayKey === "chunk:indexed:chunk-replay-job-1") ++ ).toBe(true); ++ expect( ++ replayEvents.some((item) => item.replayKey === "chunk:indexed:chunk-replay-job-2") ++ ).toBe(true); ++ ++ await moduleRef.close(); ++ }); ++}); +diff --git a/apps/backend/test/integration/rag-foundation-schema.spec.ts b/apps/backend/test/integration/rag-foundation-schema.spec.ts +new file mode 100644 +index 0000000..e3b05b8 +--- /dev/null ++++ b/apps/backend/test/integration/rag-foundation-schema.spec.ts +@@ -0,0 +1,42 @@ ++import { readdirSync, readFileSync } from "node:fs"; ++import { resolve } from "node:path"; ++ ++const findRagFoundationMigration = () => { ++ const migrationsDir = resolve(__dirname, "../../prisma/migrations"); ++ const target = readdirSync(migrationsDir, { withFileTypes: true }) ++ .filter((entry) => entry.isDirectory()) ++ .map((entry) => entry.name) ++ .find((name) => name.endsWith("_r2_rag_foundation")); ++ ++ if (!target) { ++ throw new Error("Expected *_r2_rag_foundation migration directory"); ++ } ++ ++ return resolve(migrationsDir, target, "migration.sql"); ++}; ++ ++describe("rag foundation schema baseline", () => { ++ it("defines rag foundation models in prisma schema", () => { ++ const schemaPath = resolve(__dirname, "../../prisma/schema.prisma"); ++ const schema = readFileSync(schemaPath, "utf-8"); ++ ++ expect(schema).toContain("model RagDocument"); ++ expect(schema).toContain("model RagChunk"); ++ expect(schema).toContain("model RagIndexVersion"); ++ expect(schema).toContain("model RagChunkIndexEntry"); ++ expect(schema).toContain("model RagRunReplay"); ++ }); ++ ++ it("creates rag foundation tables and replay primary key in migration", () => { ++ const migrationSql = readFileSync(findRagFoundationMigration(), "utf-8"); ++ ++ expect(migrationSql).toContain('CREATE TABLE "rag_documents"'); ++ expect(migrationSql).toContain('CREATE TABLE "rag_chunks"'); ++ expect(migrationSql).toContain('CREATE TABLE "rag_index_versions"'); ++ expect(migrationSql).toContain('CREATE TABLE "rag_chunk_index_entries"'); ++ expect(migrationSql).toContain('CREATE TABLE "rag_run_replays"'); ++ expect(migrationSql).toContain( ++ 'CONSTRAINT "rag_run_replays_pkey" PRIMARY KEY ("runId","replayKey")' ++ ); ++ }); ++}); +diff --git a/apps/backend/test/integration/rag-incremental-refresh.spec.ts b/apps/backend/test/integration/rag-incremental-refresh.spec.ts +new file mode 100644 +index 0000000..8f622ca +--- /dev/null ++++ b/apps/backend/test/integration/rag-incremental-refresh.spec.ts +@@ -0,0 +1,262 @@ ++import { Test, type TestingModule } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { RagEventConsumerService } from "../../src/modules/rag/events/rag-event-consumer.service"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++import { RagReplayRepository } from "../../src/modules/rag/observability/rag-replay.repository"; ++import { RagRerankService } from "../../src/modules/rag/rerank/rag-rerank.service"; ++import { RagRetrievalService } from "../../src/modules/rag/retrieval/rag-retrieval.service"; ++import { SemanticRegistryService } from "../../src/modules/semantic-registry/semantic-registry.service"; ++import { createSeededSqliteFixture } from "../support/sqlite-fixture"; ++ ++describe("rag incremental refresh integration", () => { ++ let moduleRef: TestingModule; ++ let eventConsumer: RagEventConsumerService; ++ let indexRepository: RagIndexRepository; ++ let replayRepository: RagReplayRepository; ++ let retrievalService: RagRetrievalService; ++ let rerankService: RagRerankService; ++ let semanticRegistryService: SemanticRegistryService; ++ let cleanupFixture: (() => Promise) | undefined; ++ ++ beforeEach(async () => { ++ const fixture = await createSeededSqliteFixture("rag-incremental-refresh"); ++ cleanupFixture = fixture.cleanup; ++ process.env.SQLITE_PATH = fixture.dbPath; ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ ++ moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ ++ eventConsumer = moduleRef.get(RagEventConsumerService, { ++ strict: false ++ }); ++ indexRepository = moduleRef.get(RagIndexRepository, { ++ strict: false ++ }); ++ replayRepository = moduleRef.get(RagReplayRepository, { ++ strict: false ++ }); ++ retrievalService = moduleRef.get(RagRetrievalService, { ++ strict: false ++ }); ++ rerankService = moduleRef.get(RagRerankService, { ++ strict: false ++ }); ++ semanticRegistryService = moduleRef.get(SemanticRegistryService, { ++ strict: false ++ }); ++ }); ++ ++ afterEach(async () => { ++ await moduleRef.close(); ++ if (cleanupFixture) { ++ await cleanupFixture(); ++ } ++ }); ++ ++ it("builds and activates a new index version from an incremental event", async () => { ++ indexRepository.seedChunksForDatasource("ds-rag-incremental", [ ++ { ++ id: "chunk-incremental-1", ++ datasourceId: "ds-rag-incremental", ++ domain: "schema", ++ content: "table orders(id, amount, status)" ++ }, ++ { ++ id: "chunk-incremental-2", ++ datasourceId: "ds-rag-incremental", ++ domain: "semantic_term", ++ content: "GMV means gross merchandise volume" ++ } ++ ]); ++ ++ const result = await eventConsumer.consumeEvent({ ++ eventId: "evt-incremental-1", ++ datasourceId: "ds-rag-incremental", ++ sourceVersion: "schema-v1", ++ eventType: "ddl_changed", ++ runId: "run-rag-incremental-1", ++ sessionId: "session-rag-incremental-1", ++ occurredAt: "2026-04-18T02:00:00.000Z" ++ }); ++ ++ expect(result.status).toBe("processed"); ++ expect(result.indexVersionId).toBeTruthy(); ++ ++ const activeVersion = await indexRepository.getActiveVersion("ds-rag-incremental"); ++ expect(activeVersion?.id).toBe(result.indexVersionId); ++ ++ const replayRows = await replayRepository.listByRunId("run-rag-incremental-1"); ++ expect(replayRows.some((row) => row.stage === "incremental_event_received")).toBe(true); ++ expect(replayRows.some((row) => row.stage === "incremental_event_processed")).toBe(true); ++ expect(replayRows.some((row) => row.stage === "index_build_completed")).toBe(true); ++ expect(replayRows.filter((row) => row.stage === "chunk_indexed")).toHaveLength(2); ++ ++ const duplicate = await eventConsumer.consumeEvent({ ++ eventId: "evt-incremental-1", ++ datasourceId: "ds-rag-incremental", ++ sourceVersion: "schema-v1", ++ eventType: "ddl_changed", ++ runId: "run-rag-incremental-1" ++ }); ++ expect(duplicate.status).toBe("duplicate"); ++ ++ const replayAfterDuplicate = await replayRepository.listByRunId("run-rag-incremental-1"); ++ expect( ++ replayAfterDuplicate.filter((row) => ++ row.replayKey.startsWith("incremental:event:received:evt-incremental-1") ++ ) ++ ).toHaveLength(1); ++ }); ++ ++ it("promotes glossary semantic payload with winner selection and surfaces selected_context hit clues", async () => { ++ const datasourceId = "ds-rag-semantic-promoted"; ++ indexRepository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-semantic-promoted-schema", ++ datasourceId, ++ domain: "schema", ++ content: "table orders(id, amount, status)" ++ }, ++ { ++ id: "chunk-semantic-promoted-sql", ++ datasourceId, ++ domain: "sql_example", ++ content: "SELECT SUM(amount) AS gmv FROM orders" ++ } ++ ]); ++ ++ const consume = await eventConsumer.consumeEvent({ ++ eventId: "evt-semantic-promoted-1", ++ datasourceId, ++ sourceVersion: "glossary-v1", ++ eventType: "semantic_promoted", ++ runId: "run-semantic-promoted-1", ++ requestId: "req-glossary-semantic-promoted-1", ++ payload: { ++ linkageStatus: "success", ++ glossaryTerms: [ ++ { ++ id: "gterm-global-1", ++ term: "GMV", ++ definition: "global gmv", ++ scope: "global", ++ priority: 60, ++ updatedAt: "2026-04-18T02:00:00.000Z" ++ }, ++ { ++ id: "gterm-ds-winner", ++ term: "GMV", ++ definition: "datasource gmv winner", ++ scope: "datasource", ++ datasourceId, ++ priority: 90, ++ updatedAt: "2026-04-18T02:01:00.000Z" ++ }, ++ { ++ id: "gterm-ds-loser", ++ term: "GMV", ++ definition: "datasource gmv loser despite newer timestamp", ++ scope: "datasource", ++ datasourceId, ++ priority: 80, ++ updatedAt: "2026-04-18T02:02:00.000Z" ++ } ++ ] ++ } ++ }); ++ ++ expect(consume.status).toBe("processed"); ++ expect(consume.indexVersionId).toBeTruthy(); ++ ++ const activeVersion = await indexRepository.getActiveVersion(datasourceId); ++ expect(activeVersion?.id).toBe(consume.indexVersionId); ++ ++ const entries = await indexRepository.listEntriesByVersion(consume.indexVersionId as string); ++ const semanticEntries = entries.filter((item) => item.domain === "semantic_term"); ++ expect(semanticEntries.length).toBeGreaterThan(0); ++ expect( ++ semanticEntries.some((item) => item.lexicalContent.includes("datasource gmv winner")) ++ ).toBe(true); ++ expect( ++ semanticEntries.some((item) => ++ item.lexicalContent.includes("datasource gmv loser despite newer timestamp") ++ ) ++ ).toBe(false); ++ ++ const retrieval = await retrievalService.retrieve({ ++ query: "GMV", ++ datasourceId, ++ runId: "run-semantic-promoted-retrieval-1" ++ }); ++ const reranked = await rerankService.rerank({ ++ retrievalBundle: retrieval.retrieval_bundle, ++ secondaryEnabled: false ++ }); ++ const semanticSelectedContext = reranked.retrieval_bundle.selected_context?.find( ++ (item) => item.metadata.domain === "semantic_term" ++ ); ++ ++ expect(semanticSelectedContext).toBeDefined(); ++ expect( ++ semanticSelectedContext?.metadata.sourceMetadata as { ++ semanticHitClues?: string[]; ++ } ++ ).toEqual( ++ expect.objectContaining({ ++ semanticHitClues: expect.arrayContaining(["semantic_hit:glossary"]) ++ }) ++ ); ++ ++ const semanticResolved = await semanticRegistryService.resolveTerm({ ++ domain: "semantic_term", ++ datasourceId, ++ term: "gmv" ++ }); ++ expect(semanticResolved.status).toBe("ready"); ++ expect(semanticResolved.matched_scope).toBe("datasource"); ++ }); ++ ++ it("retries failed incremental refresh and sends event to DLQ after max attempts", async () => { ++ const runId = "run-rag-incremental-dlq"; ++ ++ const first = await eventConsumer.consumeEvent({ ++ eventId: "evt-incremental-dlq-1", ++ datasourceId: "ds-rag-incremental-dlq", ++ sourceVersion: "schema-v2", ++ eventType: "semantic_promoted", ++ runId ++ }); ++ const second = await eventConsumer.consumeEvent({ ++ eventId: "evt-incremental-dlq-1", ++ datasourceId: "ds-rag-incremental-dlq", ++ sourceVersion: "schema-v2", ++ eventType: "semantic_promoted", ++ runId ++ }); ++ const third = await eventConsumer.consumeEvent({ ++ eventId: "evt-incremental-dlq-1", ++ datasourceId: "ds-rag-incremental-dlq", ++ sourceVersion: "schema-v2", ++ eventType: "semantic_promoted", ++ runId ++ }); ++ ++ expect(first.status).toBe("retry_scheduled"); ++ expect(second.status).toBe("retry_scheduled"); ++ expect(third.status).toBe("dlq"); ++ expect(first.failureReason).toBe("semantic_promoted_linkage_failed"); ++ expect(second.failureReason).toBe("semantic_promoted_linkage_failed"); ++ expect(third.failureReason).toBe("semantic_promoted_linkage_failed"); ++ ++ const replayRows = await replayRepository.listByRunId(runId); ++ expect(replayRows.filter((row) => row.stage === "incremental_event_failed")).toHaveLength(3); ++ ++ const activeVersion = await indexRepository.getActiveVersion("ds-rag-incremental-dlq"); ++ expect(activeVersion).toBeUndefined(); ++ }); ++}); +diff --git a/apps/backend/test/integration/rag-index-activation.spec.ts b/apps/backend/test/integration/rag-index-activation.spec.ts +new file mode 100644 +index 0000000..1724f09 +--- /dev/null ++++ b/apps/backend/test/integration/rag-index-activation.spec.ts +@@ -0,0 +1,140 @@ ++import { createHash } from "node:crypto"; ++import { resolve } from "node:path"; ++import { Test } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { RagIndexBuilderService } from "../../src/modules/rag/index/rag-index-builder.service"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++ ++describe("rag index activation integration", () => { ++ beforeAll(() => { ++ process.env.SQLITE_PATH = resolve( ++ __dirname, ++ "../../../../data/sqlite/text2sql.db" ++ ); ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ }); ++ ++ it("keeps previous active readable when activation fails in the same transaction window", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ ++ const repository = moduleRef.get(RagIndexRepository); ++ const builder = moduleRef.get(RagIndexBuilderService); ++ const datasourceId = "ds-rag-index-rollback"; ++ ++ repository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-base-1", ++ datasourceId, ++ domain: "schema", ++ content: "table users(id, email)" ++ } ++ ]); ++ const baseline = await builder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-v1", ++ createdByRunId: "run-v1", ++ activatedByRunId: "run-v1" ++ }); ++ const activeBefore = await repository.getActiveVersion(datasourceId); ++ expect(activeBefore?.id).toBe(baseline.indexVersionId); ++ ++ const candidate = await repository.createBuildingVersion({ ++ datasourceId, ++ sourceVersion: "source-v2", ++ buildReason: "candidate_build", ++ createdByRunId: "run-v2" ++ }); ++ expect(candidate.status).toBe("building"); ++ ++ const now = new Date().toISOString(); ++ await repository.replaceEntriesForVersion(candidate.id, [ ++ { ++ id: createHash("sha256") ++ .update(`${candidate.id}|chunk-base-1|candidate`) ++ .digest("hex"), ++ indexVersionId: candidate.id, ++ chunkId: "chunk-base-1", ++ datasourceId, ++ domain: "schema", ++ lexicalContent: "table users(id, email, deleted_at)", ++ denseVector: JSON.stringify([0.1, 0.2, 0.3, 0.4]), ++ createdAt: now, ++ updatedAt: now ++ } ++ ]); ++ const ready = await repository.markVersionReady(candidate.id); ++ expect(ready.status).toBe("ready"); ++ ++ await expect( ++ repository.activateVersion({ ++ datasourceId, ++ indexVersionId: candidate.id, ++ activatedByRunId: "run-v2", ++ simulateFailure: "after_deprecating_current_active" ++ }) ++ ).rejects.toThrow("simulated activation failure"); ++ ++ const activeAfterFailure = await repository.getActiveVersion(datasourceId); ++ const candidateAfterFailure = await repository.getVersionById(candidate.id); ++ expect(activeAfterFailure?.id).toBe(activeBefore?.id); ++ expect(candidateAfterFailure?.status).toBe("ready"); ++ ++ await moduleRef.close(); ++ }); ++ ++ it("allows concurrent builds but only one version ends as active", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ ++ const repository = moduleRef.get(RagIndexRepository); ++ const builder = moduleRef.get(RagIndexBuilderService); ++ const datasourceId = "ds-rag-index-concurrency"; ++ ++ repository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-concurrency-1", ++ datasourceId, ++ domain: "sql_example", ++ content: "SELECT COUNT(*) FROM orders" ++ } ++ ]); ++ ++ await Promise.all([ ++ builder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-v1", ++ buildReason: "concurrent-build-1", ++ createdByRunId: "run-concurrency-1", ++ activatedByRunId: "run-concurrency-1" ++ }), ++ builder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-v2", ++ buildReason: "concurrent-build-2", ++ createdByRunId: "run-concurrency-2", ++ activatedByRunId: "run-concurrency-2" ++ }) ++ ]); ++ ++ const versions = await repository.listVersionsByDatasource(datasourceId); ++ const active = versions.filter((item) => item.status === "active"); ++ const deprecated = versions.filter((item) => item.status === "deprecated"); ++ ++ expect(versions).toHaveLength(2); ++ expect(active).toHaveLength(1); ++ expect(deprecated.length).toBeGreaterThanOrEqual(1); ++ expect( ++ versions.every((item) => ++ ["building", "ready", "active", "deprecated"].includes(item.status) ++ ) ++ ).toBe(true); ++ ++ await moduleRef.close(); ++ }); ++}); +diff --git a/apps/backend/test/integration/rag-index-builder.spec.ts b/apps/backend/test/integration/rag-index-builder.spec.ts +new file mode 100644 +index 0000000..60913a0 +--- /dev/null ++++ b/apps/backend/test/integration/rag-index-builder.spec.ts +@@ -0,0 +1,68 @@ ++import { resolve } from "node:path"; ++import { Test } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { RagIndexBuilderService } from "../../src/modules/rag/index/rag-index-builder.service"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++ ++describe("rag index builder integration", () => { ++ beforeAll(() => { ++ process.env.SQLITE_PATH = resolve( ++ __dirname, ++ "../../../../data/sqlite/text2sql.db" ++ ); ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ }); ++ ++ it("builds lexical + dense entries in one snapshot and activates the new version", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ ++ const repository = moduleRef.get(RagIndexRepository); ++ const builder = moduleRef.get(RagIndexBuilderService); ++ const datasourceId = "ds-rag-index-builder"; ++ repository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-orders-1", ++ datasourceId, ++ domain: "schema", ++ content: "table orders(id, amount, status)", ++ metadata: JSON.stringify({ table: "orders" }) ++ }, ++ { ++ id: "chunk-sql-1", ++ datasourceId, ++ domain: "sql_example", ++ content: "SELECT SUM(amount) FROM orders WHERE status = 'paid'" ++ } ++ ]); ++ ++ const result = await builder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-v1", ++ buildReason: "initial_build", ++ createdByRunId: "run-build-v1", ++ activatedByRunId: "run-build-v1" ++ }); ++ ++ const version = await repository.getVersionById(result.indexVersionId); ++ const entries = await repository.listEntriesByVersion(result.indexVersionId); ++ ++ expect(result.status).toBe("active"); ++ expect(result.archivedChannels).toEqual(["lexical", "dense"]); ++ expect(result.denseMode).toBe("placeholder_vector_string"); ++ expect(result.entryCount).toBe(2); ++ expect(version?.status).toBe("active"); ++ expect(entries).toHaveLength(2); ++ expect(entries.every((entry) => entry.lexicalContent.length > 0)).toBe(true); ++ expect(entries.every((entry) => typeof entry.denseVector === "string")).toBe(true); ++ const parsedVector = JSON.parse(entries[0]?.denseVector ?? "[]"); ++ expect(Array.isArray(parsedVector)).toBe(true); ++ expect(parsedVector).toHaveLength(8); ++ ++ await moduleRef.close(); ++ }); ++}); +diff --git a/apps/backend/test/integration/rag-multi-datasource-orchestrator.spec.ts b/apps/backend/test/integration/rag-multi-datasource-orchestrator.spec.ts +new file mode 100644 +index 0000000..2ef1161 +--- /dev/null ++++ b/apps/backend/test/integration/rag-multi-datasource-orchestrator.spec.ts +@@ -0,0 +1,131 @@ ++import { Test, type TestingModule } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++import { RagDatasourceOrchestratorService } from "../../src/modules/rag/orchestration/rag-datasource-orchestrator.service"; ++import { createSeededSqliteFixture } from "../support/sqlite-fixture"; ++ ++describe("rag multi datasource orchestrator integration", () => { ++ let moduleRef: TestingModule; ++ let orchestrator: RagDatasourceOrchestratorService; ++ let indexRepository: RagIndexRepository; ++ let cleanupFixture: (() => Promise) | undefined; ++ ++ beforeEach(async () => { ++ const fixture = await createSeededSqliteFixture("rag-orchestrator"); ++ cleanupFixture = fixture.cleanup; ++ process.env.SQLITE_PATH = fixture.dbPath; ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ ++ moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ orchestrator = moduleRef.get(RagDatasourceOrchestratorService, { ++ strict: false ++ }); ++ indexRepository = moduleRef.get(RagIndexRepository, { ++ strict: false ++ }); ++ }); ++ ++ afterEach(async () => { ++ await moduleRef.close(); ++ if (cleanupFixture) { ++ await cleanupFixture(); ++ } ++ }); ++ ++ it("builds multiple datasources in parallel without cross-source entry pollution", async () => { ++ indexRepository.seedChunksForDatasource("ds-r6-a", [ ++ { ++ id: "chunk-r6-a-1", ++ datasourceId: "ds-r6-a", ++ domain: "schema", ++ content: "table orders(id, status)" ++ } ++ ]); ++ indexRepository.seedChunksForDatasource("ds-r6-b", [ ++ { ++ id: "chunk-r6-b-1", ++ datasourceId: "ds-r6-b", ++ domain: "schema", ++ content: "table payments(id, channel)" ++ } ++ ]); ++ indexRepository.seedChunksForDatasource("ds-r6-c", [ ++ { ++ id: "chunk-r6-c-1", ++ datasourceId: "ds-r6-c", ++ domain: "semantic_term", ++ content: "GMV means gross merchandise volume" ++ } ++ ]); ++ ++ const [a, b, c] = await Promise.all([ ++ orchestrator.enqueueBuild({ ++ datasourceId: "ds-r6-a", ++ workspaceId: "workspace-r6-1", ++ sourceVersion: "source-v1" ++ }), ++ orchestrator.enqueueBuild({ ++ datasourceId: "ds-r6-b", ++ workspaceId: "workspace-r6-1", ++ sourceVersion: "source-v1" ++ }), ++ orchestrator.enqueueBuild({ ++ datasourceId: "ds-r6-c", ++ workspaceId: "workspace-r6-2", ++ sourceVersion: "source-v1" ++ }) ++ ]); ++ ++ expect(a.status).toBe("active"); ++ expect(b.status).toBe("active"); ++ expect(c.status).toBe("active"); ++ expect(a.isolationViolation).toBe(false); ++ expect(b.isolationViolation).toBe(false); ++ expect(c.isolationViolation).toBe(false); ++ ++ const aEntries = await indexRepository.listEntriesByVersion(a.indexVersionId); ++ const bEntries = await indexRepository.listEntriesByVersion(b.indexVersionId); ++ const cEntries = await indexRepository.listEntriesByVersion(c.indexVersionId); ++ ++ expect(aEntries.every((item) => item.datasourceId === "ds-r6-a")).toBe(true); ++ expect(bEntries.every((item) => item.datasourceId === "ds-r6-b")).toBe(true); ++ expect(cEntries.every((item) => item.datasourceId === "ds-r6-c")).toBe(true); ++ }); ++ ++ it("serializes concurrent builds for the same datasource", async () => { ++ indexRepository.seedChunksForDatasource("ds-r6-serial", [ ++ { ++ id: "chunk-r6-serial-1", ++ datasourceId: "ds-r6-serial", ++ domain: "schema", ++ content: "table inventory(id, sku, qty)" ++ } ++ ]); ++ ++ const firstPromise = orchestrator.enqueueBuild({ ++ datasourceId: "ds-r6-serial", ++ workspaceId: "workspace-r6-serial", ++ sourceVersion: "source-v1" ++ }); ++ const secondPromise = orchestrator.enqueueBuild({ ++ datasourceId: "ds-r6-serial", ++ workspaceId: "workspace-r6-serial", ++ sourceVersion: "source-v2" ++ }); ++ ++ const [first, second] = await Promise.all([firstPromise, secondPromise]); ++ expect(second.queueWaitMs).toBeGreaterThanOrEqual(0); ++ expect(Date.parse(second.startedAt)).toBeGreaterThanOrEqual(Date.parse(first.startedAt)); ++ ++ const versions = await indexRepository.listVersionsByDatasource("ds-r6-serial"); ++ const active = versions.filter((item) => item.status === "active"); ++ expect(active).toHaveLength(1); ++ expect(versions.length).toBeGreaterThanOrEqual(2); ++ }); ++}); ++ +diff --git a/apps/backend/test/integration/rag-performance-budget.spec.ts b/apps/backend/test/integration/rag-performance-budget.spec.ts +new file mode 100644 +index 0000000..a6c8cca +--- /dev/null ++++ b/apps/backend/test/integration/rag-performance-budget.spec.ts +@@ -0,0 +1,140 @@ ++import { Test, type TestingModule } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { RagIndexBuilderService } from "../../src/modules/rag/index/rag-index-builder.service"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++import { RagRetrievalService } from "../../src/modules/rag/retrieval/rag-retrieval.service"; ++import { RagRerankService } from "../../src/modules/rag/rerank/rag-rerank.service"; ++import { createSeededSqliteFixture } from "../support/sqlite-fixture"; ++ ++describe("rag performance budget integration", () => { ++ let moduleRef: TestingModule; ++ let retrievalService: RagRetrievalService; ++ let rerankService: RagRerankService; ++ let indexBuilder: RagIndexBuilderService; ++ let indexRepository: RagIndexRepository; ++ let cleanupFixture: (() => Promise) | undefined; ++ ++ beforeEach(async () => { ++ const fixture = await createSeededSqliteFixture("rag-performance-budget"); ++ cleanupFixture = fixture.cleanup; ++ process.env.SQLITE_PATH = fixture.dbPath; ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ ++ moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ retrievalService = moduleRef.get(RagRetrievalService, { ++ strict: false ++ }); ++ rerankService = moduleRef.get(RagRerankService, { ++ strict: false ++ }); ++ indexBuilder = moduleRef.get(RagIndexBuilderService, { ++ strict: false ++ }); ++ indexRepository = moduleRef.get(RagIndexRepository, { ++ strict: false ++ }); ++ }); ++ ++ afterEach(async () => { ++ await moduleRef.close(); ++ if (cleanupFixture) { ++ await cleanupFixture(); ++ } ++ }); ++ ++ it("applies budget degrade ladder on retrieval and rerank under pressure", async () => { ++ const datasourceId = "ds-rag-budget"; ++ indexRepository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-budget-schema", ++ datasourceId, ++ domain: "schema", ++ content: "table orders(id, amount, status, paid_at)" ++ }, ++ { ++ id: "chunk-budget-sql", ++ datasourceId, ++ domain: "sql_example", ++ content: "SELECT SUM(amount) FROM orders WHERE status = 'paid'" ++ }, ++ { ++ id: "chunk-budget-semantic", ++ datasourceId, ++ domain: "semantic_term", ++ content: "GMV maps to sum(order amount)" ++ } ++ ]); ++ await indexBuilder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-budget-v1", ++ createdByRunId: "run-budget-build-v1", ++ activatedByRunId: "run-budget-build-v1" ++ }); ++ ++ const retrieval = await retrievalService.retrieve({ ++ query: "orders GMV amount", ++ datasourceId, ++ runId: "run-budget-retrieval-v1", ++ perLaneLimit: 20, ++ finalCandidateLimit: 20, ++ budgetSignal: { ++ costPressure: 0.97, ++ latencyPressure: 0.97, ++ tokenPressure: 0.99 ++ } ++ }); ++ ++ expect(retrieval.retrieval_bundle.decision_reasons).toEqual( ++ expect.arrayContaining([ ++ "budget_degrade_cost_extreme", ++ "budget_degrade_latency_extreme", ++ "budget_degrade_token_extreme", ++ "budget_lane_single_lexical", ++ "degrade_level=maximum" ++ ]) ++ ); ++ expect(retrieval.retrieval_bundle.degrade_reasons).toEqual( ++ expect.arrayContaining([ ++ "budget_degrade_cost_extreme", ++ "budget_degrade_latency_extreme", ++ "budget_degrade_token_extreme", ++ "budget_lane_disabled_dense", ++ "budget_lane_disabled_graph", ++ "degrade_level=maximum" ++ ]) ++ ); ++ expect(retrieval.retrieval_bundle.lane_results.lexical.status).toBe("ok"); ++ expect(retrieval.retrieval_bundle.lane_results.dense.degrade_reason).toBe( ++ "budget_lane_disabled_dense" ++ ); ++ expect(retrieval.retrieval_bundle.lane_results.graph.degrade_reason).toBe( ++ "budget_lane_disabled_graph" ++ ); ++ expect(retrieval.retrieval_bundle.candidates.length).toBeLessThanOrEqual(4); ++ ++ const reranked = await rerankService.rerank({ ++ retrievalBundle: retrieval.retrieval_bundle, ++ budgetSignal: { ++ costPressure: 0.98 ++ } ++ }); ++ ++ expect(reranked.retrieval_bundle.degrade_reasons).toEqual( ++ expect.arrayContaining(["secondary_rerank_disabled_by_budget"]) ++ ); ++ expect(reranked.retrieval_bundle.decision_reasons).toEqual( ++ expect.arrayContaining([ ++ "budget_secondary_rerank_disabled_cost_extreme", ++ "degrade_level=maximum" ++ ]) ++ ); ++ expect( ++ reranked.retrieval_bundle.reranked?.every((item) => item.secondary_score === undefined) ++ ).toBe(true); ++ }); ++}); +diff --git a/apps/backend/test/integration/rag-quality.spec.ts b/apps/backend/test/integration/rag-quality.spec.ts +new file mode 100644 +index 0000000..a149191 +--- /dev/null ++++ b/apps/backend/test/integration/rag-quality.spec.ts +@@ -0,0 +1,106 @@ ++import { resolve } from "node:path"; ++import type { Request } from "express"; ++import { Test } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { RagQualityService } from "../../src/modules/rag/quality/rag-quality.service"; ++import { HealthController } from "../../src/modules/system/health.controller"; ++ ++describe("rag quality gate integration", () => { ++ beforeAll(() => { ++ process.env.SQLITE_PATH = resolve( ++ __dirname, ++ "../../../../data/sqlite/text2sql.db" ++ ); ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ }); ++ ++ it("marks gate pass when thresholds are met with sufficient sample", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ const quality = moduleRef.get(RagQualityService); ++ quality.reset(); ++ ++ quality.recordEvaluation({ ++ runId: "run-rag-quality-pass-v1", ++ datasourceId: "ds-rag-quality-pass", ++ sampleSize: 48, ++ recallAt20: 0.88, ++ mrrAt10: 0.71, ++ retrievalRerankP95Ms: 640, ++ degradeRate: 0.03 ++ }); ++ ++ const snapshot = quality.snapshot(); ++ expect(snapshot.sampleReady).toBe(true); ++ expect(snapshot.gatePass).toBe(true); ++ expect(snapshot.reasons).toEqual([]); ++ ++ await moduleRef.close(); ++ }); ++ ++ it("keeps gate closed when sample is not ready even if metrics look good", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ const quality = moduleRef.get(RagQualityService); ++ quality.reset(); ++ ++ quality.recordEvaluation({ ++ runId: "run-rag-quality-sample-not-ready-v1", ++ datasourceId: "ds-rag-quality-sample-not-ready", ++ sampleSize: 12, ++ recallAt20: 0.92, ++ mrrAt10: 0.81, ++ retrievalRerankP95Ms: 420, ++ degradeRate: 0.01 ++ }); ++ ++ const snapshot = quality.snapshot(); ++ expect(snapshot.sampleReady).toBe(false); ++ expect(snapshot.gatePass).toBe(false); ++ expect(snapshot.reasons).toEqual(expect.arrayContaining(["sample_not_ready"])); ++ ++ await moduleRef.close(); ++ }); ++ ++ it("exposes rag quality gate summary on /health", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ const quality = moduleRef.get(RagQualityService); ++ const health = moduleRef.get(HealthController); ++ quality.reset(); ++ quality.recordEvaluation({ ++ runId: "run-rag-quality-health-v1", ++ datasourceId: "ds-rag-quality-health", ++ sampleSize: 30, ++ recallAt20: 0.81, ++ mrrAt10: 0.66, ++ retrievalRerankP95Ms: 700, ++ degradeRate: 0.05 ++ }); ++ ++ const response = await health.health({ ++ requestId: "req-rag-quality-health-v1" ++ } as unknown as Request); ++ expect(response.status).toBe("success"); ++ if (response.status !== "success") { ++ throw new Error("health endpoint returned unexpected error response"); ++ } ++ const payload = response.data as { ++ dependencies: { ++ ragQuality: { ++ gate: ReturnType; ++ }; ++ }; ++ }; ++ expect(payload.dependencies.ragQuality.gate.sampleReady).toBe(true); ++ expect(payload.dependencies.ragQuality.gate.gatePass).toBe(true); ++ ++ await moduleRef.close(); ++ }); ++}); +diff --git a/apps/backend/test/integration/rag-rerank.integration.spec.ts b/apps/backend/test/integration/rag-rerank.integration.spec.ts +new file mode 100644 +index 0000000..3c95ee1 +--- /dev/null ++++ b/apps/backend/test/integration/rag-rerank.integration.spec.ts +@@ -0,0 +1,168 @@ ++import { resolve } from "node:path"; ++import { Test } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { RagIndexBuilderService } from "../../src/modules/rag/index/rag-index-builder.service"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++import { RagReplayRepository } from "../../src/modules/rag/observability/rag-replay.repository"; ++import { RagRetrievalService } from "../../src/modules/rag/retrieval/rag-retrieval.service"; ++import { ModelRerankerAdapter } from "../../src/modules/rag/rerank/model-reranker.adapter"; ++import { RagRerankService } from "../../src/modules/rag/rerank/rag-rerank.service"; ++ ++describe("rag rerank integration", () => { ++ beforeAll(() => { ++ process.env.SQLITE_PATH = resolve( ++ __dirname, ++ "../../../../data/sqlite/text2sql.db" ++ ); ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ }); ++ ++ it("generates reranked output and writes primary/secondary/final replay records", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ ++ const indexRepository = moduleRef.get(RagIndexRepository); ++ const indexBuilder = moduleRef.get(RagIndexBuilderService); ++ const retrievalService = moduleRef.get(RagRetrievalService); ++ const rerankService = moduleRef.get(RagRerankService); ++ const replayRepository = moduleRef.get(RagReplayRepository); ++ ++ const datasourceId = "ds-rag-rerank-int"; ++ indexRepository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-rerank-schema", ++ datasourceId, ++ domain: "schema", ++ content: "table orders(id, amount, status)", ++ metadata: JSON.stringify({ ++ tableNames: ["orders"], ++ columnNames: ["id", "amount", "status"] ++ }) ++ }, ++ { ++ id: "chunk-rerank-sql", ++ datasourceId, ++ domain: "sql_example", ++ content: "SELECT SUM(amount) FROM orders WHERE status = 'paid'", ++ metadata: JSON.stringify({ ++ tableNames: ["orders"], ++ columnNames: ["amount", "status"] ++ }) ++ }, ++ { ++ id: "chunk-rerank-semantic", ++ datasourceId, ++ domain: "semantic_term", ++ content: "GMV maps to SUM(order amount).", ++ metadata: JSON.stringify({ ++ tableNames: ["orders"], ++ columnNames: ["amount"] ++ }) ++ } ++ ]); ++ await indexBuilder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-rag-rerank-v1", ++ createdByRunId: "run-rag-rerank-build-v1", ++ activatedByRunId: "run-rag-rerank-build-v1" ++ }); ++ ++ const retrieval = await retrievalService.retrieve({ ++ query: "GMV amount orders", ++ datasourceId, ++ runId: "run-rag-rerank-v1" ++ }); ++ const reranked = await rerankService.rerank({ ++ retrievalBundle: retrieval.retrieval_bundle ++ }); ++ ++ expect(reranked.retrieval_bundle.reranked?.length).toBeGreaterThan(0); ++ expect(reranked.retrieval_bundle.selected_context?.length).toBeGreaterThan(0); ++ expect(reranked.retrieval_bundle.risk_tags).toBeDefined(); ++ ++ const replayEvents = await replayRepository.listByRunId("run-rag-rerank-v1"); ++ expect(replayEvents.some((item) => item.replayKey === "rerank:primary")).toBe(true); ++ expect(replayEvents.some((item) => item.replayKey === "rerank:secondary")).toBe(true); ++ expect(replayEvents.some((item) => item.replayKey === "rerank:final")).toBe(true); ++ ++ await moduleRef.close(); ++ }); ++ ++ it("falls back to primary ranking when secondary rerank exceeds timeout budget", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ ++ const indexRepository = moduleRef.get(RagIndexRepository); ++ const indexBuilder = moduleRef.get(RagIndexBuilderService); ++ const retrievalService = moduleRef.get(RagRetrievalService); ++ const rerankService = moduleRef.get(RagRerankService); ++ const modelAdapter = moduleRef.get(ModelRerankerAdapter); ++ ++ jest.spyOn(modelAdapter, "rerank").mockImplementation( ++ async () => ++ new Promise((resolvePromise) => { ++ setTimeout(() => { ++ resolvePromise([ ++ { ++ candidateId: "chunk-timeout-schema", ++ score: 0.9, ++ reason: "late result" ++ } ++ ]); ++ }, 30); ++ }) ++ ); ++ ++ const datasourceId = "ds-rag-rerank-timeout"; ++ indexRepository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-timeout-schema", ++ datasourceId, ++ domain: "schema", ++ content: "table users(id, email)" ++ }, ++ { ++ id: "chunk-timeout-semantic", ++ datasourceId, ++ domain: "semantic_term", ++ content: "email identity mapping" ++ }, ++ { ++ id: "chunk-timeout-sql", ++ datasourceId, ++ domain: "sql_example", ++ content: "SELECT COUNT(*) FROM users" ++ } ++ ]); ++ await indexBuilder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-rag-rerank-timeout-v1", ++ createdByRunId: "run-rag-rerank-timeout-build-v1", ++ activatedByRunId: "run-rag-rerank-timeout-build-v1" ++ }); ++ ++ const retrieval = await retrievalService.retrieve({ ++ query: "users email", ++ datasourceId, ++ runId: "run-rag-rerank-timeout-v1" ++ }); ++ const reranked = await rerankService.rerank({ ++ retrievalBundle: retrieval.retrieval_bundle, ++ secondaryTimeoutMs: 1 ++ }); ++ ++ expect(reranked.retrieval_bundle.degrade_reasons).toEqual( ++ expect.arrayContaining(["secondary_rerank_timeout"]) ++ ); ++ expect(reranked.retrieval_bundle.reranked?.every((item) => item.secondary_score === undefined)).toBe( ++ true ++ ); ++ ++ await moduleRef.close(); ++ }); ++}); +diff --git a/apps/backend/test/integration/rag-retrieval.service.spec.ts b/apps/backend/test/integration/rag-retrieval.service.spec.ts +new file mode 100644 +index 0000000..097963b +--- /dev/null ++++ b/apps/backend/test/integration/rag-retrieval.service.spec.ts +@@ -0,0 +1,193 @@ ++import { resolve } from "node:path"; ++import { Test } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { RagIndexBuilderService } from "../../src/modules/rag/index/rag-index-builder.service"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++import { RagReplayRepository } from "../../src/modules/rag/observability/rag-replay.repository"; ++import { RagRetrievalService } from "../../src/modules/rag/retrieval/rag-retrieval.service"; ++ ++describe("rag retrieval service integration", () => { ++ beforeAll(() => { ++ process.env.SQLITE_PATH = resolve( ++ __dirname, ++ "../../../../data/sqlite/text2sql.db" ++ ); ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ }); ++ ++ it("returns reproducible candidates and keeps domain coverage across schema/sql_example/semantic_term", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ ++ const indexRepository = moduleRef.get(RagIndexRepository); ++ const indexBuilder = moduleRef.get(RagIndexBuilderService); ++ const retrievalService = moduleRef.get(RagRetrievalService); ++ const replayRepository = moduleRef.get(RagReplayRepository); ++ ++ const datasourceId = "ds-rag-retrieval-coverage"; ++ indexRepository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-schema-orders", ++ datasourceId, ++ domain: "schema", ++ content: "table orders(id, amount, status, created_at)", ++ metadata: JSON.stringify({ ++ chunkProfile: "schema_table", ++ tableNames: ["orders"], ++ columnNames: ["id", "amount", "status", "created_at"] ++ }) ++ }, ++ { ++ id: "chunk-sql-orders", ++ datasourceId, ++ domain: "sql_example", ++ content: "SELECT SUM(amount) FROM orders WHERE status = 'paid'", ++ metadata: JSON.stringify({ ++ chunkProfile: "sql_example", ++ tableNames: ["orders"], ++ columnNames: ["amount", "status"] ++ }) ++ }, ++ { ++ id: "chunk-semantic-gmv", ++ datasourceId, ++ domain: "semantic_term", ++ content: "GMV means gross merchandise volume and maps to order amount.", ++ metadata: JSON.stringify({ ++ chunkProfile: "semantic_term", ++ tableNames: ["orders"], ++ columnNames: ["amount"] ++ }) ++ } ++ ]); ++ ++ await indexBuilder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-rag-retrieval-v1", ++ createdByRunId: "run-rag-retrieval-build-v1", ++ activatedByRunId: "run-rag-retrieval-build-v1" ++ }); ++ ++ const first = await retrievalService.retrieve({ ++ query: "orders amount GMV", ++ datasourceId, ++ runId: "run-rag-retrieval-v1", ++ perLaneLimit: 10, ++ finalCandidateLimit: 10 ++ }); ++ const second = await retrievalService.retrieve({ ++ query: "orders amount GMV", ++ datasourceId, ++ runId: "run-rag-retrieval-v2", ++ perLaneLimit: 10, ++ finalCandidateLimit: 10 ++ }); ++ ++ const firstIds = first.retrieval_bundle.candidates.map((item) => item.chunk_id); ++ const secondIds = second.retrieval_bundle.candidates.map((item) => item.chunk_id); ++ const coveredDomains = new Set( ++ first.retrieval_bundle.candidates.map((item) => item.chunk.metadata.domain) ++ ); ++ ++ expect(first.retrieval_bundle.index_version_id).toBeTruthy(); ++ expect(first.retrieval_bundle.lane_results.lexical.status).toBe("ok"); ++ expect(first.retrieval_bundle.lane_results.dense.status).toBe("ok"); ++ expect(first.retrieval_bundle.lane_results.graph.status).toBe("ok"); ++ expect(firstIds.length).toBeGreaterThan(0); ++ expect(firstIds).toEqual(secondIds); ++ expect(coveredDomains.has("schema")).toBe(true); ++ expect(coveredDomains.has("sql_example")).toBe(true); ++ expect(coveredDomains.has("semantic_term")).toBe(true); ++ ++ const replayEvents = await replayRepository.listByRunId("run-rag-retrieval-v1"); ++ expect(replayEvents.some((item) => item.replayKey === "retrieval:lane:lexical")).toBe( ++ true ++ ); ++ expect(replayEvents.some((item) => item.replayKey === "retrieval:lane:dense")).toBe( ++ true ++ ); ++ expect(replayEvents.some((item) => item.replayKey === "retrieval:lane:graph")).toBe( ++ true ++ ); ++ expect(replayEvents.some((item) => item.replayKey === "retrieval:fused")).toBe(true); ++ ++ await moduleRef.close(); ++ }); ++ ++ it("degrades a timed-out lane but still returns candidates from healthy lanes", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ ++ const indexRepository = moduleRef.get(RagIndexRepository); ++ const indexBuilder = moduleRef.get(RagIndexBuilderService); ++ const retrievalService = moduleRef.get(RagRetrievalService); ++ ++ const datasourceId = "ds-rag-retrieval-timeout"; ++ indexRepository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-timeout-schema", ++ datasourceId, ++ domain: "schema", ++ content: "table users(id, email, created_at)" ++ }, ++ { ++ id: "chunk-timeout-sql", ++ datasourceId, ++ domain: "sql_example", ++ content: "SELECT COUNT(*) FROM users" ++ } ++ ]); ++ await indexBuilder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-rag-retrieval-timeout-v1", ++ createdByRunId: "run-rag-retrieval-timeout-build-v1", ++ activatedByRunId: "run-rag-retrieval-timeout-build-v1" ++ }); ++ ++ const response = await retrievalService.retrieve({ ++ query: "users count", ++ datasourceId, ++ runId: "run-rag-retrieval-timeout-v1", ++ laneTimeoutMs: { ++ dense: 1 ++ }, ++ laneArtificialDelayMs: { ++ dense: 20 ++ } ++ }); ++ ++ expect(response.retrieval_bundle.lane_results.dense.status).toBe("degraded"); ++ expect(response.retrieval_bundle.degrade_reasons).toEqual( ++ expect.arrayContaining(["dense_timeout"]) ++ ); ++ expect(response.retrieval_bundle.candidates.length).toBeGreaterThan(0); ++ ++ await moduleRef.close(); ++ }); ++ ++ it("returns degraded bundle when datasource has no active index", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ const retrievalService = moduleRef.get(RagRetrievalService); ++ ++ const response = await retrievalService.retrieve({ ++ query: "orders", ++ datasourceId: "ds-rag-retrieval-no-active-index", ++ runId: "run-rag-retrieval-no-active-index" ++ }); ++ ++ expect(response.retrieval_bundle.status).toBe("degraded"); ++ expect(response.retrieval_bundle.degrade_reasons).toEqual( ++ expect.arrayContaining(["no_active_index"]) ++ ); ++ expect(response.retrieval_bundle.candidates).toHaveLength(0); ++ ++ await moduleRef.close(); ++ }); ++}); +diff --git a/apps/backend/test/integration/rag-run-replay.spec.ts b/apps/backend/test/integration/rag-run-replay.spec.ts +new file mode 100644 +index 0000000..4389e30 +--- /dev/null ++++ b/apps/backend/test/integration/rag-run-replay.spec.ts +@@ -0,0 +1,103 @@ ++import { resolve } from "node:path"; ++import { Test } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { RagReplayRepository } from "../../src/modules/rag/observability/rag-replay.repository"; ++import { RagQualityService } from "../../src/modules/rag/quality/rag-quality.service"; ++ ++describe("rag run replay completeness integration", () => { ++ beforeAll(() => { ++ process.env.SQLITE_PATH = resolve( ++ __dirname, ++ "../../../../data/sqlite/text2sql.db" ++ ); ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ }); ++ ++ it("reports replay as ready when all required stages are present", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ const replay = moduleRef.get(RagReplayRepository); ++ const quality = moduleRef.get(RagQualityService); ++ const runId = "run-rag-replay-complete-v1"; ++ ++ await replay.writeReplay({ ++ runId, ++ replayKey: "retrieval:lane:lexical", ++ datasourceId: "ds-rag-replay", ++ stage: "retrieval_lane", ++ payload: {} ++ }); ++ await replay.writeReplay({ ++ runId, ++ replayKey: "retrieval:fused", ++ datasourceId: "ds-rag-replay", ++ stage: "retrieval_fused", ++ payload: {} ++ }); ++ await replay.writeReplay({ ++ runId, ++ replayKey: "rerank:primary", ++ datasourceId: "ds-rag-replay", ++ stage: "rerank_primary", ++ payload: {} ++ }); ++ await replay.writeReplay({ ++ runId, ++ replayKey: "rerank:secondary", ++ datasourceId: "ds-rag-replay", ++ stage: "rerank_secondary", ++ payload: {} ++ }); ++ await replay.writeReplay({ ++ runId, ++ replayKey: "rerank:final", ++ datasourceId: "ds-rag-replay", ++ stage: "rerank_finalized", ++ payload: {} ++ }); ++ ++ const report = await quality.getReplayCompleteness(runId); ++ expect(report.ready).toBe(true); ++ expect(report.completeness).toBe(1); ++ expect(report.missingStages).toHaveLength(0); ++ ++ await moduleRef.close(); ++ }); ++ ++ it("reports replay as not ready when required stages are missing", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ const replay = moduleRef.get(RagReplayRepository); ++ const quality = moduleRef.get(RagQualityService); ++ const runId = "run-rag-replay-incomplete-v1"; ++ ++ await replay.writeReplay({ ++ runId, ++ replayKey: "retrieval:lane:lexical", ++ datasourceId: "ds-rag-replay", ++ stage: "retrieval_lane", ++ payload: {} ++ }); ++ await replay.writeReplay({ ++ runId, ++ replayKey: "rerank:primary", ++ datasourceId: "ds-rag-replay", ++ stage: "rerank_primary", ++ payload: {} ++ }); ++ ++ const report = await quality.getReplayCompleteness(runId); ++ expect(report.ready).toBe(false); ++ expect(report.completeness).toBeLessThan(1); ++ expect(report.missingStages).toEqual( ++ expect.arrayContaining(["retrieval_fused", "rerank_secondary", "rerank_finalized"]) ++ ); ++ ++ await moduleRef.close(); ++ }); ++}); +diff --git a/apps/backend/test/integration/sandbox-failover.spec.ts b/apps/backend/test/integration/sandbox-failover.spec.ts +new file mode 100644 +index 0000000..00afbf9 +--- /dev/null ++++ b/apps/backend/test/integration/sandbox-failover.spec.ts +@@ -0,0 +1,105 @@ ++import type { SqlRun } from "@text2sql/shared-types"; ++import { DeliveryContractMapper } from "../../src/modules/delivery/delivery-contract.mapper"; ++import { ++ DELIVERY_SANDBOX_REPLAY_KEY, ++ SandboxRuntimeService ++} from "../../src/modules/delivery/sandbox/sandbox-runtime.service"; ++ ++const createBaseRun = (override: Partial = {}): SqlRun => ({ ++ runId: "run-sandbox-failover", ++ sessionId: "session-sandbox-failover", ++ question: "统计订单状态分布", ++ status: "executionResult", ++ provider: "volcengine", ++ model: "mock-model", ++ answer: "共 3 种订单状态。", ++ sql: "SELECT status, COUNT(*) AS count FROM orders GROUP BY status", ++ rows: [ ++ { status: "paid", count: 4 }, ++ { status: "pending", count: 2 }, ++ { status: "closed", count: 1 } ++ ], ++ columns: ["status", "count"], ++ trace: { ++ runId: "run-sandbox-failover", ++ provider: "volcengine", ++ retryCount: 0, ++ steps: [] ++ }, ++ llmRaw: null, ++ createdAt: "2026-04-18T00:00:00.000Z", ++ ...override ++}); ++ ++describe("sandbox failover integration", () => { ++ it("fails closed for sandbox operation while preserving the main answer and artifact", () => { ++ const mapper = new DeliveryContractMapper(new SandboxRuntimeService()); ++ const run = createBaseRun(); ++ ++ const delivery = mapper.map({ ++ run, ++ replayRecords: [ ++ { ++ replayKey: DELIVERY_SANDBOX_REPLAY_KEY, ++ stage: "delivery_sandbox_postprocess", ++ payload: JSON.stringify({ ++ operations: [ ++ { ++ type: "network_request", ++ host: "example.com", ++ protocol: "https", ++ port: 443 ++ }, ++ { ++ type: "rows_preview_limit", ++ maxRows: 1 ++ } ++ ] ++ }), ++ createdAt: "2026-04-18T00:00:01.000Z" ++ } ++ ] ++ }); ++ ++ expect(delivery.answer.text).toBe("共 3 种订单状态。"); ++ expect(delivery.artifact?.rowCount).toBe(3); ++ expect(delivery.artifact?.rowsPreview).toHaveLength(3); ++ expect(delivery.evidence?.riskTags).toEqual( ++ expect.arrayContaining(["sandbox_failed", "sandbox_network_denied"]) ++ ); ++ }); ++ ++ it("degrades gracefully when sandbox runtime throws", () => { ++ const mapper = new DeliveryContractMapper(({ ++ executeArtifactPostProcess: () => { ++ throw new Error("sandbox crash"); ++ } ++ } as unknown) as SandboxRuntimeService); ++ ++ const run = createBaseRun(); ++ const delivery = mapper.map({ ++ run, ++ replayRecords: [ ++ { ++ replayKey: DELIVERY_SANDBOX_REPLAY_KEY, ++ stage: "delivery_sandbox_postprocess", ++ payload: JSON.stringify({ ++ operations: [ ++ { ++ type: "rows_preview_limit", ++ maxRows: 1 ++ } ++ ] ++ }), ++ createdAt: "2026-04-18T00:00:02.000Z" ++ } ++ ] ++ }); ++ ++ expect(delivery.answer.text).toBe("共 3 种订单状态。"); ++ expect(delivery.artifact?.rowsPreview).toHaveLength(3); ++ expect(delivery.evidence?.riskTags).toEqual( ++ expect.arrayContaining(["sandbox_failed", "sandbox_runtime_failed"]) ++ ); ++ }); ++}); +diff --git a/apps/backend/test/integration/sandbox-isolation.spec.ts b/apps/backend/test/integration/sandbox-isolation.spec.ts +new file mode 100644 +index 0000000..81335c0 +--- /dev/null ++++ b/apps/backend/test/integration/sandbox-isolation.spec.ts +@@ -0,0 +1,109 @@ ++import type { DeliveryArtifactLayer } from "@text2sql/shared-types"; ++import { createDeliverySandboxPolicy } from "../../src/modules/delivery/sandbox/sandbox-policy"; ++import { SandboxRuntimeService } from "../../src/modules/delivery/sandbox/sandbox-runtime.service"; ++ ++const buildArtifact = (): DeliveryArtifactLayer => ({ ++ sql: "SELECT id, status FROM orders LIMIT 10", ++ columns: ["id", "status"], ++ rowCount: 3, ++ rowsPreview: [ ++ { id: 1, status: "paid" }, ++ { id: 2, status: "pending" }, ++ { id: 3, status: "closed" } ++ ], ++ hasError: false ++}); ++ ++describe("sandbox isolation integration", () => { ++ it("blocks network, file write, and process spawn under deny-by-default policy", () => { ++ const runtime = new SandboxRuntimeService(); ++ ++ const networkDenied = runtime.executeArtifactPostProcess({ ++ artifact: buildArtifact(), ++ request: { ++ operations: [ ++ { ++ type: "network_request", ++ host: "example.com", ++ protocol: "https", ++ port: 443 ++ } ++ ] ++ } ++ }); ++ expect(networkDenied.ok).toBe(false); ++ if (!networkDenied.ok) { ++ expect(networkDenied.riskTags).toContain("sandbox_network_denied"); ++ } ++ ++ const fileDenied = runtime.executeArtifactPostProcess({ ++ artifact: buildArtifact(), ++ request: { ++ operations: [{ type: "file_write", path: "/tmp/sandbox-artifact.json" }] ++ } ++ }); ++ expect(fileDenied.ok).toBe(false); ++ if (!fileDenied.ok) { ++ expect(fileDenied.riskTags).toContain("sandbox_filesystem_denied"); ++ } ++ ++ const processDenied = runtime.executeArtifactPostProcess({ ++ artifact: buildArtifact(), ++ request: { ++ operations: [{ type: "process_spawn", command: "node" }] ++ } ++ }); ++ expect(processDenied.ok).toBe(false); ++ if (!processDenied.ok) { ++ expect(processDenied.riskTags).toContain("sandbox_process_denied"); ++ } ++ }); ++ ++ it("permits only allowlisted operations and applies safe artifact post-processing", () => { ++ const policy = createDeliverySandboxPolicy({ ++ outboundNetworkAllowlist: [ ++ { ++ host: "internal.example.com", ++ protocols: ["https"], ++ ports: [443] ++ } ++ ], ++ filesystemWriteAllowlist: ["/tmp/sandbox-output"], ++ processSpawnAllowlist: ["node"] ++ }); ++ const runtime = new SandboxRuntimeService(policy); ++ ++ const result = runtime.executeArtifactPostProcess({ ++ artifact: buildArtifact(), ++ request: { ++ policyVersion: policy.version, ++ operations: [ ++ { ++ type: "network_request", ++ host: "internal.example.com", ++ protocol: "https", ++ port: 443 ++ }, ++ { ++ type: "file_write", ++ path: "/tmp/sandbox-output/artifact.json" ++ }, ++ { ++ type: "process_spawn", ++ command: "node" ++ }, ++ { ++ type: "rows_preview_limit", ++ maxRows: 1 ++ } ++ ] ++ } ++ }); ++ ++ expect(result.ok).toBe(true); ++ if (result.ok) { ++ expect(result.artifact.rowsPreview).toHaveLength(1); ++ expect(result.artifact.rowCount).toBe(3); ++ } ++ }); ++}); +diff --git a/apps/backend/test/integration/semantic-registry.spec.ts b/apps/backend/test/integration/semantic-registry.spec.ts +new file mode 100644 +index 0000000..6d6ea0b +--- /dev/null ++++ b/apps/backend/test/integration/semantic-registry.spec.ts +@@ -0,0 +1,118 @@ ++import { resolve } from "node:path"; ++import { Test } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { SemanticRegistryService } from "../../src/modules/semantic-registry/semantic-registry.service"; ++ ++describe("semantic registry integration", () => { ++ beforeAll(() => { ++ process.env.SQLITE_PATH = resolve( ++ __dirname, ++ "../../../../data/sqlite/text2sql.db" ++ ); ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ }); ++ ++ it("allows planner-stage lookup in the same run after publishing a semantic version", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ const registry = moduleRef.get(SemanticRegistryService); ++ ++ const runId = "run-semantic-registry-int-v1"; ++ await registry.publishVersion({ ++ domain: "semantic_term", ++ semanticVersion: 1, ++ releaseSummary: "R3 semantic release", ++ auditSummary: "integration publish", ++ publishedByRunId: runId, ++ activatedByRunId: runId, ++ activatedAt: "2026-04-18T00:00:00.000Z", ++ riskTags: ["semantic_registry_release"], ++ terms: [ ++ { ++ term: "gmv", ++ canonicalKey: "metric.gmv", ++ definition: "gross merchandise volume", ++ binding: "{\"metric\":\"sum(order_amount)\"}" ++ } ++ ] ++ }); ++ ++ const result = await registry.resolveTerm({ ++ domain: "semantic_term", ++ term: "gmv" ++ }); ++ ++ expect(result.status).toBe("ready"); ++ expect(result.semantic_version).toBe(1); ++ expect(result.term?.canonical_key).toBe("metric.gmv"); ++ expect(result.published_by_run_id).toBe(runId); ++ expect(result.activated_by_run_id).toBe(runId); ++ expect(result.risk_tags).toEqual( ++ expect.arrayContaining(["semantic_registry_release"]) ++ ); ++ ++ await moduleRef.close(); ++ }); ++ ++ it("resolves datasource-scoped semantic term first and falls back to global scope", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ const registry = moduleRef.get(SemanticRegistryService); ++ ++ await registry.publishVersion({ ++ domain: "semantic_term", ++ semanticVersion: 1, ++ releaseSummary: "global semantic release", ++ auditSummary: "integration global publish", ++ terms: [ ++ { ++ term: "gmv", ++ canonicalKey: "metric.gmv.global", ++ definition: "global gmv", ++ binding: "{\"metric\":\"sum(order_amount)\"}" ++ } ++ ] ++ }); ++ ++ const datasourceDomain = registry.buildDatasourceScopedDomain("semantic_term", "ds-semantic-a"); ++ await registry.publishVersion({ ++ domain: datasourceDomain, ++ semanticVersion: 1, ++ releaseSummary: "datasource semantic release", ++ auditSummary: "integration datasource publish", ++ terms: [ ++ { ++ term: "gmv", ++ canonicalKey: "metric.gmv.ds", ++ definition: "datasource gmv", ++ binding: "{\"metric\":\"sum(amount_paid)\"}" ++ } ++ ] ++ }); ++ ++ const datasourceScoped = await registry.resolveTerm({ ++ domain: "semantic_term", ++ datasourceId: "ds-semantic-a", ++ term: "GMV" ++ }); ++ const fallbackToGlobal = await registry.resolveTerm({ ++ domain: "semantic_term", ++ datasourceId: "ds-semantic-missing", ++ term: "GMV" ++ }); ++ ++ expect(datasourceScoped.status).toBe("ready"); ++ expect(datasourceScoped.term?.canonical_key).toBe("metric.gmv.ds"); ++ expect(datasourceScoped.matched_scope).toBe("datasource"); ++ expect(fallbackToGlobal.status).toBe("ready"); ++ expect(fallbackToGlobal.term?.canonical_key).toBe("metric.gmv.global"); ++ expect(fallbackToGlobal.matched_scope).toBe("global"); ++ ++ await moduleRef.close(); ++ }); ++}); +diff --git a/apps/backend/test/integration/skill-registry-rag.spec.ts b/apps/backend/test/integration/skill-registry-rag.spec.ts +new file mode 100644 +index 0000000..f4ea406 +--- /dev/null ++++ b/apps/backend/test/integration/skill-registry-rag.spec.ts +@@ -0,0 +1,128 @@ ++import { resolve } from "node:path"; ++import { Test } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { RagIndexBuilderService } from "../../src/modules/rag/index/rag-index-builder.service"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++import { RagRetrievalService } from "../../src/modules/rag/retrieval/rag-retrieval.service"; ++import { ++ SKILL_REGISTRY_UNAVAILABLE_REASON, ++ SkillRegistryService ++} from "../../src/modules/skill-registry/skill-registry.service"; ++ ++describe("skill registry + rag retrieval integration", () => { ++ beforeAll(() => { ++ process.env.SQLITE_PATH = resolve( ++ __dirname, ++ "../../../../data/sqlite/text2sql.db" ++ ); ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ }); ++ ++ it("injects skill context into retrieval bundle when term mapping is available", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ ++ const indexRepository = moduleRef.get(RagIndexRepository); ++ const indexBuilder = moduleRef.get(RagIndexBuilderService); ++ const retrievalService = moduleRef.get(RagRetrievalService); ++ ++ const datasourceId = "ds-skill-registry-rag-ready"; ++ indexRepository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-skill-semantic", ++ datasourceId, ++ domain: "semantic_term", ++ content: "GMV means gross merchandise volume.", ++ metadata: JSON.stringify({ ++ tableNames: ["orders"], ++ columnNames: ["amount"] ++ }) ++ }, ++ { ++ id: "chunk-skill-schema", ++ datasourceId, ++ domain: "schema", ++ content: "table orders(id, amount, created_at)", ++ metadata: JSON.stringify({ ++ tableNames: ["orders"], ++ columnNames: ["id", "amount", "created_at"] ++ }) ++ } ++ ]); ++ ++ await indexBuilder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-skill-registry-rag-ready-v1", ++ createdByRunId: "run-skill-registry-rag-ready-build-v1", ++ activatedByRunId: "run-skill-registry-rag-ready-build-v1" ++ }); ++ ++ const response = await retrievalService.retrieve({ ++ query: "GMV", ++ datasourceId, ++ runId: "run-skill-registry-rag-ready-v1" ++ }); ++ ++ expect(response.retrieval_bundle.skill_context).toBeDefined(); ++ expect(response.retrieval_bundle.skill_context?.skills.map((item) => item.key)).toEqual( ++ expect.arrayContaining(["metric_term_resolution", "aggregation_sql_builder"]) ++ ); ++ expect(response.retrieval_bundle.skill_context?.degrade_reason).toBeUndefined(); ++ ++ await moduleRef.close(); ++ }); ++ ++ it("keeps retrieval available and adds degrade reason when skill registry is unavailable", async () => { ++ const moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ ++ const indexRepository = moduleRef.get(RagIndexRepository); ++ const indexBuilder = moduleRef.get(RagIndexBuilderService); ++ const retrievalService = moduleRef.get(RagRetrievalService); ++ const skillRegistry = moduleRef.get(SkillRegistryService); ++ ++ jest.spyOn(skillRegistry, "resolveSkills").mockRejectedValue(new Error("registry down")); ++ ++ const datasourceId = "ds-skill-registry-rag-degrade"; ++ indexRepository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-skill-degrade-schema", ++ datasourceId, ++ domain: "schema", ++ content: "table users(id, email)", ++ metadata: JSON.stringify({ ++ tableNames: ["users"], ++ columnNames: ["id", "email"] ++ }) ++ } ++ ]); ++ ++ await indexBuilder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-skill-registry-rag-degrade-v1", ++ createdByRunId: "run-skill-registry-rag-degrade-build-v1", ++ activatedByRunId: "run-skill-registry-rag-degrade-build-v1" ++ }); ++ ++ const response = await retrievalService.retrieve({ ++ query: "users email", ++ datasourceId, ++ runId: "run-skill-registry-rag-degrade-v1" ++ }); ++ ++ expect(response.retrieval_bundle.candidates.length).toBeGreaterThan(0); ++ expect(response.retrieval_bundle.skill_context?.degrade_reason).toBe( ++ SKILL_REGISTRY_UNAVAILABLE_REASON ++ ); ++ expect(response.retrieval_bundle.degrade_reasons).toContain( ++ SKILL_REGISTRY_UNAVAILABLE_REASON ++ ); ++ ++ await moduleRef.close(); ++ }); ++}); +diff --git a/apps/backend/test/perf/rag-cache-budget-load.spec.ts b/apps/backend/test/perf/rag-cache-budget-load.spec.ts +new file mode 100644 +index 0000000..db2452f +--- /dev/null ++++ b/apps/backend/test/perf/rag-cache-budget-load.spec.ts +@@ -0,0 +1,99 @@ ++import { Test, type TestingModule } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { RagIndexBuilderService } from "../../src/modules/rag/index/rag-index-builder.service"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++import { RagRetrievalService } from "../../src/modules/rag/retrieval/rag-retrieval.service"; ++import { RagQualityService } from "../../src/modules/rag/quality/rag-quality.service"; ++import { createSeededSqliteFixture } from "../support/sqlite-fixture"; ++ ++describe("rag cache budget load", () => { ++ let moduleRef: TestingModule; ++ let retrievalService: RagRetrievalService; ++ let qualityService: RagQualityService; ++ let indexBuilder: RagIndexBuilderService; ++ let indexRepository: RagIndexRepository; ++ let cleanupFixture: (() => Promise) | undefined; ++ ++ beforeEach(async () => { ++ const fixture = await createSeededSqliteFixture("rag-cache-budget-load"); ++ cleanupFixture = fixture.cleanup; ++ process.env.SQLITE_PATH = fixture.dbPath; ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ ++ moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ retrievalService = moduleRef.get(RagRetrievalService, { ++ strict: false ++ }); ++ qualityService = moduleRef.get(RagQualityService, { ++ strict: false ++ }); ++ indexBuilder = moduleRef.get(RagIndexBuilderService, { ++ strict: false ++ }); ++ indexRepository = moduleRef.get(RagIndexRepository, { ++ strict: false ++ }); ++ qualityService.reset(); ++ }); ++ ++ afterEach(async () => { ++ await moduleRef.close(); ++ if (cleanupFixture) { ++ await cleanupFixture(); ++ } ++ }); ++ ++ it("keeps cache hit rate above 0.5 under repeated hot-query load", async () => { ++ const datasourceId = "ds-rag-cache-load"; ++ indexRepository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-load-schema", ++ datasourceId, ++ domain: "schema", ++ content: "table orders(id, amount, status, paid_at)" ++ }, ++ { ++ id: "chunk-load-sql", ++ datasourceId, ++ domain: "sql_example", ++ content: "SELECT status, SUM(amount) FROM orders GROUP BY status" ++ } ++ ]); ++ await indexBuilder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-cache-load-v1", ++ createdByRunId: "run-cache-load-build-v1", ++ activatedByRunId: "run-cache-load-build-v1" ++ }); ++ ++ for (let index = 0; index < 12; index += 1) { ++ await retrievalService.retrieve({ ++ query: "orders amount by status", ++ datasourceId, ++ runId: `run-cache-load-query-${index}` ++ }); ++ } ++ for (let index = 0; index < 4; index += 1) { ++ await retrievalService.retrieve({ ++ query: "orders amount by status", ++ datasourceId, ++ runId: `run-cache-load-budget-query-${index}`, ++ budgetSignal: { ++ costPressure: 0.98, ++ latencyPressure: 0.96, ++ tokenPressure: 0.97 ++ } ++ }); ++ } ++ ++ const snapshot = qualityService.snapshot(); ++ expect(snapshot.cacheBudget.sampleSize1h).toBeGreaterThanOrEqual(10); ++ expect(snapshot.cacheBudget.cacheEligibleHitRate).toBeGreaterThanOrEqual(0.5); ++ expect(snapshot.cacheBudget.budgetDegradeRate).toBeGreaterThan(0); ++ }); ++}); +diff --git a/apps/backend/test/perf/rag-r6-mixed-load.spec.ts b/apps/backend/test/perf/rag-r6-mixed-load.spec.ts +new file mode 100644 +index 0000000..f1d7bca +--- /dev/null ++++ b/apps/backend/test/perf/rag-r6-mixed-load.spec.ts +@@ -0,0 +1,144 @@ ++import { Test, type TestingModule } from "@nestjs/testing"; ++import { AppModule } from "../../src/app.module"; ++import { RagIndexBuilderService } from "../../src/modules/rag/index/rag-index-builder.service"; ++import { RagIndexRepository } from "../../src/modules/rag/index/rag-index.repository"; ++import { RagQualityService } from "../../src/modules/rag/quality/rag-quality.service"; ++import { RagRetrievalService } from "../../src/modules/rag/retrieval/rag-retrieval.service"; ++import { createSeededSqliteFixture } from "../support/sqlite-fixture"; ++ ++describe("rag r6 mixed load perf", () => { ++ let moduleRef: TestingModule; ++ let retrievalService: RagRetrievalService; ++ let qualityService: RagQualityService; ++ let indexBuilder: RagIndexBuilderService; ++ let indexRepository: RagIndexRepository; ++ let cleanupFixture: (() => Promise) | undefined; ++ ++ beforeEach(async () => { ++ const fixture = await createSeededSqliteFixture("rag-r6-mixed-load"); ++ cleanupFixture = fixture.cleanup; ++ process.env.SQLITE_PATH = fixture.dbPath; ++ process.env.DATABASE_URL = ""; ++ process.env.REDIS_URL = ""; ++ process.env.LLM_PROVIDER = "volcengine"; ++ process.env.LLM_MOCK_MODE = "true"; ++ process.env.RELEASE_CANDIDATE = "r6-mixed-load-ci"; ++ ++ moduleRef = await Test.createTestingModule({ ++ imports: [AppModule] ++ }).compile(); ++ retrievalService = moduleRef.get(RagRetrievalService, { ++ strict: false ++ }); ++ qualityService = moduleRef.get(RagQualityService, { ++ strict: false ++ }); ++ indexBuilder = moduleRef.get(RagIndexBuilderService, { ++ strict: false ++ }); ++ indexRepository = moduleRef.get(RagIndexRepository, { ++ strict: false ++ }); ++ qualityService.reset(); ++ }); ++ ++ afterEach(async () => { ++ await moduleRef.close(); ++ if (cleanupFixture) { ++ await cleanupFixture(); ++ } ++ delete process.env.RELEASE_CANDIDATE; ++ }); ++ ++ it("maps mixed-load budget pressure into rollback gate decision with complete samples", async () => { ++ const datasourceId = "ds-r6-mixed-load"; ++ indexRepository.seedChunksForDatasource(datasourceId, [ ++ { ++ id: "chunk-r6-mixed-load-schema-orders", ++ datasourceId, ++ domain: "schema", ++ content: "table orders(id, amount, status, paid_at)" ++ }, ++ { ++ id: "chunk-r6-mixed-load-schema-users", ++ datasourceId, ++ domain: "schema", ++ content: "table users(id, email, registered_at)" ++ }, ++ { ++ id: "chunk-r6-mixed-load-semantic", ++ datasourceId, ++ domain: "semantic_term", ++ content: "GMV means sum(order amount)" ++ } ++ ]); ++ await indexBuilder.buildAndActivate({ ++ datasourceId, ++ sourceVersion: "source-r6-mixed-load-v1", ++ createdByRunId: "run-r6-mixed-load-build-v1", ++ activatedByRunId: "run-r6-mixed-load-build-v1" ++ }); ++ ++ const retrieval = await retrievalService.retrieve({ ++ query: "orders gmv trend by status", ++ datasourceId, ++ runId: "run-r6-mixed-load-query-v1", ++ budgetSignal: { ++ costPressure: 0.94, ++ latencyPressure: 0.91, ++ tokenPressure: 0.95 ++ } ++ }); ++ expect(retrieval.retrieval_bundle.candidates.length).toBeGreaterThan(0); ++ ++ for (let index = 0; index < 520; index += 1) { ++ qualityService.recordDatasourceOrchestration({ ++ datasourceId: `ds-r6-mixed-load-${index % 8}`, ++ workspaceId: "workspace-r6-mixed-load", ++ queueWaitMs: 1800, ++ isolationViolation: false ++ }); ++ } ++ ++ for (let index = 0; index < 1000; index += 1) { ++ qualityService.recordCacheBudget({ ++ cacheEligible: true, ++ cacheHit: index % 4 !== 0, ++ budgetDegraded: index < 100 ++ }); ++ } ++ ++ qualityService.recordEvaluation({ ++ runId: "run-r6-mixed-load-quality-v1", ++ datasourceId, ++ sampleSize: 1200, ++ recallAt20: 0.9, ++ mrrAt10: 0.77, ++ retrievalRerankP95Ms: 660, ++ degradeRate: 0.04 ++ }); ++ ++ const snapshot = qualityService.snapshot(); ++ expect(snapshot.r6.sampleReady).toBe(true); ++ expect(snapshot.r6.gatePass).toBe(false); ++ expect(snapshot.r6.gateDecision).toBe("rollback"); ++ expect(snapshot.r6.blockReasons).toEqual([]); ++ expect(snapshot.r6.freezeReasons).toEqual([]); ++ expect(snapshot.r6.rollbackReasons).toEqual( ++ expect.arrayContaining(["budgetDegradeRate_breach"]) ++ ); ++ const budgetMetric = snapshot.r6.metrics.find( ++ (metric) => metric.name === "budgetDegradeRate" ++ ); ++ const retrievalMetric = snapshot.r6.metrics.find( ++ (metric) => metric.name === "retrievalP95Ms" ++ ); ++ const queueMetric = snapshot.r6.metrics.find( ++ (metric) => metric.name === "orchestratorQueueWaitP95Ms" ++ ); ++ ++ expect(budgetMetric?.samples ?? 0).toBeGreaterThanOrEqual(1000); ++ expect(retrievalMetric?.samples).toBe(1200); ++ expect(queueMetric?.samples).toBe(520); ++ }); ++}); +diff --git a/apps/backend/test/unit/delivery-contract.mapper.spec.ts b/apps/backend/test/unit/delivery-contract.mapper.spec.ts +new file mode 100644 +index 0000000..5865254 +--- /dev/null ++++ b/apps/backend/test/unit/delivery-contract.mapper.spec.ts +@@ -0,0 +1,175 @@ ++import type { SqlRun } from "@text2sql/shared-types"; ++import { DeliveryContractMapper } from "../../src/modules/delivery/delivery-contract.mapper"; ++import { SandboxRuntimeService } from "../../src/modules/delivery/sandbox/sandbox-runtime.service"; ++ ++const createBaseRun = (override: Partial = {}): SqlRun => ({ ++ runId: "run-delivery-unit", ++ sessionId: "session-delivery-unit", ++ question: "统计订单状态分布", ++ status: "executionResult", ++ provider: "volcengine", ++ model: "mock-model", ++ answer: "共 4 种订单状态。", ++ sql: "SELECT status, COUNT(*) AS count FROM orders GROUP BY status", ++ rows: [ ++ { ++ status: "paid", ++ count: 4 ++ } ++ ], ++ columns: ["status", "count"], ++ trace: { ++ runId: "run-delivery-unit", ++ provider: "volcengine", ++ retryCount: 0, ++ steps: [] ++ }, ++ llmRaw: null, ++ createdAt: "2026-04-18T00:00:00.000Z", ++ ...override ++}); ++ ++describe("DeliveryContractMapper", () => { ++ it("maps answer/evidence/artifact from run and replay records", () => { ++ const mapper = new DeliveryContractMapper(new SandboxRuntimeService()); ++ const run = createBaseRun({ ++ trace: { ++ runId: "run-delivery-unit", ++ provider: "volcengine", ++ retryCount: 0, ++ steps: [ ++ { ++ node: "build-semantic-query", ++ status: "success", ++ at: "2026-04-18T00:00:00.500Z", ++ outputSummary: JSON.stringify({ ++ semanticVersion: 7, ++ lockStatus: "locked", ++ degradeReason: null ++ }) ++ } ++ ] ++ } ++ }); ++ ++ const delivery = mapper.map({ ++ run, ++ replayRecords: [ ++ { ++ replayKey: "retrieval:fused", ++ stage: "retrieval_fused", ++ indexVersionId: "idx-v1", ++ payload: JSON.stringify({ ++ status: "degraded", ++ skillContext: { ++ skills: [ ++ { skillId: "skill-1", title: "订单统计" }, ++ { skillId: "skill-2", title: "趋势分析" } ++ ], ++ context: [{ key: "table", value: "orders" }], ++ degrade_reason: "skill_registry_unavailable" ++ }, ++ candidates: [ ++ { ++ chunkId: "chunk-a", ++ sourceLane: "lexical", ++ domain: "schema" ++ } ++ ] ++ }), ++ createdAt: "2026-04-18T00:00:01.000Z" ++ }, ++ { ++ replayKey: "rerank:final", ++ stage: "rerank_finalized", ++ indexVersionId: "idx-v1", ++ payload: JSON.stringify({ ++ status: "degraded", ++ degradeReasons: ["secondary_rerank_timeout"], ++ selectedContextCount: 1, ++ riskTags: ["rag_secondary_timeout"] ++ }), ++ createdAt: "2026-04-18T00:00:02.000Z" ++ } ++ ] ++ }); ++ ++ expect(delivery.answer.text).toBe("共 4 种订单状态。"); ++ expect(delivery.answer.status).toBe("executionResult"); ++ expect(delivery.evidence?.runId).toBe(run.runId); ++ expect(delivery.evidence?.retrievalStatus).toBe("degraded"); ++ expect(delivery.evidence?.degradeReasons).toEqual( ++ expect.arrayContaining(["secondary_rerank_timeout"]) ++ ); ++ expect(delivery.evidence?.selectedContext?.count).toBe(1); ++ expect(delivery.evidence?.riskTags).toEqual( ++ expect.arrayContaining(["rag_secondary_timeout"]) ++ ); ++ expect(delivery.evidence?.semanticVersion).toBe(7); ++ expect(delivery.evidence?.semanticLockStatus).toBe("locked"); ++ expect(delivery.evidence?.skillContextSummary).toEqual({ ++ skillCount: 2, ++ contextCount: 1, ++ degradeReason: "skill_registry_unavailable" ++ }); ++ expect(delivery.evidence?.retrievalLogs).toHaveLength(2); ++ expect(delivery.artifact?.rowCount).toBe(1); ++ expect(delivery.artifact?.hasError).toBe(false); ++ }); ++ ++ it("keeps contract complete when artifact is absent", () => { ++ const mapper = new DeliveryContractMapper(new SandboxRuntimeService()); ++ const run = createBaseRun({ ++ sql: undefined, ++ rows: undefined, ++ columns: undefined, ++ answer: "暂无可执行 SQL,已提供解释。", ++ error: undefined ++ }); ++ ++ const delivery = mapper.map({ ++ run, ++ replayRecords: [] ++ }); ++ ++ expect(delivery.answer.text).toBe("暂无可执行 SQL,已提供解释。"); ++ expect(delivery.evidence?.runId).toBe(run.runId); ++ expect(delivery.artifact).toBeUndefined(); ++ }); ++ ++ it("adds delivery_input_invalid when replay payload is malformed", () => { ++ const mapper = new DeliveryContractMapper(new SandboxRuntimeService()); ++ const run = createBaseRun(); ++ ++ const delivery = mapper.map({ ++ run, ++ replayRecords: [ ++ { ++ replayKey: "rerank:final", ++ stage: "rerank_finalized", ++ indexVersionId: "idx-v1", ++ payload: "{invalid-json", ++ createdAt: "2026-04-18T00:00:03.000Z" ++ } ++ ] ++ }); ++ ++ expect(delivery.evidence?.riskTags).toEqual( ++ expect.arrayContaining(["delivery_input_invalid"]) ++ ); ++ }); ++ ++ it("builds fallback contract with delivery_mapper_failed risk tag", () => { ++ const mapper = new DeliveryContractMapper(new SandboxRuntimeService()); ++ const run = createBaseRun({ ++ answer: undefined, ++ error: "内部错误" ++ }); ++ ++ const fallback = mapper.buildFallback(run, "delivery_mapper_failed"); ++ ++ expect(fallback.answer.text).toBe("内部错误"); ++ expect(fallback.evidence?.riskTags).toEqual(["delivery_mapper_failed"]); ++ expect(fallback.artifact?.hasError).toBe(true); ++ }); ++}); +diff --git a/apps/backend/test/unit/langgraph-runtime.spec.ts b/apps/backend/test/unit/langgraph-runtime.spec.ts +index 535f62d..33a18ca 100644 +--- a/apps/backend/test/unit/langgraph-runtime.spec.ts ++++ b/apps/backend/test/unit/langgraph-runtime.spec.ts +@@ -4,45 +4,53 @@ import { + } from "../../src/modules/agent/graph/langgraph.state"; + import { createLangGraphRuntime } from "../../src/modules/agent/graph/langgraph.runtime"; + + describe("langgraph runtime", () => { + it("should run through executionResult path with expected node steps", async () => { + const runtime = createLangGraphRuntime({ + clarifyNode: { + run: () => undefined + }, + retrieveKnowledgeNode: { +- run: () => ({ ++ run: async () => ({ + status: "ready", + snippets: ["stub"], + summary: "stub" + }) + }, + buildIntentPlanNode: { + run: () => ({ + status: "ready", + intent: "aggregate", + constraints: [], + summary: "stub" + }) + }, + buildSemanticQueryNode: { +- run: () => ({ ++ run: async () => ({ + status: "ready", + semanticHints: [], ++ semanticVersion: 1, ++ lockStatus: "locked" as const, ++ fallbackApplied: false, ++ riskTags: [], + summary: "stub" + }) + }, + buildPhysicalPlanNode: { +- run: () => ({ ++ run: async () => ({ + status: "ready", + strategy: "direct_sql", ++ semanticVersion: 1, ++ lockStatus: "locked" as const, ++ fallbackApplied: false, ++ cacheStatus: "miss" as const, + summary: "stub" + }) + }, + generateSqlNode: { + run: async () => ({ + provider: "mock-provider", + model: "mock-model", + sql: "SELECT 1 AS value", + explanation: "mock explanation", + rawText: "mock raw text", +@@ -107,45 +115,53 @@ describe("langgraph runtime", () => { + route: "unknown" + }); + }); + + it("should capture fatal llm error into state without throwing from runtime", async () => { + const runtime = createLangGraphRuntime({ + clarifyNode: { + run: () => undefined + }, + retrieveKnowledgeNode: { +- run: () => ({ ++ run: async () => ({ + status: "ready", + snippets: ["stub"], + summary: "stub" + }) + }, + buildIntentPlanNode: { + run: () => ({ + status: "ready", + intent: "aggregate", + constraints: [], + summary: "stub" + }) + }, + buildSemanticQueryNode: { +- run: () => ({ ++ run: async () => ({ + status: "ready", + semanticHints: [], ++ semanticVersion: 1, ++ lockStatus: "locked" as const, ++ fallbackApplied: false, ++ riskTags: [], + summary: "stub" + }) + }, + buildPhysicalPlanNode: { +- run: () => ({ ++ run: async () => ({ + status: "ready", + strategy: "direct_sql", ++ semanticVersion: 1, ++ lockStatus: "locked" as const, ++ fallbackApplied: false, ++ cacheStatus: "miss" as const, + summary: "stub" + }) + }, + generateSqlNode: { + run: async () => { + throw new Error("llm failed"); + } + }, + safetyNode: { + run: async () => ({ +diff --git a/apps/backend/test/unit/memory-promotion-policy.spec.ts b/apps/backend/test/unit/memory-promotion-policy.spec.ts +new file mode 100644 +index 0000000..8ffaa2d +--- /dev/null ++++ b/apps/backend/test/unit/memory-promotion-policy.spec.ts +@@ -0,0 +1,181 @@ ++import type { SqlRun } from "@text2sql/shared-types"; ++import { ++ MemoryPromotionPolicy, ++ type MemoryPromotionRecord ++} from "../../src/modules/memory/memory-promotion-policy"; ++ ++function createRun(override: Partial = {}): SqlRun { ++ return { ++ runId: "run-memory-policy-1", ++ sessionId: "session-memory-policy-1", ++ question: "统计订单总量", ++ status: "executionResult", ++ provider: "volcengine", ++ model: "mock-model", ++ sql: "SELECT COUNT(*) AS total FROM orders", ++ answer: "订单总量为 10", ++ rows: [{ total: 10 }], ++ columns: ["total"], ++ trace: { ++ runId: "run-memory-policy-1", ++ provider: "volcengine", ++ retryCount: 0, ++ steps: [] ++ }, ++ llmRaw: null, ++ createdAt: "2026-04-18T00:00:00.000Z", ++ ...override ++ }; ++} ++ ++function createRecord( ++ override: Partial = {} ++): MemoryPromotionRecord { ++ return { ++ candidateId: "memory-candidate-policy", ++ status: "candidate", ++ evidence: { ++ sampleCount: 0, ++ successCount: 0, ++ riskFlagCount: 0, ++ successRate: 0 ++ }, ++ version: 0, ++ lastRunId: null, ++ lastTransitionKey: null, ++ updatedAt: "2026-04-18T00:00:00.000Z", ++ ...override ++ }; ++} ++ ++describe("MemoryPromotionPolicy", () => { ++ it("transitions candidate to verified when thresholds are met", () => { ++ const policy = new MemoryPromotionPolicy(); ++ const run = createRun(); ++ const record = createRecord({ ++ status: "candidate" ++ }); ++ ++ const result = policy.evaluate({ ++ record, ++ run, ++ riskTags: [] ++ }); ++ ++ expect(result.transition?.from).toBe("candidate"); ++ expect(result.transition?.to).toBe("verified"); ++ expect(result.transition?.transitionKey).toBe( ++ "memory-candidate-policy:candidate->verified" ++ ); ++ expect(result.rejectionReasons).toEqual([]); ++ expect(result.nextEvidence.sampleCount).toBe(1); ++ expect(result.nextEvidence.successCount).toBe(1); ++ expect(result.nextEvidence.riskFlagCount).toBe(0); ++ expect(result.nextEvidence.successRate).toBe(1); ++ }); ++ ++ it("keeps candidate when evidence is insufficient and returns structured reasons", () => { ++ const policy = new MemoryPromotionPolicy({ ++ minSamplesForVerified: 2, ++ minSuccessesForVerified: 2 ++ }); ++ const run = createRun({ ++ status: "failed", ++ sql: undefined, ++ error: "execution failed" ++ }); ++ const record = createRecord({ ++ status: "candidate" ++ }); ++ ++ const result = policy.evaluate({ ++ record, ++ run, ++ riskTags: ["delivery_mapper_failed"] ++ }); ++ ++ expect(result.transition).toBeUndefined(); ++ expect(result.rejectionReasons).toEqual( ++ expect.arrayContaining([ ++ "insufficient_samples_for_verified", ++ "insufficient_successes_for_verified", ++ "risk_threshold_exceeded_for_verified" ++ ]) ++ ); ++ expect(result.nextEvidence.sampleCount).toBe(1); ++ expect(result.nextEvidence.successCount).toBe(0); ++ expect(result.nextEvidence.riskFlagCount).toBe(1); ++ expect(result.nextEvidence.successRate).toBe(0); ++ }); ++ ++ it("transitions verified to production only when production thresholds are met", () => { ++ const policy = new MemoryPromotionPolicy(); ++ const run = createRun({ ++ runId: "run-memory-policy-2" ++ }); ++ const record = createRecord({ ++ status: "verified", ++ evidence: { ++ sampleCount: 2, ++ successCount: 2, ++ riskFlagCount: 0, ++ successRate: 1 ++ }, ++ version: 2, ++ lastRunId: "run-memory-policy-1" ++ }); ++ ++ const result = policy.evaluate({ ++ record, ++ run, ++ riskTags: [] ++ }); ++ ++ expect(result.transition?.from).toBe("verified"); ++ expect(result.transition?.to).toBe("production"); ++ expect(result.rejectionReasons).toEqual([]); ++ expect(result.nextEvidence.sampleCount).toBe(3); ++ expect(result.nextEvidence.successCount).toBe(3); ++ expect(result.nextEvidence.successRate).toBe(1); ++ }); ++ ++ it("keeps production unchanged and returns already_production reason", () => { ++ const policy = new MemoryPromotionPolicy(); ++ const run = createRun({ ++ runId: "run-memory-policy-production" ++ }); ++ const record = createRecord({ ++ status: "production", ++ evidence: { ++ sampleCount: 3, ++ successCount: 3, ++ riskFlagCount: 0, ++ successRate: 1 ++ }, ++ version: 3 ++ }); ++ ++ const result = policy.evaluate({ ++ record, ++ run, ++ riskTags: [] ++ }); ++ ++ expect(result.transition).toBeUndefined(); ++ expect(result.rejectionReasons).toEqual(["already_production"]); ++ expect(result.nextEvidence.sampleCount).toBe(4); ++ expect(result.nextEvidence.successCount).toBe(4); ++ expect(result.nextEvidence.successRate).toBe(1); ++ }); ++ ++ it("rejects invalid transition key construction", () => { ++ const policy = new MemoryPromotionPolicy(); ++ expect(() => ++ policy.buildTransitionKey({ ++ candidateId: "memory-candidate-policy", ++ from: "production", ++ to: "verified" ++ }) ++ ).toThrow("非法记忆状态迁移"); ++ }); ++}); +diff --git a/apps/backend/test/unit/planner-cache.service.spec.ts b/apps/backend/test/unit/planner-cache.service.spec.ts +new file mode 100644 +index 0000000..f84c34d +--- /dev/null ++++ b/apps/backend/test/unit/planner-cache.service.spec.ts +@@ -0,0 +1,75 @@ ++import { PlannerCacheService } from "../../src/modules/agent/planner/planner-cache.service"; ++ ++describe("planner cache service", () => { ++ it("builds cache key using datasource + query hash + semantic version", () => { ++ const service = new PlannerCacheService(); ++ ++ const stored = service.store({ ++ datasourceId: "ds-cache", ++ question: "SELECT count(*) FROM orders", ++ semanticVersion: 3, ++ strategy: "direct_sql", ++ summary: "initial" ++ }); ++ ++ expect(stored.cacheKey).toContain("ds-cache:"); ++ expect(stored.cacheKey).toContain(":v3"); ++ }); ++ ++ it("returns cache hit for same question and semantic version", () => { ++ const service = new PlannerCacheService(); ++ service.store({ ++ datasourceId: "ds-cache-hit", ++ question: "orders amount", ++ semanticVersion: 2, ++ strategy: "direct_sql", ++ summary: "hit-target" ++ }); ++ ++ const lookup = service.lookup({ ++ datasourceId: "ds-cache-hit", ++ question: "orders amount", ++ semanticVersion: 2 ++ }); ++ ++ expect(lookup.status).toBe("hit"); ++ expect(lookup.entry?.strategy).toBe("direct_sql"); ++ }); ++ ++ it("invalidates old-version entries when semantic version changes", () => { ++ const service = new PlannerCacheService(); ++ service.store({ ++ datasourceId: "ds-cache-version", ++ question: "orders amount", ++ semanticVersion: 1, ++ strategy: "fallback_sql", ++ summary: "v1" ++ }); ++ service.store({ ++ datasourceId: "ds-cache-version", ++ question: "orders amount", ++ semanticVersion: 2, ++ strategy: "direct_sql", ++ summary: "v2" ++ }); ++ ++ const removed = service.invalidateByDatasourceAndSemanticVersion( ++ "ds-cache-version", ++ 2 ++ ); ++ const v1Lookup = service.lookup({ ++ datasourceId: "ds-cache-version", ++ question: "orders amount", ++ semanticVersion: 1 ++ }); ++ const v2Lookup = service.lookup({ ++ datasourceId: "ds-cache-version", ++ question: "orders amount", ++ semanticVersion: 2 ++ }); ++ ++ expect(removed).toBeGreaterThanOrEqual(1); ++ expect(v1Lookup.status).toBe("miss"); ++ expect(v2Lookup.status).toBe("hit"); ++ }); ++}); +diff --git a/apps/backend/test/unit/planner-version-lock.service.spec.ts b/apps/backend/test/unit/planner-version-lock.service.spec.ts +new file mode 100644 +index 0000000..f5f0038 +--- /dev/null ++++ b/apps/backend/test/unit/planner-version-lock.service.spec.ts +@@ -0,0 +1,85 @@ ++import { PlannerVersionLockService } from "../../src/modules/agent/planner/planner-version-lock.service"; ++import type { SemanticRegistryService } from "../../src/modules/semantic-registry/semantic-registry.service"; ++import { ++ SEMANTIC_REGISTRY_DEGRADED_RISK_TAG, ++ SEMANTIC_VERSION_NOT_FOUND_REASON ++} from "../../src/modules/semantic-registry/semantic-registry.service"; ++ ++describe("planner version lock service", () => { ++ it("locks requested semantic version when it exists", async () => { ++ const semanticRegistry = { ++ resolveTerm: jest.fn().mockResolvedValue({ ++ status: "ready", ++ semantic_version: 3, ++ risk_tags: ["semantic_registry_v3"] ++ }) ++ } as unknown as SemanticRegistryService; ++ const service = new PlannerVersionLockService(semanticRegistry); ++ ++ const result = await service.resolve({ ++ question: "GMV 趋势", ++ requestedSemanticVersion: 3 ++ }); ++ ++ expect(result.lockStatus).toBe("locked"); ++ expect(result.semanticVersion).toBe(3); ++ expect(result.fallbackApplied).toBe(false); ++ expect(result.riskTags).toEqual(expect.arrayContaining(["semantic_registry_v3"])); ++ }); ++ ++ it("falls back to active version when requested version is missing", async () => { ++ const semanticRegistry = { ++ resolveTerm: jest ++ .fn() ++ .mockResolvedValueOnce({ ++ status: "degraded", ++ semantic_version: 9, ++ degrade_reason: SEMANTIC_VERSION_NOT_FOUND_REASON, ++ risk_tags: [SEMANTIC_REGISTRY_DEGRADED_RISK_TAG] ++ }) ++ .mockResolvedValueOnce({ ++ status: "ready", ++ semantic_version: 2, ++ risk_tags: ["semantic_registry_active"] ++ }) ++ } as unknown as SemanticRegistryService; ++ const service = new PlannerVersionLockService(semanticRegistry); ++ ++ const result = await service.resolve({ ++ question: "GMV 统计", ++ requestedSemanticVersion: 9 ++ }); ++ ++ expect(result.lockStatus).toBe("fallback"); ++ expect(result.semanticVersion).toBe(2); ++ expect(result.fallbackApplied).toBe(true); ++ expect(result.degradeReason).toBe(SEMANTIC_VERSION_NOT_FOUND_REASON); ++ expect(result.riskTags).toEqual( ++ expect.arrayContaining([ ++ SEMANTIC_REGISTRY_DEGRADED_RISK_TAG, ++ "semantic_registry_active" ++ ]) ++ ); ++ }); ++ ++ it("returns degraded decision when neither requested nor active version can be resolved", async () => { ++ const semanticRegistry = { ++ resolveTerm: jest.fn().mockResolvedValue({ ++ status: "degraded", ++ degrade_reason: SEMANTIC_VERSION_NOT_FOUND_REASON, ++ risk_tags: [SEMANTIC_REGISTRY_DEGRADED_RISK_TAG] ++ }) ++ } as unknown as SemanticRegistryService; ++ const service = new PlannerVersionLockService(semanticRegistry); ++ ++ const result = await service.resolve({ ++ question: "orders 统计", ++ requestedSemanticVersion: 99 ++ }); ++ ++ expect(result.lockStatus).toBe("degraded"); ++ expect(result.fallbackApplied).toBe(false); ++ expect(result.degradeReason).toBe(SEMANTIC_VERSION_NOT_FOUND_REASON); ++ expect(result.riskTags).toContain(SEMANTIC_REGISTRY_DEGRADED_RISK_TAG); ++ }); ++}); +diff --git a/apps/backend/test/unit/rag-chunking.service.spec.ts b/apps/backend/test/unit/rag-chunking.service.spec.ts +new file mode 100644 +index 0000000..59b879d +--- /dev/null ++++ b/apps/backend/test/unit/rag-chunking.service.spec.ts +@@ -0,0 +1,50 @@ ++import { getChunkProfileConfig } from "../../src/modules/rag/ingestion/chunk-profiles"; ++import { RagChunkingService } from "../../src/modules/rag/ingestion/rag-chunking.service"; ++ ++const buildLongSchemaText = (): string => ++ Array.from({ length: 260 }, (_, index) => `orders_column_${index} DECIMAL(10,2) NOT NULL`) ++ .join(", "); ++ ++describe("RagChunkingService", () => { ++ const service = new RagChunkingService(); ++ ++ it("keeps overlap and preserves table/column metadata for long text chunks", () => { ++ const chunks = service.chunk({ ++ documentId: "doc-1", ++ datasourceId: "ds-1", ++ domain: "schema", ++ chunkProfile: "schema_table", ++ content: buildLongSchemaText(), ++ documentChecksum: "checksum-1", ++ tableNames: ["orders"], ++ columnNames: ["amount"] ++ }); ++ const profile = getChunkProfileConfig("schema_table"); ++ ++ expect(chunks.length).toBeGreaterThan(1); ++ expect(chunks[1]?.startOffset).toBe(chunks[0]?.endOffset - profile.overlapCharacters); ++ for (const chunk of chunks) { ++ expect(chunk.tableNames).toEqual(["orders"]); ++ expect(chunk.columnNames).toEqual(["amount"]); ++ } ++ }); ++ ++ it("generates stable chunk ids for same input", () => { ++ const input = { ++ documentId: "doc-stable", ++ datasourceId: "ds-1", ++ domain: "sql_example", ++ chunkProfile: "sql_example" as const, ++ content: Array.from({ length: 180 }, (_, index) => `SELECT ${index} AS n`).join("\n"), ++ documentChecksum: "checksum-stable", ++ tableNames: ["orders"], ++ columnNames: ["id"] ++ }; ++ ++ const first = service.chunk(input); ++ const second = service.chunk(input); ++ ++ expect(first.map((item) => item.id)).toEqual(second.map((item) => item.id)); ++ expect(first.map((item) => item.chunkOrder)).toEqual(second.map((item) => item.chunkOrder)); ++ }); ++}); +diff --git a/apps/backend/test/unit/rag-event-consumer.spec.ts b/apps/backend/test/unit/rag-event-consumer.spec.ts +new file mode 100644 +index 0000000..41521a7 +--- /dev/null ++++ b/apps/backend/test/unit/rag-event-consumer.spec.ts +@@ -0,0 +1,111 @@ ++import { RagEventConsumerService } from "../../src/modules/rag/events/rag-event-consumer.service"; ++import type { BuildRagIndexJob } from "../../src/modules/rag/jobs/build-rag-index.job"; ++import type { RagReplayRepository } from "../../src/modules/rag/observability/rag-replay.repository"; ++import type { AuditLogRepository } from "../../src/modules/data/persistence/audit-log.repository"; ++ ++describe("RagEventConsumerService", () => { ++ it("processes event once and returns duplicate on repeated idempotency key", async () => { ++ const buildJob: Pick = { ++ run: jest.fn().mockResolvedValue({ ++ indexVersionId: "idx-event-unit-1", ++ datasourceId: "ds-event-unit", ++ status: "active", ++ entryCount: 2, ++ archivedChannels: ["lexical", "dense"], ++ denseMode: "placeholder_vector_string" ++ }) ++ }; ++ const replayRepository: Pick = { ++ writeReplay: jest.fn().mockResolvedValue(undefined) ++ }; ++ const auditRepository: Pick = { ++ appendEvent: jest.fn().mockResolvedValue(undefined) ++ }; ++ ++ const service = new RagEventConsumerService( ++ buildJob as BuildRagIndexJob, ++ replayRepository as RagReplayRepository, ++ auditRepository as AuditLogRepository ++ ); ++ ++ const first = await service.consumeEvent({ ++ eventId: "evt-unit-1", ++ datasourceId: "ds-event-unit", ++ sourceVersion: "schema-v1", ++ eventType: "ddl_changed", ++ runId: "run-event-unit-1", ++ occurredAt: "2026-04-18T01:00:00.000Z" ++ }); ++ const second = await service.consumeEvent({ ++ eventId: "evt-unit-1", ++ datasourceId: "ds-event-unit", ++ sourceVersion: "schema-v1", ++ eventType: "ddl_changed", ++ runId: "run-event-unit-1", ++ occurredAt: "2026-04-18T01:00:01.000Z" ++ }); ++ ++ expect(first.status).toBe("processed"); ++ expect(first.indexVersionId).toBe("idx-event-unit-1"); ++ expect(second.status).toBe("duplicate"); ++ expect(buildJob.run).toHaveBeenCalledTimes(1); ++ expect(replayRepository.writeReplay).toHaveBeenCalledTimes(2); ++ expect(auditRepository.appendEvent).toHaveBeenCalledTimes(1); ++ }); ++ ++ it("schedules retries and moves event to DLQ after max attempts", async () => { ++ const buildJob: Pick = { ++ run: jest.fn().mockRejectedValue(new Error("index build failed")) ++ }; ++ const replayRepository: Pick = { ++ writeReplay: jest.fn().mockResolvedValue(undefined) ++ }; ++ const auditRepository: Pick = { ++ appendEvent: jest.fn().mockResolvedValue(undefined) ++ }; ++ ++ const service = new RagEventConsumerService( ++ buildJob as BuildRagIndexJob, ++ replayRepository as RagReplayRepository, ++ auditRepository as AuditLogRepository ++ ); ++ ++ const first = await service.consumeEvent({ ++ eventId: "evt-unit-dlq-1", ++ datasourceId: "ds-event-unit", ++ sourceVersion: "schema-v2", ++ eventType: "sql_feedback_positive", ++ runId: "run-event-unit-dlq" ++ }); ++ const second = await service.consumeEvent({ ++ eventId: "evt-unit-dlq-1", ++ datasourceId: "ds-event-unit", ++ sourceVersion: "schema-v2", ++ eventType: "sql_feedback_positive", ++ runId: "run-event-unit-dlq" ++ }); ++ const third = await service.consumeEvent({ ++ eventId: "evt-unit-dlq-1", ++ datasourceId: "ds-event-unit", ++ sourceVersion: "schema-v2", ++ eventType: "sql_feedback_positive", ++ runId: "run-event-unit-dlq" ++ }); ++ const duplicateAfterDlq = await service.consumeEvent({ ++ eventId: "evt-unit-dlq-1", ++ datasourceId: "ds-event-unit", ++ sourceVersion: "schema-v2", ++ eventType: "sql_feedback_positive", ++ runId: "run-event-unit-dlq" ++ }); ++ ++ expect(first.status).toBe("retry_scheduled"); ++ expect(first.attempts).toBe(1); ++ expect(second.status).toBe("retry_scheduled"); ++ expect(second.attempts).toBe(2); ++ expect(third.status).toBe("dlq"); ++ expect(third.attempts).toBe(3); ++ expect(duplicateAfterDlq.status).toBe("duplicate"); ++ expect(buildJob.run).toHaveBeenCalledTimes(3); ++ }); ++}); +diff --git a/apps/backend/test/unit/rag-rerank.service.spec.ts b/apps/backend/test/unit/rag-rerank.service.spec.ts +new file mode 100644 +index 0000000..2b51d6f +--- /dev/null ++++ b/apps/backend/test/unit/rag-rerank.service.spec.ts +@@ -0,0 +1,179 @@ ++import { RagRerankService } from "../../src/modules/rag/rerank/rag-rerank.service"; ++import type { ModelRerankerAdapter } from "../../src/modules/rag/rerank/model-reranker.adapter"; ++import type { RagReplayRepository } from "../../src/modules/rag/observability/rag-replay.repository"; ++import type { RagRetrievalBundle } from "../../src/modules/rag/retrieval/rag-retrieval.types"; ++import { RagBudgetPolicy } from "../../src/modules/rag/perf/rag-budget-policy"; ++import { RagCacheKeyFactory } from "../../src/modules/rag/perf/rag-cache-key.factory"; ++import { RagQueryCacheService } from "../../src/modules/rag/perf/rag-query-cache.service"; ++import type { RagQualityService } from "../../src/modules/rag/quality/rag-quality.service"; ++ ++const createBundle = (): RagRetrievalBundle => ({ ++ query: "orders amount", ++ run_id: "run-rerank-unit", ++ datasource_id: "ds-rerank-unit", ++ index_version_id: "idx-rerank-unit", ++ status: "ready", ++ degrade_reasons: [], ++ lane_results: { ++ lexical: { ++ lane: "lexical", ++ status: "ok", ++ timeout_ms: 100, ++ elapsed_ms: 10, ++ hits: [] ++ }, ++ dense: { ++ lane: "dense", ++ status: "ok", ++ timeout_ms: 100, ++ elapsed_ms: 11, ++ hits: [] ++ }, ++ graph: { ++ lane: "graph", ++ status: "ok", ++ timeout_ms: 100, ++ elapsed_ms: 12, ++ hits: [] ++ } ++ }, ++ candidates: [ ++ { ++ chunk_id: "chunk-a", ++ source_lane: "lexical", ++ evidence: ["token:orders"], ++ score: 0.7, ++ lane_scores: { lexical: 0.7 }, ++ lane_ranks: { lexical: 1 }, ++ chunk: { ++ chunk_id: "chunk-a", ++ content: "table orders(id, amount)", ++ metadata: { ++ datasourceId: "ds-rerank-unit", ++ indexVersionId: "idx-rerank-unit", ++ chunkId: "chunk-a", ++ domain: "schema", ++ tableNames: ["orders"], ++ columnNames: ["id", "amount"], ++ sourceMetadata: {} ++ } ++ } ++ }, ++ { ++ chunk_id: "chunk-b", ++ source_lane: "dense", ++ evidence: ["cosine:0.8"], ++ score: 0.6, ++ lane_scores: { dense: 0.8 }, ++ lane_ranks: { dense: 1 }, ++ chunk: { ++ chunk_id: "chunk-b", ++ content: "GMV means gross merchandise volume", ++ metadata: { ++ datasourceId: "ds-rerank-unit", ++ indexVersionId: "idx-rerank-unit", ++ chunkId: "chunk-b", ++ domain: "semantic_term", ++ tableNames: ["orders"], ++ columnNames: ["amount"], ++ sourceMetadata: {} ++ } ++ } ++ } ++ ] ++}); ++ ++describe("rag rerank service", () => { ++ function createService( ++ adapter: Pick, ++ replay: Pick ++ ): RagRerankService { ++ return new RagRerankService( ++ adapter as ModelRerankerAdapter, ++ replay as RagReplayRepository, ++ new RagBudgetPolicy(), ++ new RagCacheKeyFactory(), ++ new RagQueryCacheService(), ++ ({ ++ recordCacheBudget: jest.fn() ++ } as unknown) as RagQualityService ++ ); ++ } ++ ++ it("skips secondary rerank when candidate count is below threshold", async () => { ++ const adapter: Pick = { ++ rerank: jest.fn().mockResolvedValue([]) ++ }; ++ const replay: Pick = { ++ writeReplay: jest.fn().mockResolvedValue(undefined) ++ }; ++ const service = createService(adapter, replay); ++ const bundle = createBundle(); ++ ++ const response = await service.rerank({ ++ retrievalBundle: bundle, ++ secondaryMinCandidates: 10 ++ }); ++ ++ expect(adapter.rerank).not.toHaveBeenCalled(); ++ expect(response.retrieval_bundle.reranked?.length).toBe(2); ++ expect(response.retrieval_bundle.degrade_reasons).toEqual( ++ expect.arrayContaining(["secondary_rerank_skipped_low_candidates"]) ++ ); ++ }); ++ ++ it("falls back to primary ranking when secondary rerank fails", async () => { ++ const adapter: Pick = { ++ rerank: jest.fn().mockRejectedValue(new Error("secondary_model_unavailable")) ++ }; ++ const replay: Pick = { ++ writeReplay: jest.fn().mockResolvedValue(undefined) ++ }; ++ const service = createService(adapter, replay); ++ const bundle = createBundle(); ++ ++ const response = await service.rerank({ ++ retrievalBundle: bundle, ++ secondaryMinCandidates: 2 ++ }); ++ ++ expect(adapter.rerank).toHaveBeenCalledTimes(1); ++ expect(response.retrieval_bundle.reranked?.every((item) => item.secondary_score === undefined)).toBe( ++ true ++ ); ++ expect(response.retrieval_bundle.degrade_reasons).toEqual( ++ expect.arrayContaining(["secondary_model_unavailable"]) ++ ); ++ }); ++ ++ it("applies secondary scores when model rerank succeeds", async () => { ++ const adapter: Pick = { ++ rerank: jest.fn().mockResolvedValue([ ++ { ++ candidateId: "chunk-a", ++ score: 0.2, ++ reason: "less relevant" ++ }, ++ { ++ candidateId: "chunk-b", ++ score: 0.95, ++ reason: "strong semantic match" ++ } ++ ]) ++ }; ++ const replay: Pick = { ++ writeReplay: jest.fn().mockResolvedValue(undefined) ++ }; ++ const service = createService(adapter, replay); ++ const bundle = createBundle(); ++ ++ const response = await service.rerank({ ++ retrievalBundle: bundle, ++ secondaryMinCandidates: 2 ++ }); ++ ++ expect(adapter.rerank).toHaveBeenCalledTimes(1); ++ expect(response.retrieval_bundle.reranked?.[0]?.chunk_id).toBe("chunk-b"); ++ expect(response.retrieval_bundle.reranked?.[0]?.secondary_score).toBeCloseTo(0.95); ++ }); ++}); +diff --git a/apps/backend/test/unit/rrf-fusion.spec.ts b/apps/backend/test/unit/rrf-fusion.spec.ts +new file mode 100644 +index 0000000..be5ae04 +--- /dev/null ++++ b/apps/backend/test/unit/rrf-fusion.spec.ts +@@ -0,0 +1,126 @@ ++import { fuseWithRrf } from "../../src/modules/rag/retrieval/fusion/rrf-fusion"; ++ ++describe("rrf fusion", () => { ++ it("fuses lane scores with deterministic tie-break by chunk_id", () => { ++ const result = fuseWithRrf({ ++ laneHits: { ++ lexical: [ ++ { ++ lane: "lexical", ++ chunk_id: "chunk-b", ++ score: 1, ++ evidence: ["lexical"], ++ chunk: { ++ chunk_id: "chunk-b", ++ content: "b", ++ metadata: { ++ datasourceId: "ds", ++ indexVersionId: "idx", ++ chunkId: "chunk-b", ++ domain: "schema", ++ tableNames: [], ++ columnNames: [], ++ sourceMetadata: {} ++ } ++ } ++ }, ++ { ++ lane: "lexical", ++ chunk_id: "chunk-a", ++ score: 1, ++ evidence: ["lexical"], ++ chunk: { ++ chunk_id: "chunk-a", ++ content: "a", ++ metadata: { ++ datasourceId: "ds", ++ indexVersionId: "idx", ++ chunkId: "chunk-a", ++ domain: "schema", ++ tableNames: [], ++ columnNames: [], ++ sourceMetadata: {} ++ } ++ } ++ } ++ ], ++ dense: [], ++ graph: [] ++ }, ++ rankConstant: 60 ++ }); ++ ++ expect(result.map((item) => item.chunk_id)).toEqual(["chunk-a", "chunk-b"]); ++ expect(result[0]?.score).toBeGreaterThan(result[1]?.score ?? 0); ++ }); ++ ++ it("keeps stable source lane based on best rank then lane order", () => { ++ const result = fuseWithRrf({ ++ laneHits: { ++ lexical: [ ++ { ++ lane: "lexical", ++ chunk_id: "chunk-1", ++ score: 0.95, ++ evidence: ["lexical"], ++ chunk: { ++ chunk_id: "chunk-1", ++ content: "x", ++ metadata: { ++ datasourceId: "ds", ++ indexVersionId: "idx", ++ chunkId: "chunk-1", ++ domain: "schema", ++ tableNames: [], ++ columnNames: [], ++ sourceMetadata: {} ++ } ++ } ++ } ++ ], ++ dense: [ ++ { ++ lane: "dense", ++ chunk_id: "chunk-1", ++ score: 0.95, ++ evidence: ["dense"], ++ chunk: { ++ chunk_id: "chunk-1", ++ content: "x", ++ metadata: { ++ datasourceId: "ds", ++ indexVersionId: "idx", ++ chunkId: "chunk-1", ++ domain: "schema", ++ tableNames: [], ++ columnNames: [], ++ sourceMetadata: {} ++ } ++ } ++ } ++ ], ++ graph: [] ++ } ++ }); ++ ++ expect(result).toHaveLength(1); ++ expect(result[0]?.source_lane).toBe("lexical"); ++ expect(result[0]?.lane_ranks).toEqual({ ++ lexical: 1, ++ dense: 1 ++ }); ++ expect(result[0]?.evidence).toEqual(expect.arrayContaining(["lexical", "dense"])); ++ }); ++ ++ it("returns empty list when all lanes are empty", () => { ++ const result = fuseWithRrf({ ++ laneHits: { ++ lexical: [], ++ dense: [], ++ graph: [] ++ } ++ }); ++ ++ expect(result).toEqual([]); ++ }); ++}); +diff --git a/apps/backend/test/unit/sandbox-policy.spec.ts b/apps/backend/test/unit/sandbox-policy.spec.ts +new file mode 100644 +index 0000000..058b94b +--- /dev/null ++++ b/apps/backend/test/unit/sandbox-policy.spec.ts +@@ -0,0 +1,89 @@ ++import { join } from "node:path"; ++import { ++ DELIVERY_SANDBOX_POLICY_VERSION, ++ DEFAULT_DELIVERY_SANDBOX_POLICY, ++ createDeliverySandboxPolicy, ++ isSandboxFilesystemWriteAllowed, ++ isSandboxNetworkAllowed, ++ isSandboxProcessSpawnAllowed ++} from "../../src/modules/delivery/sandbox/sandbox-policy"; ++ ++describe("delivery sandbox policy", () => { ++ it("is explicit, versioned, and deny-by-default", () => { ++ expect(DEFAULT_DELIVERY_SANDBOX_POLICY.policyId).toBe( ++ "delivery.postprocess.sandbox" ++ ); ++ expect(DEFAULT_DELIVERY_SANDBOX_POLICY.version).toBe( ++ DELIVERY_SANDBOX_POLICY_VERSION ++ ); ++ expect(DEFAULT_DELIVERY_SANDBOX_POLICY.defaultAction).toBe("deny"); ++ expect(DEFAULT_DELIVERY_SANDBOX_POLICY.outboundNetwork.denyByDefault).toBe( ++ true ++ ); ++ expect(DEFAULT_DELIVERY_SANDBOX_POLICY.filesystemWrite.denyByDefault).toBe( ++ true ++ ); ++ expect(DEFAULT_DELIVERY_SANDBOX_POLICY.processSpawn.denyByDefault).toBe(true); ++ }); ++ ++ it("denies outbound network/process/file-write by default", () => { ++ expect( ++ isSandboxNetworkAllowed(DEFAULT_DELIVERY_SANDBOX_POLICY, { ++ host: "example.com", ++ protocol: "https", ++ port: 443 ++ }) ++ ).toBe(false); ++ expect( ++ isSandboxFilesystemWriteAllowed( ++ DEFAULT_DELIVERY_SANDBOX_POLICY, ++ join(process.cwd(), "tmp", "sandbox.out") ++ ) ++ ).toBe(false); ++ expect( ++ isSandboxProcessSpawnAllowed(DEFAULT_DELIVERY_SANDBOX_POLICY, "node") ++ ).toBe(false); ++ }); ++ ++ it("allows only explicit allowlist entries", () => { ++ const policy = createDeliverySandboxPolicy({ ++ outboundNetworkAllowlist: [ ++ { ++ host: "internal.example.com", ++ protocols: ["https"], ++ ports: [443] ++ } ++ ], ++ filesystemWriteAllowlist: [join(process.cwd(), "data", "sandbox-output")], ++ processSpawnAllowlist: ["node"] ++ }); ++ ++ expect( ++ isSandboxNetworkAllowed(policy, { ++ host: "internal.example.com", ++ protocol: "https", ++ port: 443 ++ }) ++ ).toBe(true); ++ expect( ++ isSandboxNetworkAllowed(policy, { ++ host: "internal.example.com", ++ protocol: "http", ++ port: 80 ++ }) ++ ).toBe(false); ++ ++ expect( ++ isSandboxFilesystemWriteAllowed( ++ policy, ++ join(process.cwd(), "data", "sandbox-output", "artifact.json") ++ ) ++ ).toBe(true); ++ expect( ++ isSandboxFilesystemWriteAllowed(policy, join(process.cwd(), "tmp", "artifact.json")) ++ ).toBe(false); ++ ++ expect(isSandboxProcessSpawnAllowed(policy, "node")).toBe(true); ++ expect(isSandboxProcessSpawnAllowed(policy, "bash")).toBe(false); ++ }); ++}); +diff --git a/apps/backend/test/unit/semantic-registry.service.spec.ts b/apps/backend/test/unit/semantic-registry.service.spec.ts +new file mode 100644 +index 0000000..93af077 +--- /dev/null ++++ b/apps/backend/test/unit/semantic-registry.service.spec.ts +@@ -0,0 +1,191 @@ ++import { SemanticRegistryService } from "../../src/modules/semantic-registry/semantic-registry.service"; ++import type { AppConfigService } from "../../src/modules/config/app-config.service"; ++import { ++ SEMANTIC_REGISTRY_DEGRADED_RISK_TAG, ++ SEMANTIC_TERM_NOT_FOUND_REASON, ++ SEMANTIC_VERSION_NOT_FOUND_REASON ++} from "../../src/modules/semantic-registry/semantic-registry.service"; ++ ++const createService = () => ++ new SemanticRegistryService( ++ { ++ databaseUrl: "" ++ } as AppConfigService ++ ); ++ ++describe("semantic registry service", () => { ++ it("publishes a semantic version and resolves term with explicit version", async () => { ++ const service = createService(); ++ ++ await service.publishVersion({ ++ domain: "semantic_term", ++ semanticVersion: 1, ++ releaseSummary: "r3 semantic baseline", ++ auditSummary: "unit publish", ++ terms: [ ++ { ++ term: "gmv", ++ canonicalKey: "metric.gmv", ++ definition: "Gross merchandise volume", ++ binding: "{\"metric\":\"sum(order_amount)\"}" ++ } ++ ], ++ riskTags: ["semantic_registry_v1_ready"] ++ }); ++ ++ const result = await service.resolveTerm({ ++ domain: "semantic_term", ++ term: "GMV", ++ semanticVersion: 1 ++ }); ++ ++ expect(result.status).toBe("ready"); ++ expect(result.semantic_version).toBe(1); ++ expect(result.term?.canonical_key).toBe("metric.gmv"); ++ expect(result.risk_tags).toEqual( ++ expect.arrayContaining(["semantic_registry_v1_ready"]) ++ ); ++ }); ++ ++ it("defaults to active version but supports stable old-version read", async () => { ++ const service = createService(); ++ ++ await service.publishVersion({ ++ domain: "semantic_term", ++ semanticVersion: 1, ++ releaseSummary: "v1", ++ auditSummary: "seed v1", ++ terms: [ ++ { ++ term: "gmv", ++ canonicalKey: "metric.gmv.v1", ++ definition: "GMV v1", ++ binding: "{\"metric\":\"sum(order_amount)\"}" ++ } ++ ] ++ }); ++ await service.publishVersion({ ++ domain: "semantic_term", ++ semanticVersion: 2, ++ releaseSummary: "v2", ++ auditSummary: "seed v2", ++ terms: [ ++ { ++ term: "gmv", ++ canonicalKey: "metric.gmv.v2", ++ definition: "GMV v2", ++ binding: "{\"metric\":\"sum(amount_paid)\"}" ++ } ++ ] ++ }); ++ ++ const active = await service.resolveTerm({ ++ domain: "semantic_term", ++ term: "gmv" ++ }); ++ const oldVersion = await service.resolveTerm({ ++ domain: "semantic_term", ++ term: "gmv", ++ semanticVersion: 1 ++ }); ++ ++ expect(active.status).toBe("ready"); ++ expect(active.semantic_version).toBe(2); ++ expect(active.term?.canonical_key).toBe("metric.gmv.v2"); ++ expect(oldVersion.status).toBe("ready"); ++ expect(oldVersion.semantic_version).toBe(1); ++ expect(oldVersion.term?.canonical_key).toBe("metric.gmv.v1"); ++ }); ++ ++ it("returns controlled degraded result for missing version or term", async () => { ++ const service = createService(); ++ ++ await service.publishVersion({ ++ domain: "schema", ++ semanticVersion: 1, ++ releaseSummary: "schema-v1", ++ auditSummary: "seed schema", ++ terms: [ ++ { ++ term: "orders", ++ canonicalKey: "table.orders", ++ definition: "orders table", ++ binding: "{\"table\":\"orders\"}" ++ } ++ ] ++ }); ++ ++ const missingVersion = await service.resolveTerm({ ++ domain: "schema", ++ term: "orders", ++ semanticVersion: 9 ++ }); ++ const missingTerm = await service.resolveTerm({ ++ domain: "schema", ++ term: "payments", ++ semanticVersion: 1 ++ }); ++ ++ expect(missingVersion.status).toBe("degraded"); ++ expect(missingVersion.degrade_reason).toBe(SEMANTIC_VERSION_NOT_FOUND_REASON); ++ expect(missingVersion.risk_tags).toContain(SEMANTIC_REGISTRY_DEGRADED_RISK_TAG); ++ expect(missingTerm.status).toBe("degraded"); ++ expect(missingTerm.degrade_reason).toBe(SEMANTIC_TERM_NOT_FOUND_REASON); ++ expect(missingTerm.risk_tags).toContain(SEMANTIC_REGISTRY_DEGRADED_RISK_TAG); ++ }); ++ ++ it("resolves datasource-scoped domain first and falls back to global", async () => { ++ const service = createService(); ++ const datasourceScopedDomain = service.buildDatasourceScopedDomain( ++ "semantic_term", ++ "ds-semantic-unit" ++ ); ++ ++ await service.publishVersion({ ++ domain: "semantic_term", ++ semanticVersion: 1, ++ releaseSummary: "global semantic v1", ++ auditSummary: "global baseline", ++ terms: [ ++ { ++ term: "gmv", ++ canonicalKey: "metric.gmv.global", ++ definition: "global gmv", ++ binding: "{\"metric\":\"sum(order_amount)\"}" ++ } ++ ] ++ }); ++ await service.publishVersion({ ++ domain: datasourceScopedDomain, ++ semanticVersion: 1, ++ releaseSummary: "ds semantic v1", ++ auditSummary: "datasource baseline", ++ terms: [ ++ { ++ term: "gmv", ++ canonicalKey: "metric.gmv.datasource", ++ definition: "datasource gmv", ++ binding: "{\"metric\":\"sum(amount_paid)\"}" ++ } ++ ] ++ }); ++ ++ const datasourceHit = await service.resolveTerm({ ++ domain: "semantic_term", ++ datasourceId: "ds-semantic-unit", ++ term: "gmv" ++ }); ++ const globalFallback = await service.resolveTerm({ ++ domain: "semantic_term", ++ datasourceId: "ds-semantic-other", ++ term: "gmv" ++ }); ++ ++ expect(datasourceHit.status).toBe("ready"); ++ expect(datasourceHit.term?.canonical_key).toBe("metric.gmv.datasource"); ++ expect(datasourceHit.matched_scope).toBe("datasource"); ++ expect(globalFallback.status).toBe("ready"); ++ expect(globalFallback.term?.canonical_key).toBe("metric.gmv.global"); ++ expect(globalFallback.matched_scope).toBe("global"); ++ }); ++}); +diff --git a/apps/backend/test/unit/skill-registry.service.spec.ts b/apps/backend/test/unit/skill-registry.service.spec.ts +new file mode 100644 +index 0000000..b1a3d0f +--- /dev/null ++++ b/apps/backend/test/unit/skill-registry.service.spec.ts +@@ -0,0 +1,72 @@ ++import { ++ SKILL_REGISTRY_UNAVAILABLE_REASON, ++ SkillRegistryService ++} from "../../src/modules/skill-registry/skill-registry.service"; ++ ++describe("SkillRegistryService", () => { ++ it("returns mapped skills by domain and term lookup", async () => { ++ const service = new SkillRegistryService(); ++ ++ const result = await service.resolveSkills({ ++ domain: "semantic_term", ++ term: "GMV", ++ context: { ++ question: "请按 GMV 趋势统计", ++ tableNames: ["orders"] ++ } ++ }); ++ ++ expect(result.degrade_reason).toBeUndefined(); ++ expect(result.skills.map((item) => item.key)).toEqual( ++ expect.arrayContaining(["metric_term_resolution", "aggregation_sql_builder"]) ++ ); ++ expect(result.context).toEqual( ++ expect.arrayContaining([ ++ expect.objectContaining({ ++ source: "skill_registry", ++ domain: "semantic_term", ++ term: "gmv", ++ matched_by: "term" ++ }) ++ ]) ++ ); ++ }); ++ ++ it("returns empty context when no binding is matched", async () => { ++ const service = new SkillRegistryService(); ++ ++ const result = await service.resolveSkills({ ++ domain: "semantic_term", ++ term: "unknown_metric", ++ context: { ++ question: "展示库存周转率" ++ } ++ }); ++ ++ expect(result).toEqual({ ++ skills: [], ++ context: [] ++ }); ++ }); ++ ++ it("returns controlled degradation when registry lookup throws", async () => { ++ const service = new SkillRegistryService(); ++ jest ++ .spyOn(service as unknown as { lookupBindings: () => Promise }, "lookupBindings") ++ .mockRejectedValue(new Error("registry unavailable")); ++ ++ const result = await service.resolveSkills({ ++ domain: "semantic_term", ++ term: "gmv", ++ context: { ++ question: "按 gmv 聚合" ++ } ++ }); ++ ++ expect(result).toEqual({ ++ skills: [], ++ context: [], ++ degrade_reason: SKILL_REGISTRY_UNAVAILABLE_REASON ++ }); ++ }); ++}); +diff --git a/apps/backend/test/unit/sql-prompt.builder.spec.ts b/apps/backend/test/unit/sql-prompt.builder.spec.ts +index 8f4f4c7..e5204f9 100644 +--- a/apps/backend/test/unit/sql-prompt.builder.spec.ts ++++ b/apps/backend/test/unit/sql-prompt.builder.spec.ts +@@ -1,11 +1,21 @@ + import { SqlPromptBuilder } from "../../src/modules/agent/sql/sql-prompt.builder"; + + describe("SqlPromptBuilder", () => { + it("should build sql-specific prompt payload", () => { + const builder = new SqlPromptBuilder(); + const prompt = builder.build("统计订单状态分布"); + + expect(prompt.systemPrompt).toContain("read-only SQL"); + expect(prompt.userPrompt).toContain("Question: 统计订单状态分布"); + }); ++ ++ it("injects runtime template overlay when provided", () => { ++ const builder = new SqlPromptBuilder(); ++ const prompt = builder.build("统计订单状态分布", "sqlite", undefined, { ++ templateOverlay: "Always join users table with explicit alias." ++ }); ++ ++ expect(prompt.systemPrompt).toContain("Runtime template overlay"); ++ expect(prompt.systemPrompt).toContain("Always join users table with explicit alias."); ++ }); + }); +diff --git a/apps/frontend/src/app/glossary/page.tsx b/apps/frontend/src/app/glossary/page.tsx +index 7dd7b2a..0674557 100644 +--- a/apps/frontend/src/app/glossary/page.tsx ++++ b/apps/frontend/src/app/glossary/page.tsx +@@ -1,182 +1,655 @@ + "use client"; + +-import { BookOpen, Plus, Search } from "lucide-react"; ++import type { Datasource, GlossaryScope, GlossaryTerm, UpsertGlossaryTermResponse } from "@text2sql/shared-types"; ++import { BookOpen, Pencil, Plus, Search } from "lucide-react"; + import { useEffect, useMemo, useState } from "react"; + import { Badge } from "@/components/ui/badge"; + import { Button } from "@/components/ui/button"; + import { Input } from "@/components/ui/input"; + import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; + import { StateBlock } from "@/components/ui/state-block"; + import { Switch } from "@/components/ui/switch"; + import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow + } from "@/components/ui/table"; +-import { type GlossaryTerm, fetchGlossaryTerms } from "@/lib/platform-mock-adapter"; ++import { listDatasources } from "@/lib/api-client"; ++import { ++ AdminApiError, ++ createGlossaryTerm, ++ listGlossaryTerms, ++ toggleGlossaryTerm, ++ updateGlossaryTerm ++} from "@/lib/admin-api-client"; ++ ++type GlossaryFormState = { ++ term: string; ++ synonyms: string; ++ definition: string; ++ scope: GlossaryScope; ++ datasourceId: string; ++ priority: string; ++}; ++ ++type OperationNotice = { ++ variant: "success" | "idle"; ++ text: string; ++ termId?: string; ++}; ++ ++const DEFAULT_FORM_STATE: GlossaryFormState = { ++ term: "", ++ synonyms: "", ++ definition: "", ++ scope: "global", ++ datasourceId: "", ++ priority: "50" ++}; ++ ++function createIdempotencyKey(action: string): string { ++ return `glossary-${action}-${Date.now()}-${Math.random().toString(16).slice(2)}`; ++} ++ ++function resolveWriteError(error: unknown): string { ++ if (error instanceof AdminApiError && error.code === "FORBIDDEN") { ++ return "当前账号没有术语写权限(仅管理员可执行写操作)。"; ++ } ++ return error instanceof Error ? error.message : "术语写入失败"; ++} ++ ++function parseSynonyms(value: string): string[] { ++ const deduped = new Set(); ++ for (const item of value.split(/[,\n,]/)) { ++ const normalized = item.trim(); ++ if (!normalized) { ++ continue; ++ } ++ deduped.add(normalized); ++ } ++ return Array.from(deduped); ++} ++ ++function parsePriority(value: string): number { ++ const parsed = Number(value.trim()); ++ if (!Number.isFinite(parsed)) { ++ return 50; ++ } ++ return Math.min(100, Math.max(0, Math.round(parsed))); ++} ++ ++function sortTerms(items: GlossaryTerm[]): GlossaryTerm[] { ++ return [...items].sort((left, right) => { ++ if (left.priority !== right.priority) { ++ return right.priority - left.priority; ++ } ++ const updatedAtDiff = Date.parse(right.updatedAt) - Date.parse(left.updatedAt); ++ if (updatedAtDiff !== 0) { ++ return updatedAtDiff; ++ } ++ return left.term.localeCompare(right.term); ++ }); ++} ++ ++function buildOperationNotice( ++ action: string, ++ response: UpsertGlossaryTermResponse ++): OperationNotice { ++ const parts: string[] = [action]; ++ const decision = response.conflictDecision; ++ ++ if (response.term.scope === "datasource") { ++ parts.push("作用域规则:同名术语下,数据源作用域优先于全局。"); ++ } ++ ++ if (decision) { ++ if (decision.winnerTermId === response.term.id) { ++ parts.push( ++ decision.loserTermIds.length > 0 ++ ? `冲突裁决:当前术语胜出,覆盖 ${decision.loserTermIds.length} 个候选。` ++ : "冲突裁决:当前术语为唯一候选。" ++ ); ++ } else { ++ parts.push(`冲突裁决:当前术语未胜出,生效术语 ID 为 ${decision.winnerTermId}。`); ++ } ++ } ++ ++ if (response.linkageStatus === "degraded") { ++ parts.push("联动降级:术语已保存,RAG 联动当前处于降级状态。"); ++ } else if (response.linkageStatus === "error") { ++ parts.push("联动异常:术语已保存,但语义联动返回异常。"); ++ } else if (response.linkageStatus === "empty") { ++ parts.push("联动提示:当前写入未命中可联动内容。"); ++ } ++ ++ return { ++ variant: response.linkageStatus === "success" ? "success" : "idle", ++ text: parts.join(" "), ++ termId: response.term.id ++ }; ++} ++ ++function scopeLabel(term: GlossaryTerm, datasourceMap: Map): string { ++ if (term.scope === "global") { ++ return "全局"; ++ } ++ const datasourceId = term.datasourceId ?? ""; ++ return datasourceMap.get(datasourceId) ?? (datasourceId || "数据源"); ++} + + export default function GlossaryPage() { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [query, setQuery] = useState(""); + const [terms, setTerms] = useState([]); ++ const [datasources, setDatasources] = useState([]); ++ const [datasourceLoadError, setDatasourceLoadError] = useState(""); ++ + const [drawerOpen, setDrawerOpen] = useState(false); ++ const [editingTermId, setEditingTermId] = useState(null); ++ const [formState, setFormState] = useState(DEFAULT_FORM_STATE); ++ const [formSaving, setFormSaving] = useState(false); ++ const [formError, setFormError] = useState(""); ++ const [toggleLoadingTermId, setToggleLoadingTermId] = useState(null); ++ const [operationNotice, setOperationNotice] = useState(null); ++ ++ const datasourceMap = useMemo( ++ () => ++ new Map( ++ datasources.map((item) => [item.id, item.name] as const) ++ ), ++ [datasources] ++ ); ++ ++ const loadTerms = async (): Promise => { ++ setLoading(true); ++ setError(""); ++ try { ++ const data = await listGlossaryTerms({ ++ page: 1, ++ pageSize: 100 ++ }); ++ setTerms(sortTerms(data.items)); ++ } catch (loadError) { ++ setError(loadError instanceof Error ? loadError.message : "加载术语库失败"); ++ } finally { ++ setLoading(false); ++ } ++ }; + + useEffect(() => { + let active = true; +- const load = async () => { +- setLoading(true); +- setError(""); ++ const load = async (): Promise => { + try { +- const data = await fetchGlossaryTerms(); ++ const [termResult, datasourceResult] = await Promise.allSettled([ ++ listGlossaryTerms({ ++ page: 1, ++ pageSize: 100 ++ }), ++ listDatasources({ ++ includeUnavailable: true ++ }) ++ ]); + if (!active) { + return; + } +- setTerms(data); +- } catch (loadError) { +- if (!active) { +- return; ++ ++ if (termResult.status === "fulfilled") { ++ setTerms(sortTerms(termResult.value.items)); ++ setError(""); ++ } else { ++ setTerms([]); ++ setError( ++ termResult.reason instanceof Error ++ ? termResult.reason.message ++ : "加载术语库失败" ++ ); ++ } ++ ++ if (datasourceResult.status === "fulfilled") { ++ setDatasources(datasourceResult.value); ++ setDatasourceLoadError(""); ++ } else { ++ setDatasources([]); ++ setDatasourceLoadError("数据源列表加载失败,数据源范围需要手动填写。"); + } +- setError(loadError instanceof Error ? loadError.message : "加载术语库失败"); + } finally { + if (active) { + setLoading(false); + } + } + }; + void load(); + return () => { + active = false; + }; + }, []); + ++ const isEditing = editingTermId !== null; ++ + const filteredTerms = useMemo(() => { + const keyword = query.trim().toLowerCase(); + if (!keyword) { + return terms; + } + return terms.filter((term) => +- `${term.term} ${term.synonyms.join(" ")} ${term.description}`.toLowerCase().includes(keyword) ++ `${term.term} ${term.synonyms.join(" ")} ${term.definition} ${scopeLabel(term, datasourceMap)}` ++ .toLowerCase() ++ .includes(keyword) + ); +- }, [query, terms]); ++ }, [datasourceMap, query, terms]); ++ ++ const openCreateDrawer = () => { ++ setEditingTermId(null); ++ setFormError(""); ++ setFormState(DEFAULT_FORM_STATE); ++ setDrawerOpen(true); ++ }; ++ ++ const openEditDrawer = (term: GlossaryTerm) => { ++ setEditingTermId(term.id); ++ setFormError(""); ++ setFormState({ ++ term: term.term, ++ synonyms: term.synonyms.join(", "), ++ definition: term.definition, ++ scope: term.scope, ++ datasourceId: term.datasourceId ?? "", ++ priority: String(term.priority) ++ }); ++ setDrawerOpen(true); ++ }; ++ ++ const closeDrawer = () => { ++ setDrawerOpen(false); ++ setFormError(""); ++ }; ++ ++ const submitForm = async () => { ++ const normalizedTerm = formState.term.trim(); ++ const normalizedDefinition = formState.definition.trim(); ++ const normalizedDatasourceId = formState.datasourceId.trim(); ++ const priority = parsePriority(formState.priority); ++ const synonyms = parseSynonyms(formState.synonyms); ++ ++ if (!normalizedTerm) { ++ setFormError("请填写术语名称。"); ++ return; ++ } ++ if (!normalizedDefinition) { ++ setFormError("请填写术语定义。"); ++ return; ++ } ++ if (formState.scope === "datasource" && !normalizedDatasourceId) { ++ setFormError("选择数据源范围时必须指定 datasourceId。"); ++ return; ++ } ++ ++ setFormSaving(true); ++ setFormError(""); ++ try { ++ const idempotencyKey = createIdempotencyKey(isEditing ? "update" : "create"); ++ const response = isEditing ++ ? await updateGlossaryTerm( ++ editingTermId!, ++ { ++ definition: normalizedDefinition, ++ synonyms, ++ priority ++ }, ++ { idempotencyKey } ++ ) ++ : await createGlossaryTerm( ++ { ++ term: normalizedTerm, ++ definition: normalizedDefinition, ++ synonyms, ++ scope: formState.scope, ++ datasourceId: ++ formState.scope === "datasource" ? normalizedDatasourceId : undefined, ++ priority ++ }, ++ { idempotencyKey } ++ ); ++ ++ setOperationNotice( ++ buildOperationNotice(isEditing ? "术语已更新。" : "术语已创建。", response) ++ ); ++ await loadTerms(); ++ closeDrawer(); ++ } catch (saveError) { ++ setFormError(resolveWriteError(saveError)); ++ } finally { ++ setFormSaving(false); ++ } ++ }; ++ ++ const handleToggleTerm = async (term: GlossaryTerm): Promise => { ++ setToggleLoadingTermId(term.id); ++ setFormError(""); ++ try { ++ const response = await toggleGlossaryTerm(term.id, { ++ idempotencyKey: createIdempotencyKey("toggle") ++ }); ++ setOperationNotice(buildOperationNotice("术语状态已更新。", response)); ++ await loadTerms(); ++ } catch (toggleError) { ++ setFormError(resolveWriteError(toggleError)); ++ } finally { ++ setToggleLoadingTermId(null); ++ } ++ }; + + return ( +
+
+
+

业务术语库

+

统一业务术语和同义词,提高问数理解准确性。

+
+
+
+ + setQuery(event.target.value)} + className="pl-9" + placeholder="搜索术语或同义词" + /> +
+- ++ { ++ setDrawerOpen(nextOpen); ++ if (!nextOpen) { ++ setFormError(""); ++ } ++ }} ++ > + +- + + + +- 新增术语 +- 添加标准术语、同义词与作用范围。 ++ {isEditing ? "编辑术语" : "新增术语"} ++ 维护术语定义、作用范围和优先级。 + +
+
+- +- ++ ++ ++ setFormState((previous) => ({ ++ ...previous, ++ term: event.target.value ++ })) ++ } ++ disabled={isEditing || formSaving} ++ /> ++ {isEditing ? ( ++

++ 当前版本不支持修改术语名称;如需改名,请新建术语并停用旧术语。 ++

++ ) : null} +
+
+- +- ++ ++ ++ setFormState((previous) => ({ ++ ...previous, ++ synonyms: event.target.value ++ })) ++ } ++ disabled={formSaving} ++ /> +
+
+- ++ +