From 67901e860b256715e7a5db5d53221186f28e17bb Mon Sep 17 00:00:00 2001 From: 3494036618-eng <252820799+3494036618-eng@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:55:17 +0800 Subject: [PATCH] feat(demohouse/sales-intelligence-workbench): add sales intelligence workbench --- README.md | 1 + .../.github/workflows/ci.yml | 23 + .../sales-intelligence-workbench/.gitignore | 47 + .../sales-intelligence-workbench/CHANGELOG.md | 73 + .../CONTRIBUTING.md | 32 + .../sales-intelligence-workbench/LICENSE | 201 + .../sales-intelligence-workbench/README.md | 319 + .../sales-intelligence-workbench/SECURITY.md | 30 + .../THIRD_PARTY_NOTICES.md | 26 + .../UPSTREAM.json | 6 + .../backend/.env.example | 116 + .../backend/package.json | 37 + .../backend/scripts/backup-supabase.mjs | 174 + .../scripts/baseline-real-readonly.mjs | 244 + .../backend/scripts/bootstrap-workspace.mjs | 44 + .../backend/scripts/check-release-secrets.mjs | 136 + .../scripts/configure-supabase-data-api.mjs | 52 + .../backend/scripts/doctor.mjs | 30 + .../backend/scripts/export-workspace.mjs | 119 + .../backend/scripts/import-feishu-cli.mjs | 601 ++ .../backend/scripts/migrate-supabase.mjs | 52 + .../backend/scripts/preflight-real.mjs | 187 + .../backend/scripts/real-chain-check.mjs | 7 + .../backend/scripts/restore-supabase.mjs | 148 + .../backend/scripts/smoke-async-job-queue.mjs | 36 + .../scripts/smoke-paid-workflow-guard.mjs | 36 + .../backend/scripts/smoke-stage2-api.mjs | 134 + .../scripts/smoke-stage2-backup-package.mjs | 271 + .../backend/scripts/smoke-stage2-data-api.mjs | 166 + .../scripts/smoke-stage3-material-sync.mjs | 219 + .../backend/scripts/verify-business-chain.mjs | 524 ++ .../scripts/verify-openviking-qa-boundary.mjs | 54 + .../backend/scripts/verify-release-local.mjs | 75 + .../verify-supabase-security-boundary.mjs | 103 + .../backend/src/agents/dossierAgent.js | 1385 ++++ .../backend/src/app.js | 98 + .../backend/src/backup/supabaseBackup.js | 100 + .../backend/src/config/providerConfig.js | 191 + .../backend/src/config/runtimeEnv.js | 55 + .../backend/src/config/runtimePolicy.js | 142 + .../backend/src/evidence/claimGrounding.js | 270 + .../src/evidence/dossierEvidenceCompiler.js | 774 ++ .../backend/src/evidence/salesEvidence.js | 1247 ++++ .../backend/src/frontend/staticFrontend.js | 79 + .../backend/src/limits/paidWorkflowGuard.js | 228 + .../src/limits/providerCircuitBreaker.js | 85 + .../src/observability/providerRunStore.js | 272 + .../src/providers/citationValidator.js | 22 + .../backend/src/providers/dataProProvider.js | 352 + .../backend/src/providers/modelProvider.js | 699 ++ .../src/providers/openVikingProvider.js | 755 ++ .../backend/src/providers/providerResult.js | 98 + .../src/providers/supabaseDataProvider.js | 146 + .../backend/src/providers/supabaseProvider.js | 204 + .../src/providers/webSearchProvider.js | 221 + .../repositories/supabaseDataRepository.js | 806 +++ .../backend/src/routes/index.js | 473 ++ .../backend/src/security/authService.js | 632 ++ .../backend/src/security/rateLimiter.js | 76 + .../backend/src/server.js | 17 + .../src/services/adminStatusService.js | 162 + .../src/services/feishuImportTaskService.js | 274 + .../backend/src/services/providerService.js | 83 + .../backend/src/services/salesService.js | 6375 +++++++++++++++++ .../backend/src/sync/materialSync.js | 209 + .../backend/src/utils/http.js | 132 + .../backend/src/utils/ids.js | 12 + .../backend/src/utils/time.js | 14 + .../backend/src/worker.js | 24 + .../backend/src/workers/jobWorker.js | 176 + .../backend/tests/adminStatusService.test.mjs | 88 + .../backend/tests/agentPlanKey.test.mjs | 61 + .../backend/tests/asyncJobWorker.test.mjs | 498 ++ .../tests/asyncQueueMigration.test.mjs | 88 + .../backend/tests/authService.test.mjs | 254 + .../tests/businessChainVerifier.test.mjs | 166 + .../backend/tests/claimGrounding.test.mjs | 121 + .../tests/dataProQueryPlanner.test.mjs | 55 + ...ossierAgent.realFailureRegression.test.mjs | 360 + .../backend/tests/dossierAgent.test.mjs | 491 ++ .../tests/dossierAgentEvidenceAtoms.test.mjs | 419 ++ .../tests/dossierEvidenceCompiler.test.mjs | 623 ++ .../backend/tests/feishuImportScript.test.mjs | 136 + .../tests/feishuImportTaskService.test.mjs | 198 + .../tests/frontendRuntimeContract.test.mjs | 212 + .../backend/tests/frontendTextFormat.test.mjs | 179 + .../backend/tests/httpSecurity.test.mjs | 386 + .../backend/tests/materialImport.test.mjs | 471 ++ .../backend/tests/materialSync.test.mjs | 67 + .../backend/tests/modelProvider.test.mjs | 346 + .../backend/tests/openVikingProvider.test.mjs | 258 + .../tests/openVikingQaBoundary.test.mjs | 33 + .../backend/tests/paidWorkflowGuard.test.mjs | 113 + .../tests/providerCircuitBreaker.test.mjs | 101 + .../backend/tests/providerResult.test.mjs | 252 + .../backend/tests/providerRunStore.test.mjs | 180 + .../tests/publicDocumentation.test.mjs | 38 + .../backend/tests/releaseSecretScan.test.mjs | 29 + .../backend/tests/runtimePolicy.test.mjs | 116 + .../backend/tests/salesCompanySearch.test.mjs | 237 + ...ossierEvidenceCompilerIntegration.test.mjs | 249 + .../backend/tests/salesEvidence.test.mjs | 607 ++ .../backend/tests/salesFailClosed.test.mjs | 472 ++ .../backend/tests/salesQaQuality.test.mjs | 272 + .../tests/salesStage4Workflow.test.mjs | 3098 ++++++++ .../tests/setupSupabasePolicy.test.mjs | 28 + .../backend/tests/staticFrontend.test.mjs | 81 + .../backend/tests/supabaseBackup.test.mjs | 64 + .../tests/supabaseDataProvider.test.mjs | 43 + .../tests/supabaseDataRepository.test.mjs | 256 + .../backend/tests/supabaseProvider.test.mjs | 61 + .../tests/supabaseSecurityBoundary.test.mjs | 35 + .../backend/tests/workspaceExport.test.mjs | 156 + .../docs/README.md | 12 + .../docs/api/api-contract.md | 324 + .../docs/architecture/dossier-agent.md | 185 + .../docs/database/supabase-schema.md | 109 + .../docs/deployment/self-hosting.md | 121 + .../frontend/app.js | 1996 ++++++ .../frontend/index.html | 14 + .../frontend/styles.css | 2834 ++++++++ .../frontend/text-format.js | 189 + .../package-lock.json | 15 + .../sales-intelligence-workbench/package.json | 20 + .../scripts/install-agent-skill.mjs | 153 + .../scripts/install-claude-code-skill.mjs | 9 + .../scripts/install-codex-skill.mjs | 9 + .../scripts/print-public-skill-command.mjs | 74 + .../scripts/test-skill-installer.mjs | 200 + .../scripts/validate-public-release.mjs | 292 + .../scripts/validate-skill-package.mjs | 152 + .../sales-intelligence-workbench/SKILL.md | 362 + .../agents/openai.yaml | 6 + .../assets/app/backend/.env.example | 116 + .../assets/app/backend/package.json | 37 + .../app/backend/scripts/backup-supabase.mjs | 174 + .../scripts/baseline-real-readonly.mjs | 244 + .../backend/scripts/bootstrap-workspace.mjs | 44 + .../backend/scripts/check-release-secrets.mjs | 136 + .../scripts/configure-supabase-data-api.mjs | 52 + .../assets/app/backend/scripts/doctor.mjs | 30 + .../app/backend/scripts/export-workspace.mjs | 119 + .../app/backend/scripts/import-feishu-cli.mjs | 601 ++ .../app/backend/scripts/migrate-supabase.mjs | 52 + .../app/backend/scripts/preflight-real.mjs | 187 + .../app/backend/scripts/real-chain-check.mjs | 7 + .../app/backend/scripts/restore-supabase.mjs | 148 + .../backend/scripts/smoke-async-job-queue.mjs | 36 + .../scripts/smoke-paid-workflow-guard.mjs | 36 + .../app/backend/scripts/smoke-stage2-api.mjs | 134 + .../scripts/smoke-stage2-backup-package.mjs | 271 + .../backend/scripts/smoke-stage2-data-api.mjs | 166 + .../scripts/smoke-stage3-material-sync.mjs | 219 + .../backend/scripts/verify-business-chain.mjs | 524 ++ .../scripts/verify-openviking-qa-boundary.mjs | 54 + .../backend/scripts/verify-release-local.mjs | 75 + .../verify-supabase-security-boundary.mjs | 103 + .../app/backend/src/agents/dossierAgent.js | 1385 ++++ .../assets/app/backend/src/app.js | 98 + .../app/backend/src/backup/supabaseBackup.js | 100 + .../app/backend/src/config/providerConfig.js | 191 + .../app/backend/src/config/runtimeEnv.js | 55 + .../app/backend/src/config/runtimePolicy.js | 142 + .../backend/src/evidence/claimGrounding.js | 270 + .../src/evidence/dossierEvidenceCompiler.js | 774 ++ .../app/backend/src/evidence/salesEvidence.js | 1247 ++++ .../backend/src/frontend/staticFrontend.js | 79 + .../backend/src/limits/paidWorkflowGuard.js | 228 + .../src/limits/providerCircuitBreaker.js | 85 + .../src/observability/providerRunStore.js | 272 + .../src/providers/citationValidator.js | 22 + .../backend/src/providers/dataProProvider.js | 352 + .../backend/src/providers/modelProvider.js | 699 ++ .../src/providers/openVikingProvider.js | 755 ++ .../backend/src/providers/providerResult.js | 98 + .../src/providers/supabaseDataProvider.js | 146 + .../backend/src/providers/supabaseProvider.js | 204 + .../src/providers/webSearchProvider.js | 221 + .../repositories/supabaseDataRepository.js | 806 +++ .../assets/app/backend/src/routes/index.js | 473 ++ .../app/backend/src/security/authService.js | 632 ++ .../app/backend/src/security/rateLimiter.js | 76 + .../assets/app/backend/src/server.js | 17 + .../src/services/adminStatusService.js | 162 + .../src/services/feishuImportTaskService.js | 274 + .../backend/src/services/providerService.js | 83 + .../app/backend/src/services/salesService.js | 6375 +++++++++++++++++ .../app/backend/src/sync/materialSync.js | 209 + .../assets/app/backend/src/utils/http.js | 132 + .../assets/app/backend/src/utils/ids.js | 12 + .../assets/app/backend/src/utils/time.js | 14 + .../assets/app/backend/src/worker.js | 24 + .../app/backend/src/workers/jobWorker.js | 176 + .../backend/tests/adminStatusService.test.mjs | 88 + .../app/backend/tests/agentPlanKey.test.mjs | 61 + .../app/backend/tests/asyncJobWorker.test.mjs | 498 ++ .../tests/asyncQueueMigration.test.mjs | 88 + .../app/backend/tests/authService.test.mjs | 254 + .../tests/businessChainVerifier.test.mjs | 166 + .../app/backend/tests/claimGrounding.test.mjs | 121 + .../tests/dataProQueryPlanner.test.mjs | 55 + ...ossierAgent.realFailureRegression.test.mjs | 360 + .../app/backend/tests/dossierAgent.test.mjs | 491 ++ .../tests/dossierAgentEvidenceAtoms.test.mjs | 419 ++ .../tests/dossierEvidenceCompiler.test.mjs | 623 ++ .../backend/tests/feishuImportScript.test.mjs | 136 + .../tests/feishuImportTaskService.test.mjs | 198 + .../tests/frontendRuntimeContract.test.mjs | 212 + .../backend/tests/frontendTextFormat.test.mjs | 179 + .../app/backend/tests/httpSecurity.test.mjs | 386 + .../app/backend/tests/materialImport.test.mjs | 471 ++ .../app/backend/tests/materialSync.test.mjs | 67 + .../app/backend/tests/modelProvider.test.mjs | 346 + .../backend/tests/openVikingProvider.test.mjs | 258 + .../tests/openVikingQaBoundary.test.mjs | 33 + .../backend/tests/paidWorkflowGuard.test.mjs | 113 + .../tests/providerCircuitBreaker.test.mjs | 101 + .../app/backend/tests/providerResult.test.mjs | 252 + .../backend/tests/providerRunStore.test.mjs | 180 + .../tests/publicDocumentation.test.mjs | 38 + .../backend/tests/releaseSecretScan.test.mjs | 29 + .../app/backend/tests/runtimePolicy.test.mjs | 116 + .../backend/tests/salesCompanySearch.test.mjs | 237 + ...ossierEvidenceCompilerIntegration.test.mjs | 249 + .../app/backend/tests/salesEvidence.test.mjs | 607 ++ .../backend/tests/salesFailClosed.test.mjs | 472 ++ .../app/backend/tests/salesQaQuality.test.mjs | 272 + .../tests/salesStage4Workflow.test.mjs | 3098 ++++++++ .../tests/setupSupabasePolicy.test.mjs | 28 + .../app/backend/tests/staticFrontend.test.mjs | 81 + .../app/backend/tests/supabaseBackup.test.mjs | 64 + .../tests/supabaseDataProvider.test.mjs | 43 + .../tests/supabaseDataRepository.test.mjs | 256 + .../backend/tests/supabaseProvider.test.mjs | 61 + .../tests/supabaseSecurityBoundary.test.mjs | 35 + .../backend/tests/workspaceExport.test.mjs | 156 + .../assets/app/frontend/app.js | 1996 ++++++ .../assets/app/frontend/index.html | 14 + .../assets/app/frontend/styles.css | 2834 ++++++++ .../assets/app/frontend/text-format.js | 189 + .../functions/sales-cli-health-b1/index.ts | 17 + .../migrations/202607210001_stage2_core.sql | 395 + .../migrations/202607210002_stage2_rls.sql | 221 + .../202607210003_stage2_fk_corrections.sql | 39 + .../202607210004_stage2_data_api_rpc.sql | 278 + .../202607210005_fix_dossier_citation_rpc.sql | 127 + ...202607210006_cover_foreign_key_indexes.sql | 61 + .../202607210007_stage3_material_sync.sql | 52 + .../202607210008_stage4_evidence_versions.sql | 366 + .../202607230001_paid_workflow_guard.sql | 318 + .../202607230002_async_job_queue.sql | 710 ++ .../202607230003_safe_job_cancellation.sql | 343 + .../202607280001_openviking_qa_boundary.sql | 27 + .../202607280002_secure_internal_tables.sql | 11 + ...1_reconcile_terminal_job_provider_runs.sql | 106 + .../202607300001_durable_job_checkpoints.sql | 222 + .../202607210008_stage4_evidence_smoke.sql | 214 + ...202607230002_paid_workflow_guard_smoke.sql | 88 + .../202607230003_async_job_queue_smoke.sql | 198 + .../references/architecture.md | 48 + .../references/cookbook-workflow.md | 63 + .../references/evidence-policy.md | 46 + .../references/feishu-import.md | 57 + .../references/provider-configuration.md | 75 + .../references/security.md | 31 + .../references/setup.md | 51 + .../references/troubleshooting.md | 47 + .../scripts/backup.mjs | 29 + .../scripts/configure.mjs | 136 + .../scripts/doctor.mjs | 78 + .../scripts/export-workspace.mjs | 15 + .../scripts/import-feishu.mjs | 62 + .../scripts/install.mjs | 84 + .../scripts/lib.mjs | 558 ++ .../scripts/login.mjs | 119 + .../scripts/logout.mjs | 13 + .../scripts/migrate.mjs | 25 + .../scripts/onboard.mjs | 269 + .../scripts/restore.mjs | 20 + .../scripts/self-test.mjs | 156 + .../scripts/setup-openviking.mjs | 335 + .../scripts/setup-supabase.mjs | 267 + .../scripts/setup.mjs | 272 + .../scripts/smoke-async-job-queue.mjs | 18 + .../scripts/smoke-paid-workflow.mjs | 18 + .../scripts/start.mjs | 104 + .../scripts/status.mjs | 49 + .../scripts/stop.mjs | 28 + .../scripts/sync-assets.mjs | 56 + .../scripts/uninstall.mjs | 25 + .../scripts/upgrade.mjs | 8 + .../scripts/verify-business-chain.mjs | 49 + .../scripts/verify-real-chain.mjs | 18 + .../functions/sales-cli-health-b1/index.ts | 17 + .../migrations/202607210001_stage2_core.sql | 395 + .../migrations/202607210002_stage2_rls.sql | 221 + .../202607210003_stage2_fk_corrections.sql | 39 + .../202607210004_stage2_data_api_rpc.sql | 278 + .../202607210005_fix_dossier_citation_rpc.sql | 127 + ...202607210006_cover_foreign_key_indexes.sql | 61 + .../202607210007_stage3_material_sync.sql | 52 + .../202607210008_stage4_evidence_versions.sql | 366 + .../202607230001_paid_workflow_guard.sql | 318 + .../202607230002_async_job_queue.sql | 710 ++ .../202607230003_safe_job_cancellation.sql | 343 + .../202607280001_openviking_qa_boundary.sql | 27 + .../202607280002_secure_internal_tables.sql | 11 + ...1_reconcile_terminal_job_provider_runs.sql | 106 + .../202607300001_durable_job_checkpoints.sql | 222 + .../202607210008_stage4_evidence_smoke.sql | 214 + ...202607230002_paid_workflow_guard_smoke.sql | 88 + .../202607230003_async_job_queue_smoke.sql | 198 + 312 files changed, 88584 insertions(+) create mode 100644 demohouse/sales-intelligence-workbench/.github/workflows/ci.yml create mode 100644 demohouse/sales-intelligence-workbench/.gitignore create mode 100644 demohouse/sales-intelligence-workbench/CHANGELOG.md create mode 100644 demohouse/sales-intelligence-workbench/CONTRIBUTING.md create mode 100644 demohouse/sales-intelligence-workbench/LICENSE create mode 100644 demohouse/sales-intelligence-workbench/README.md create mode 100644 demohouse/sales-intelligence-workbench/SECURITY.md create mode 100644 demohouse/sales-intelligence-workbench/THIRD_PARTY_NOTICES.md create mode 100644 demohouse/sales-intelligence-workbench/UPSTREAM.json create mode 100644 demohouse/sales-intelligence-workbench/backend/.env.example create mode 100644 demohouse/sales-intelligence-workbench/backend/package.json create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/backup-supabase.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/baseline-real-readonly.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/bootstrap-workspace.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/check-release-secrets.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/configure-supabase-data-api.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/doctor.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/export-workspace.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/import-feishu-cli.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/migrate-supabase.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/preflight-real.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/real-chain-check.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/restore-supabase.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/smoke-async-job-queue.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/smoke-paid-workflow-guard.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/smoke-stage2-api.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/smoke-stage2-backup-package.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/smoke-stage2-data-api.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/smoke-stage3-material-sync.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/verify-business-chain.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/verify-openviking-qa-boundary.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/verify-release-local.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/scripts/verify-supabase-security-boundary.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/src/agents/dossierAgent.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/app.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/backup/supabaseBackup.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/config/providerConfig.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/config/runtimeEnv.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/config/runtimePolicy.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/evidence/claimGrounding.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/evidence/dossierEvidenceCompiler.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/evidence/salesEvidence.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/frontend/staticFrontend.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/limits/paidWorkflowGuard.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/limits/providerCircuitBreaker.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/observability/providerRunStore.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/providers/citationValidator.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/providers/dataProProvider.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/providers/modelProvider.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/providers/openVikingProvider.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/providers/providerResult.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/providers/supabaseDataProvider.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/providers/supabaseProvider.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/providers/webSearchProvider.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/repositories/supabaseDataRepository.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/routes/index.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/security/authService.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/security/rateLimiter.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/server.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/services/adminStatusService.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/services/feishuImportTaskService.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/services/providerService.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/services/salesService.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/sync/materialSync.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/utils/http.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/utils/ids.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/utils/time.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/worker.js create mode 100644 demohouse/sales-intelligence-workbench/backend/src/workers/jobWorker.js create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/adminStatusService.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/agentPlanKey.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/asyncJobWorker.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/asyncQueueMigration.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/authService.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/businessChainVerifier.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/claimGrounding.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/dataProQueryPlanner.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/dossierAgent.realFailureRegression.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/dossierAgent.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/dossierAgentEvidenceAtoms.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/dossierEvidenceCompiler.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/feishuImportScript.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/feishuImportTaskService.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/frontendRuntimeContract.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/frontendTextFormat.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/httpSecurity.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/materialImport.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/materialSync.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/modelProvider.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/openVikingProvider.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/openVikingQaBoundary.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/paidWorkflowGuard.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/providerCircuitBreaker.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/providerResult.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/providerRunStore.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/publicDocumentation.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/releaseSecretScan.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/runtimePolicy.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/salesCompanySearch.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/salesDossierEvidenceCompilerIntegration.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/salesEvidence.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/salesFailClosed.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/salesQaQuality.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/salesStage4Workflow.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/setupSupabasePolicy.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/staticFrontend.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/supabaseBackup.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/supabaseDataProvider.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/supabaseDataRepository.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/supabaseProvider.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/supabaseSecurityBoundary.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/backend/tests/workspaceExport.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/docs/README.md create mode 100644 demohouse/sales-intelligence-workbench/docs/api/api-contract.md create mode 100644 demohouse/sales-intelligence-workbench/docs/architecture/dossier-agent.md create mode 100644 demohouse/sales-intelligence-workbench/docs/database/supabase-schema.md create mode 100644 demohouse/sales-intelligence-workbench/docs/deployment/self-hosting.md create mode 100644 demohouse/sales-intelligence-workbench/frontend/app.js create mode 100644 demohouse/sales-intelligence-workbench/frontend/index.html create mode 100644 demohouse/sales-intelligence-workbench/frontend/styles.css create mode 100644 demohouse/sales-intelligence-workbench/frontend/text-format.js create mode 100644 demohouse/sales-intelligence-workbench/package-lock.json create mode 100644 demohouse/sales-intelligence-workbench/package.json create mode 100644 demohouse/sales-intelligence-workbench/scripts/install-agent-skill.mjs create mode 100644 demohouse/sales-intelligence-workbench/scripts/install-claude-code-skill.mjs create mode 100644 demohouse/sales-intelligence-workbench/scripts/install-codex-skill.mjs create mode 100644 demohouse/sales-intelligence-workbench/scripts/print-public-skill-command.mjs create mode 100644 demohouse/sales-intelligence-workbench/scripts/test-skill-installer.mjs create mode 100644 demohouse/sales-intelligence-workbench/scripts/validate-public-release.mjs create mode 100644 demohouse/sales-intelligence-workbench/scripts/validate-skill-package.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/agents/openai.yaml create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/.env.example create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/package.json create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/backup-supabase.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/baseline-real-readonly.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/bootstrap-workspace.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/check-release-secrets.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/configure-supabase-data-api.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/doctor.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/export-workspace.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/import-feishu-cli.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/migrate-supabase.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/preflight-real.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/real-chain-check.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/restore-supabase.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-async-job-queue.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-paid-workflow-guard.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-stage2-api.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-stage2-backup-package.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-stage2-data-api.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-stage3-material-sync.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-business-chain.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-openviking-qa-boundary.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-release-local.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-supabase-security-boundary.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/agents/dossierAgent.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/app.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/backup/supabaseBackup.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/config/providerConfig.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/config/runtimeEnv.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/config/runtimePolicy.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/evidence/claimGrounding.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/evidence/dossierEvidenceCompiler.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/evidence/salesEvidence.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/frontend/staticFrontend.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/limits/paidWorkflowGuard.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/limits/providerCircuitBreaker.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/observability/providerRunStore.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/citationValidator.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/dataProProvider.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/modelProvider.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/openVikingProvider.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/providerResult.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/supabaseDataProvider.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/supabaseProvider.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/webSearchProvider.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/repositories/supabaseDataRepository.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/routes/index.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/security/authService.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/security/rateLimiter.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/server.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/services/adminStatusService.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/services/feishuImportTaskService.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/services/providerService.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/services/salesService.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/sync/materialSync.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/utils/http.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/utils/ids.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/utils/time.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/worker.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/workers/jobWorker.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/adminStatusService.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/agentPlanKey.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/asyncJobWorker.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/asyncQueueMigration.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/authService.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/businessChainVerifier.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/claimGrounding.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dataProQueryPlanner.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dossierAgent.realFailureRegression.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dossierAgent.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dossierAgentEvidenceAtoms.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dossierEvidenceCompiler.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/feishuImportScript.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/feishuImportTaskService.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/frontendRuntimeContract.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/frontendTextFormat.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/httpSecurity.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/materialImport.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/materialSync.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/modelProvider.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/openVikingProvider.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/openVikingQaBoundary.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/paidWorkflowGuard.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/providerCircuitBreaker.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/providerResult.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/providerRunStore.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/publicDocumentation.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/releaseSecretScan.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/runtimePolicy.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesCompanySearch.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesDossierEvidenceCompilerIntegration.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesEvidence.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesFailClosed.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesQaQuality.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesStage4Workflow.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/setupSupabasePolicy.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/staticFrontend.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseBackup.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseDataProvider.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseDataRepository.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseProvider.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseSecurityBoundary.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/workspaceExport.test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/frontend/app.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/frontend/index.html create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/frontend/styles.css create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/frontend/text-format.js create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/functions/sales-cli-health-b1/index.ts create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210001_stage2_core.sql create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210002_stage2_rls.sql create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210003_stage2_fk_corrections.sql create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210004_stage2_data_api_rpc.sql create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210005_fix_dossier_citation_rpc.sql create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210006_cover_foreign_key_indexes.sql create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210007_stage3_material_sync.sql create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210008_stage4_evidence_versions.sql create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607230001_paid_workflow_guard.sql create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607230002_async_job_queue.sql create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607230003_safe_job_cancellation.sql create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607280001_openviking_qa_boundary.sql create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607280002_secure_internal_tables.sql create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607290001_reconcile_terminal_job_provider_runs.sql create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607300001_durable_job_checkpoints.sql create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/tests/202607210008_stage4_evidence_smoke.sql create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/tests/202607230002_paid_workflow_guard_smoke.sql create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/tests/202607230003_async_job_queue_smoke.sql create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/architecture.md create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/cookbook-workflow.md create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/evidence-policy.md create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/feishu-import.md create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/provider-configuration.md create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/security.md create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/setup.md create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/troubleshooting.md create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/backup.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/configure.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/doctor.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/export-workspace.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/import-feishu.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/install.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/lib.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/login.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/logout.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/migrate.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/onboard.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/restore.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/self-test.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup-openviking.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup-supabase.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/smoke-async-job-queue.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/smoke-paid-workflow.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/start.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/status.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/stop.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/sync-assets.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/uninstall.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/upgrade.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/verify-business-chain.mjs create mode 100644 demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/verify-real-chain.mjs create mode 100644 demohouse/sales-intelligence-workbench/supabase/functions/sales-cli-health-b1/index.ts create mode 100644 demohouse/sales-intelligence-workbench/supabase/migrations/202607210001_stage2_core.sql create mode 100644 demohouse/sales-intelligence-workbench/supabase/migrations/202607210002_stage2_rls.sql create mode 100644 demohouse/sales-intelligence-workbench/supabase/migrations/202607210003_stage2_fk_corrections.sql create mode 100644 demohouse/sales-intelligence-workbench/supabase/migrations/202607210004_stage2_data_api_rpc.sql create mode 100644 demohouse/sales-intelligence-workbench/supabase/migrations/202607210005_fix_dossier_citation_rpc.sql create mode 100644 demohouse/sales-intelligence-workbench/supabase/migrations/202607210006_cover_foreign_key_indexes.sql create mode 100644 demohouse/sales-intelligence-workbench/supabase/migrations/202607210007_stage3_material_sync.sql create mode 100644 demohouse/sales-intelligence-workbench/supabase/migrations/202607210008_stage4_evidence_versions.sql create mode 100644 demohouse/sales-intelligence-workbench/supabase/migrations/202607230001_paid_workflow_guard.sql create mode 100644 demohouse/sales-intelligence-workbench/supabase/migrations/202607230002_async_job_queue.sql create mode 100644 demohouse/sales-intelligence-workbench/supabase/migrations/202607230003_safe_job_cancellation.sql create mode 100644 demohouse/sales-intelligence-workbench/supabase/migrations/202607280001_openviking_qa_boundary.sql create mode 100644 demohouse/sales-intelligence-workbench/supabase/migrations/202607280002_secure_internal_tables.sql create mode 100644 demohouse/sales-intelligence-workbench/supabase/migrations/202607290001_reconcile_terminal_job_provider_runs.sql create mode 100644 demohouse/sales-intelligence-workbench/supabase/migrations/202607300001_durable_job_checkpoints.sql create mode 100644 demohouse/sales-intelligence-workbench/supabase/tests/202607210008_stage4_evidence_smoke.sql create mode 100644 demohouse/sales-intelligence-workbench/supabase/tests/202607230002_paid_workflow_guard_smoke.sql create mode 100644 demohouse/sales-intelligence-workbench/supabase/tests/202607230003_async_job_queue_smoke.sql diff --git a/README.md b/README.md index dd857f5e..305d82e6 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ | [长记忆方案](./demohouse/longterm_memory/README.md) | 基于 DeepSeek-R1 模型的强大思考能力将对话内容抽取成记忆,记录用户偏好、性格、生日等,并在对话到相关话题时帮助 Doubao 角色模型生成更贴合角色人设的回复。 | | [手机助手](./demohouse/pocket_pal/README.md) | 移动端手机智能助手。 | | [智能客服助手](./demohouse/shop_assist/backend/README.md) | 以车载零配件网店客服场景为例设计的智能导购机器人。 | +| [销售智能工作台](./demohouse/sales-intelligence-workbench/README.md) | 基于 Agent Plan、DataPro、豆包搜索、OpenViking 和 AI Native 应用开发底座,构建来源可追溯的企业档案、销售资料问答与飞书资料导入工作台。 | | [教师分身](./demohouse/teacher_avatar/README.md) | 基于豆包视觉理解与 DeepSeek 深度推理双引擎的教育解决方案。 | | [视频实时理解](./demohouse/video_analyser/README.md) | 多模态洞察,基于豆包-视觉理解模型实时视觉与语音理解。 | | [实时对话式 AI](./demohouse/rtc_conversational_ai/README.md) | 超低延迟的 AI 实时对话应用,更流畅,更自然,更实时。 | diff --git a/demohouse/sales-intelligence-workbench/.github/workflows/ci.yml b/demohouse/sales-intelligence-workbench/.github/workflows/ci.yml new file mode 100644 index 00000000..d3712936 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: ci + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - name: Install locked dependencies + run: npm ci + - name: Run complete offline release verification + run: npm run verify diff --git a/demohouse/sales-intelligence-workbench/.gitignore b/demohouse/sales-intelligence-workbench/.gitignore new file mode 100644 index 00000000..4a559037 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/.gitignore @@ -0,0 +1,47 @@ +.DS_Store +新账号完整交接提示词.md +node_modules/ +coverage/ +dist/ +*.log +*.pid +*.mp4 +*.mov +*.mkv + +# Local secrets and machine-specific configuration +.env +.env.* +!.env.example +backend/.env +backend/.env.* +!backend/.env.example + +# Private backups and machine-local artifacts +backups/ +artifacts/ +backend/.baseline/ +supabase/.temp/ + +# Internal research and historical acceptance evidence are not release assets. +docs/afp-testing/ +docs/handoff/ +docs/optimization/ +docs/research/ +docs/open-source/ +design-qa.md +学习笔记/ +tests/**/*.png +tests/**/*.jpg +tests/**/*.jpeg +tests/**/*.json + +# Historical local acceptance scripts use machine-specific runtimes and are not +# part of the installable application. Backend tests remain release assets. +/tests/ + +# Unrelated internal skills are outside this project's open-source boundary. +/skills/agent-demo-frontend/ +/skills/agent-harness-backend/ +/skills/change-report-writer/ +/skills/change-visual-brief/ diff --git a/demohouse/sales-intelligence-workbench/CHANGELOG.md b/demohouse/sales-intelligence-workbench/CHANGELOG.md new file mode 100644 index 00000000..3ba3914a --- /dev/null +++ b/demohouse/sales-intelligence-workbench/CHANGELOG.md @@ -0,0 +1,73 @@ +# 变更记录 + +本文件记录各发行版本的公开变化。当前版本为 `0.10.0` 自托管开源版。 + +## 0.10.0 - 2026-08-03 + +### 身份与使用体验 + +- 统一为单工作区、单管理员流程:首次使用只设置用户名和密码,不提供邮箱确认、公开注册、邮件找回密码或成员/角色功能。 +- 修复短期访问令牌过期后的长期会话恢复,同一浏览器默认保持登录一年;移除与当前公开身份流程不一致的本机密码重置脚本。 +- 前端长任务进度改用紧凑阶段文字和完成计数,不展示容易停滞的百分比或 Worker、检查点和 Provider 错误栈。 + +### 档案 Agent 与引用 + +- 档案生成改为主体锚定和事实级引用门禁:专业来源必须能以法定名称或统一社会信用代码确认目标主体,引用数量不再作为生成成功条件。 +- 验证码、人机校验和访问拦截页不进入证据包。 +- 将档案生成重构为有界单 Agent:通过 Agent Plan Responses API 强制调用严格函数提交固定六章节档案,服务端独立执行证据与展示质量门禁。 +- 移除档案文本 JSON 修补和模型二次自由成稿;Agent 提交完整六章节规划,服务端独立执行主体、日期、数值、机构、事件关系、高风险事实和最终公开视图门禁。 +- 按主体、经营、近期事件、风险、招采项目和来源独立性评估覆盖缺口,只对缺失主题执行有界补充检索。 +- 不设置整份档案的最低来源数量;每个事实只绑定最少且直接的证据,高风险事实和关键数字继续执行双来源规则。 +- 当最终正文及实际引用未变化时不保存重复版本;专业数据集(DataPro)来源可展开逐字段核验,豆包搜索(联网搜索)来源只展示标题、站点、发布时间和原文链接。 + +### 任务可靠性 + +- 档案采集使用有界并发;网络、限流、超时和上游临时故障执行有界重试,鉴权、配置和内容门禁错误不盲目重试。 +- 持久化已完成查询、证据包和 Agent 执行检查点;可重试故障只继续未完成查询,避免重复已成功的付费调用。 +- Job 因 Worker 中断而失败或取消时,自动结束关联的 Provider Run 和运行中步骤,并修复历史悬挂记录。 + +## 0.9.2 - 2026-07-29 + +- 将公开身份流程统一为单工作区、单管理员:首次使用只设置用户名和密码,后续直接登录。 +- 移除浏览器邮箱字段、邮件确认、公开注册、邮件找回密码及成员/角色相关产品表述。 +- 新增本机交互式密码重置命令;密码只通过标准输入传递,不进入命令参数或日志。 +- 保留现有账号和数据的兼容读取;底层归属记录继续用于鉴权与数据隔离,不作为多人协作功能开放。 + +## 0.9.1 - 2026-07-29 + +- 统一使用 Agent Plan 控制台正式名称:专业数据集(DataPro)、豆包搜索(联网搜索)、Agent 记忆(OpenViking)和 AI Native 应用开发底座(Supabase)。 +- 将远程初始化入口固定到独立仓库的 `v0.9.1` tag,避免引用尚未发布的仓库路径。 +- 公开应用统一为一种真实数据运行方式,移除开发/录屏运行参数、虚构企业、固定档案和 Provider 静态兜底。 +- 移除开发路线图、验收流水、旧 Skill 兼容入口、环境特定说明和未加载的旧前端服务层,只保留用户使用与二次开发所需内容。 +- 新增公开发布检查,覆盖私密文件、本机路径、内部材料、相对文档链接、版本一致性、正式产品名称与 Skill 分发包边界。 + +## 0.9.0 - 2026-07-23 + +### 新增 + +- 移除成员管理界面及公开成员管理 API;当前发行版按单工作区、单负责人使用,内部工作区归属记录仅用于登录鉴权和数据隔离。 +- Skill 从“已有应用运维手册”升级为按 Cookbook 执行的 Builder:先确认销售场景,再安装标准前后端、配置真实资源、导入资料并验收业务闭环。 +- 新增 `setup.mjs` 阶段检测,以及首批资料导入和真实业务验收的本机脱敏回执。 +- Supabase Auth、工作区归属与负责人访问边界。 +- DataPro 企业识别、专业数据与公开搜索取证、带引用档案和资料问答。 +- 飞书 CLI 增量资料导入、OpenViking 企业级目录隔离及长期记忆检索。 +- Supabase 持久化异步任务队列、独立 Worker、进度恢复、重试和安全取消。 +- 工作区级付费任务并发与每日次数保护。 +- Skill 安装、配置、诊断、迁移、备份、恢复、升级和卸载脚本。 +- 仅负责人可调用的工作区业务数据导出接口与私密文件导出脚本。 +- 关键业务写操作、Provider 探测和工作区导出的脱敏审计,以及管理员只读查询接口。 +- 发布密钥扫描和一键离线发布验收。 + +### 安全 + +- 根目录加入项目许可证文本。 +- 正式运行路径禁止演示数据和 Provider 静态兜底。 +- Service Role、Agent Plan Key 和控制面凭证只允许保存在服务端私密配置。 +- 业务接口增加认证授权、请求体上限、来源校验和安全响应头。 +- `verify-real-chain.mjs --help` 只显示用法,不触发真实 Provider 请求或产生 AFP/Token。 + +### 已知限制 + +- 当前版本仅支持单工作区、单人使用,以及本机或受控内网自托管。 +- 公网部署仍需 HTTPS 反向代理、Secure Cookie 和精确来源白名单。 +- 版本化迁移目标更新至 `202607280002`:问答正文仅由 OpenViking 保存和检索,Supabase 只保留业务结构与 OpenViking 会话引用;旧问答表以只读归档方式保留,项目迁移元数据表启用 RLS 并仅允许后端 `service_role` 访问。 diff --git a/demohouse/sales-intelligence-workbench/CONTRIBUTING.md b/demohouse/sales-intelligence-workbench/CONTRIBUTING.md new file mode 100644 index 00000000..4bd5d3c5 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing + +## Development setup + +Use Node.js 20 or newer. The backend currently has no third-party runtime dependency installation step. + +```bash +node --check frontend/app.js +node --check frontend/text-format.js +cd backend +npm test +``` + +## Change rules + +- Keep the runtime fail closed; never add realistic test-data fallback to a real Provider path. +- Keep model output evidence-bound and validate citation IDs on the backend. +- Never commit API keys, Service Role Keys, Feishu content, customer data, backups, logs, videos, or screenshots from real accounts. +- Add focused tests for Provider contracts, persistence, workspace isolation and frontend runtime wiring. +- Update the Skill application bundle after source changes: + +```bash +node skills/sales-intelligence-workbench/scripts/sync-assets.mjs +node skills/sales-intelligence-workbench/scripts/sync-assets.mjs --check +node skills/sales-intelligence-workbench/scripts/self-test.mjs +``` + +## Pull requests + +Describe the user-visible behavior, affected Provider or data boundary, tests run, and any external calls or cost. Call out migrations, compatibility changes and remaining risks explicitly. + +Do not include private acceptance evidence in a pull request. Use synthetic fixtures for automated tests. diff --git a/demohouse/sales-intelligence-workbench/LICENSE b/demohouse/sales-intelligence-workbench/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/demohouse/sales-intelligence-workbench/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/demohouse/sales-intelligence-workbench/README.md b/demohouse/sales-intelligence-workbench/README.md new file mode 100644 index 00000000..0457bd02 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/README.md @@ -0,0 +1,319 @@ +# 销售智能工作台 + +销售智能工作台把企业专业数据、公开信息、飞书资料和长期记忆组织成可追溯的企业档案与资料问答。项目包含完整前后端、AI Native 应用开发底座(Supabase)数据层、Agent 记忆(OpenViking)链路,以及可在 Codex 和 Claude Code 中安装并运维应用的同一份 Skill。 + +> 当前为 `0.10.0` 自托管开源版,支持单工作区、单管理员,以及本机或受控内网部署。已支持 Supabase Auth、工作区数据隔离、安全请求边界、工作区级付费任务保护、持久化异步任务队列和独立 Worker。当前版本不提供公网托管 SaaS、多人协作或 SLA;不要把 Node.js 服务端口直接暴露到公网。 + +## 核心能力 + +- 通过专业数据集(DataPro)解析真实企业主体并加入目标企业池。 +- 由受约束的档案 Agent 仅使用专业数据集(DataPro)和豆包搜索(联网搜索)的已核验证据生成最新档案,避免内部资料混入外部事实报告。 +- 使用 Codex CLI 调度飞书 CLI,增量导入云文档、群聊、单聊或消息搜索结果。 +- 将飞书资料正文和资料问答会话按 Workspace、企业隔离写入 Agent 记忆(OpenViking),并在问答前真实检索和恢复。 +- 使用 AI Native 应用开发底座(Supabase)保存企业、档案版本、引用、任务、工作区归属、Provider 运行记录,以及资料与会话的同步元数据。 +- 后端记录任务状态、失败原因、模型 Token 和 Provider 调用证据,供诊断接口与日志审计;正式业务前端不展示后台配置和运维信息。 +- 通过 Supabase Auth 保护业务与付费调用;首次使用设置一个本机管理员用户名和密码,不需要邮箱、邮件确认或用户注册。 +- 企业搜索、档案、问答、资料导入、Agent 记忆(OpenViking)同步/提交和资源删除统一经过工作区级并发与每日次数保护。 +- 连续可重试的 Provider 故障达到阈值后会临时熔断;冷却结束只放行一次恢复探测,避免持续超时拖垮工作台。 +- 档案生成和 Agent 记忆(OpenViking)批量同步通过 AI Native 应用开发底座(Supabase)持久化队列交给独立 Worker;页面可恢复任务进度。运行中取消采用“请求取消—安全检查点确认”机制,不会在 Provider 调用尚未结束时提前释放付费预约或允许并行重试。 +- 档案证据按专业、官方公开、可追溯公开和内部授权资料分级,并校验公开来源时效、关键数字冲突及高风险事实双来源一致性;验证码、访问拦截和无实质内容页面不会进入报告证据。 +- 提供安装、配置、诊断、启停、迁移、备份、恢复、升级和卸载命令。 + +## 真实性原则 + +- 项目只连接真实 Provider 和 AI Native 应用开发底座(Supabase)。配置或依赖不完整时明确失败,不生成演示数据、固定档案或静态替代结果。 +- 档案必须用专业数据集(DataPro)锚定法定主体,并只使用专业数据集与豆包搜索(联网搜索)的直接证据。引用按相关性和独立性去重,不为凑数量引入弱来源;高风险事实和关键数字继续执行双来源规则。 +- 档案 Agent 固定生成六个章节,服务端负责确定性组装、事实与引用校验、有界重试、检查点恢复和重复版本抑制。成功结果必须结构完整且可核验,详细协议见 [档案 Agent 工程设计](docs/architecture/dossier-agent.md)。 + +## 架构 + +```text +浏览器 + -> 同源 Node.js 服务 + -> /api + -> 销售业务编排 + -> AI Native 应用开发底座(Supabase)持久化任务队列 +独立 Worker + -> 原子领取任务与续租 + -> 专业数据集(DataPro)/ 豆包搜索(联网搜索) + -> 档案 Agent(Agent Plan 模型 + 强制函数提交 + 服务端质量门禁) + -> Agent 记忆(OpenViking)/ AI Native 应用开发底座(Supabase)Data API + +Codex CLI / 前端导入入口 + -> 飞书 CLI + -> 受控导入任务 + -> Agent 记忆(OpenViking)保存资料正文 + -> AI Native 应用开发底座(Supabase)保存来源、游标和业务索引 +``` + +AI Native 应用开发底座(Supabase)是结构化业务事实库;Agent 记忆(OpenViking)是飞书资料正文、资料问答 Session 和长期记忆的唯一内容存储。两者通过稳定的企业、来源、资料和会话 ID 关联,不重复保存正文或问答内容。 + +## 使用 Skill 从 0 搭建 + +### 面向最终用户:一句话初始化 + +当前独立发行仓库的版本化初始化入口为: + +> 帮我初始化销售助手:`https://github.com/3494036618-eng/sales-intelligence-workbench/blob/v0.10.0/skills/sales-intelligence-workbench/SKILL.md` + +该 URL 直接指向唯一的正式 Skill。即使用户本机没有仓库、依赖和配置文件,当前 Agent 也会 +先解释下载与本机写入影响,再从 URL 指定的版本取得完整仓库,执行离线校验,并把同一份 +Skill 安装到当前客户端后立即衔接下面的 Cookbook 搭建流程。读取 URL、下载仓库和离线校验 +不会创建云资源或产生 AFP。 + +初始化入口必须固定到已发布的 tag 或经过审核的 commit SHA。也可以用仓库脚本生成口令: + +```bash +npm run skill:command -- \ + --repository https://github.com/3494036618-eng/sales-intelligence-workbench \ + --ref v0.10.0 +``` + +这里的“从 0 搭建”不等于绕过第三方服务授权:用户仍需拥有 Agent Plan,并在控制台开启 +专业数据集(DataPro)、豆包搜索(联网搜索)、Agent 记忆(OpenViking)和 +AI Native 应用开发底座(Supabase),再按需完成飞书资料授权。用户侧只输入一枚 +Agent Plan Key;Agent 记忆(OpenViking)与 AI Native 应用开发底座(Supabase)的内部连接信息由 Skill 自动获取和私密保存,不要求 +第二个 Key、Supabase Key 或火山 AK/SK。Skill 负责识别缺项、逐步引导、写入本机私密配置和验收。 + +不同账号授权、资料范围和部署环境会影响真实链路结果;安装和部署时必须运行当前版本的 +自动验证,并在目标环境完成配置、权限和业务链路检查。 + +### 面向维护者:本地安装 + +克隆仓库后,在仓库根目录按使用的客户端安装同一份 Skill: + +```bash +npm run skill:install:codex +npm run skill:install:claude +``` + +同时使用两个客户端时可以一次安装: + +```bash +npm run skill:install:all +``` + +`npm run skill:install` 保留为 Codex 安装别名。安装目录分别是 +`${CODEX_HOME:-~/.codex}/skills/sales-intelligence-workbench` 和 +`${CLAUDE_CONFIG_DIR:-~/.claude}/skills/sales-intelligence-workbench`,两端不共享或覆盖配置目录。 + +重新启动对应客户端后直接描述业务目标: + +> 按 Cookbook 步骤帮我搭建销售团队工作台。目标是服务新能源汽车企业客户,历史资料来自飞书云文档和会话,部署在本机。 + +也可以明确输入: + +> 请使用 $sales-intelligence-workbench 搭建我的销售团队工作台。 + +在 Claude Code 中也可以输入: + +> /sales-intelligence-workbench + +Skill 会先确认销售目标和资料范围,再通过可恢复的安全编排器安装经过测试的完整前后端模板,依次连接 Agent Plan 模型、专业数据集(DataPro)、豆包搜索(联网搜索)、Agent 记忆(OpenViking)、AI Native 应用开发底座(Supabase)和授权资料,最后运行真实企业搜索、档案及资料问答验收。它不会为每位用户临时拼一套静态前端,也不会用演示数据冒充完成;云资源写入、真实调用、登录和业务验收都会停下来取得用户确认。 + +继续上次搭建或让 Skill 自动推进安全步骤: + +```bash +node skills/sales-intelligence-workbench/scripts/onboard.mjs +``` + +只读查看当前阶段和唯一下一步: + +```bash +node skills/sales-intelligence-workbench/scripts/setup.mjs +``` + +更新已安装 Skill: + +```bash +npm run skill:install:codex -- --force +npm run skill:install:claude -- --force +``` + +完整阶段与验收标准见 [Cookbook 搭建流程](skills/sales-intelligence-workbench/references/cookbook-workflow.md)。下面的手工命令适合排障或不通过 Agent 运行时使用。 + +## 前置条件 + +- Node.js 20 或更高版本。 +- 可用的 Agent Plan 模型。 +- 可选:已安装并以用户身份登录的 `lark-cli`。 +- 数据库初始化、迁移和备份需要 `byted-supabase-cli` 及相应控制面权限。 + +在 Agent Plan 控制台的能力列表找到以下卡片,确认“开启抵扣”;首次使用时按卡片中的“配置使用”完成授权。本文后续始终使用“控制台名称(内部技术名或作用说明)”的写法: + +| Agent Plan 控制台名称 | 本项目中的作用 | 要求 | +| --- | --- | --- | +| 专业数据集(DataPro) | 企业主体识别、工商、经营和风险等专业事实 | 必需 | +| 豆包搜索(联网搜索) | 近期公开动态、公告、报道和可追溯网页来源 | 必需 | +| Agent 记忆(OpenViking) | 飞书资料正文、资料检索、问答 Session 和长期记忆 | 必需 | +| AI Native 应用开发底座(Supabase) | 企业、档案、引用、任务、权限和同步元数据 | 必需;使用北京地域 Agent Plan Workspace | + +密钥只能写入本机私密配置或部署平台 Secret,不要粘贴到 Issue、日志、截图或提交记录。 + +## 安装 + +```bash +node skills/sales-intelligence-workbench/scripts/install.mjs +node skills/sales-intelligence-workbench/scripts/configure.mjs +``` + +已有私密环境文件时可迁移,脚本不会修改或打印源文件: + +```bash +node skills/sales-intelligence-workbench/scripts/configure.mjs \ + --from-env-file /absolute/path/to/backend/.env.local +``` + +### 初始化 Agent 记忆(OpenViking) + +先只读查看已有记忆库: + +```bash +node skills/sales-intelligence-workbench/scripts/setup-openviking.mjs +``` + +脚本会给出复用已有记忆库的准确命令。没有资源时,确认名称、持续计费和数量上限后再使用 +`--apply --collection-name <英文名称> --yes` 创建。用户不输入第二个 Key;内部连接信息 +由官方控制面返回并以 `0600` 保存。 + +### 初始化 AI Native 应用开发底座(Supabase) + +先用 Agent Plan 身份登录 Supabase CLI。这里完成的是火山账号 OAuth 授权,不是输入另一枚 Key: + +```bash +byted-supabase-cli login --profile agent-plan --region cn-beijing --is-agent-plan +``` + +需要新建 Workspace 时,先确认持续计费与休眠策略,再由有 `aidap:CreateWorkspace` 权限的账号执行: + +```bash +byted-supabase-cli projects create --profile agent-plan --is-agent-plan +``` + +先查看计划,不写资源: + +```bash +node skills/sales-intelligence-workbench/scripts/setup-supabase.mjs +``` + +只有一个 Agent Plan Workspace 时脚本会自动选择;存在多个时按计划输出的 ID 明确选择。确认目标后执行: + +```bash +node skills/sales-intelligence-workbench/scripts/setup-supabase.mjs \ + --apply \ + --workspace-id \ + --profile agent-plan \ + --yes +``` + +该命令会先确认目标是 Agent Plan Workspace,再自动获取 Data API 地址和后端内部凭据、写入本机 `0600` 配置、应用版本化迁移、创建应用 Workspace 记录并回读验证。用户无需输入或查看内部凭据。普通按量 Workspace 会被拒绝;命令不会创建、暂停或删除云 Workspace。 + +### 诊断与启动 + +配置检查不调用外部服务: + +```bash +node skills/sales-intelligence-workbench/scripts/doctor.mjs +``` + +在用户知情会产生少量 Agent Plan 模型、专业数据集(DataPro)和豆包搜索(联网搜索)用量后,执行真实只读检查: + +```bash +node skills/sales-intelligence-workbench/scripts/doctor.mjs --live +``` + +单个上游临时故障不会阻止查看已有数据或使用无关能力;依赖故障 Provider 的操作仍会严格失败。启动并查看地址: + +```bash +node skills/sales-intelligence-workbench/scripts/start.mjs +node skills/sales-intelligence-workbench/scripts/status.mjs +``` + +`start.mjs` 会同时启动同源 API 和独立 Worker;`status.mjs` 分别报告两个进程。缺少队列迁移或 Worker 配置时会失败关闭,不会退回同步假成功。 + +首次打开页面时设置唯一的本机管理员用户名和密码,无需填写邮箱或确认邮件。设置完成后会直接进入工作台;后续使用同一浏览器打开时会自动恢复本机会话,默认最长保持一年。只有主动退出或本机会话失效时才需要再次输入原用户名和密码。匿名请求无法读取业务数据,付费 Provider 和运维接口仅对该管理员开放;当前版本只允许这一套管理员账号。 + +## 导入飞书资料 + +项目规定由 Codex CLI 调度飞书 CLI,使用当前用户授权读取,不依赖群机器人: + +```bash +node skills/sales-intelligence-workbench/scripts/login.mjs --username <工作台用户名> +``` + +上述命令会在终端隐藏输入密码,并把用户级短期会话保存为权限 `0600` 的本机文件。随后执行: + +```bash +node skills/sales-intelligence-workbench/scripts/import-feishu.mjs \ + --company-id \ + --doc "https://example.feishu.cn/wiki/..." +``` + +会话导入支持 `--p2p-user <联系人姓名>` 或 `--chat-id `;云文档只接受完整链接。启用 `FEISHU_CLI_IMPORT_ENABLED=true` 后,登录用户也可以在“历史资料”模块点击“导入飞书资料”,选择会话或云文档并查看本机任务进度。两种入口调用同一条受控链路:正文只写入 Agent 记忆(OpenViking),AI Native 应用开发底座(Supabase)只保存来源、内容指纹、增量游标和 OpenViking 引用。详见 [飞书导入说明](skills/sales-intelligence-workbench/references/feishu-import.md)。 + +## 运维 + +```bash +node skills/sales-intelligence-workbench/scripts/backup.mjs +node skills/sales-intelligence-workbench/scripts/stop.mjs +node skills/sales-intelligence-workbench/scripts/upgrade.mjs --source /absolute/path/to/new-source +node skills/sales-intelligence-workbench/scripts/uninstall.mjs +``` + +恢复默认只预检,并要求独立空目标、显式 `--apply` 和确认参数。卸载默认保留私密配置、备份和云端数据。 + +公网自托管需要 HTTPS 反向代理,并分别托管 API 与 Worker。配置和 systemd/Nginx 示例见 [单工作区自托管部署](docs/deployment/self-hosting.md)。 + +包含数据库迁移的升级应先在服务仍运行时检查待发布源码,再应用向后兼容迁移,最后短暂停机替换运行时: + +```bash +node skills/sales-intelligence-workbench/scripts/migrate.mjs --source /absolute/path/to/new-source +node skills/sales-intelligence-workbench/scripts/migrate.mjs --source /absolute/path/to/new-source --apply +node skills/sales-intelligence-workbench/scripts/smoke-paid-workflow.mjs --source /absolute/path/to/new-source +node skills/sales-intelligence-workbench/scripts/smoke-async-job-queue.mjs --source /absolute/path/to/new-source +node skills/sales-intelligence-workbench/scripts/stop.mjs +node skills/sales-intelligence-workbench/scripts/upgrade.mjs --source /absolute/path/to/new-source +node skills/sales-intelligence-workbench/scripts/start.mjs +``` + +## 开发与验证 + +```bash +npm ci +npm run verify +``` + +`npm ci` 使用仓库锁文件建立可复现的 Node.js 环境。根目录总验收会先检查 Skill 结构和隔离安装生命周期,再执行后端离线发布验收。整个流程依次覆盖前端语法、后端测试、发布密钥、Skill 分发包一致性和隔离安装生命周期,不访问外部 Provider,也不会产生 AFP。需要单独执行时可使用: + +```bash +cd backend && npm test +cd backend && npm run release:secrets +node skills/sales-intelligence-workbench/scripts/sync-assets.mjs +node skills/sales-intelligence-workbench/scripts/sync-assets.mjs --check +node skills/sales-intelligence-workbench/scripts/self-test.mjs +``` + +真实业务链路可使用 Skill 的 `verify-business-chain.mjs --confirm-live` 验证企业搜索与加入、 +带引用档案、Agent 记忆(OpenViking)召回/写入、资料问答、Provider Run 和 Token。该命令会产生 +AFP/Token 并保留业务记录。飞书增量导入、运行中重启、版本比较、备份与隔离恢复需要在 +获授权环境中分别验证;任何一步使用固定前端数据都不能作为真实验收结果。 + +## 已知限制 + +- 当前是单工作区、单管理员自托管架构;没有公开注册、成员或角色系统,也尚未支持企业 SSO、MFA 和多工作区管理。 +- 本机默认使用 HTTP;公网部署需自行配置 HTTPS 反向代理,并启用 Secure Cookie。 +- 已有 IP/用户级限流、请求体上限、Workspace 付费任务保护、Provider 熔断、独立异步 Worker 和档案采集检查点;目标数据库必须应用到 `202607300001`。当前仍没有精确 AFP/金额预算。 +- 持久化异步队列覆盖档案生成和 Agent 记忆(OpenViking)批量同步。前端飞书导入由后端进程内的受控任务执行;服务重启后不恢复进度,已成功写入的 Agent 记忆(OpenViking)正文和 AI Native 应用开发底座(Supabase)同步元数据会保留。 +- 飞书读取范围受当前用户权限和飞书 CLI 能力限制,不能绕过平台权限。 +- 新建 AI Native 应用开发底座(Supabase)Workspace 可能持续计费,因此 Skill 不会未经确认自动创建。 +- 上游 Provider 可用性由服务方决定,诊断成功不代表长期 SLA。 + +## 支持与安全 + +当前版本只支持单工作区、单用户、本机或受控内网自托管,不提供公网生产 SaaS、多人协作 +或 SLA。源码使用、分发和贡献应遵守 [LICENSE](LICENSE);第三方服务及数据仍受各自条款 +约束。 + +安全问题请参阅 [SECURITY.md](SECURITY.md),部署要求见 +[单工作区自托管部署](docs/deployment/self-hosting.md),贡献流程见 +[CONTRIBUTING.md](CONTRIBUTING.md),版本变化见 [CHANGELOG.md](CHANGELOG.md)。 diff --git a/demohouse/sales-intelligence-workbench/SECURITY.md b/demohouse/sales-intelligence-workbench/SECURITY.md new file mode 100644 index 00000000..9e7d4763 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/SECURITY.md @@ -0,0 +1,30 @@ +# Security Policy + +## Supported scope + +The current `v0.10.0` self-hosted open-source release supports one workspace and one local administrator. It includes Supabase Auth, browser and CLI sessions, CSRF protection for cookie-authenticated mutations, and an internal account binding for workspace isolation. It does not expose public registration, email confirmation, password-recovery email, member, or role-management flows. + +This is not a managed multi-tenant SaaS release. The default listener remains `127.0.0.1`. Do not expose the backend port directly to the public Internet; place the application behind an HTTPS reverse proxy and complete the deployment checks below. + +## Reporting a vulnerability + +Do not open a public issue containing credentials, customer data, Feishu content, Supabase identifiers, OpenViking resources, or exploitable details. Contact the repository owner through a private channel and include: + +- affected version or commit; +- impact and reproduction steps; +- whether real data or credentials may have been exposed; +- a minimal redacted proof of concept. + +Rotate any credential that appeared in chat, screenshots, logs, commits, or issue content. Removing a secret from the latest file does not remove it from Git history. + +## Deployment requirements + +- Keep Provider keys and the Supabase Service Role Key on the backend only. +- Keep the default loopback bind unless HTTPS, Secure Cookie, exact Origin allowlists, request limits, and network access controls are configured. +- Reset a forgotten password only through the interactive local reset command; never pass a password in shell arguments or logs. +- Use a dedicated Supabase Workspace and OpenViking namespace for each deployment. +- Import only Feishu content the operator is authorized to process. +- Store backups as private data and test restores on an isolated target. +- Keep audit events enabled for business writes, Provider probes, and workspace exports; audit payloads must remain metadata-only. +- Run configuration doctor, database migration smoke checks, the repository test suite, and release verification before upgrading. +- Do not describe this self-hosted release as a managed multi-tenant SaaS or an SLA-backed service. diff --git a/demohouse/sales-intelligence-workbench/THIRD_PARTY_NOTICES.md b/demohouse/sales-intelligence-workbench/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..4e443803 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/THIRD_PARTY_NOTICES.md @@ -0,0 +1,26 @@ +# 第三方组件与外部服务说明 + +## 随应用分发的代码 + +当前 `backend/package.json` 没有 npm 运行时或开发依赖。应用前端使用浏览器原生能力,后端使用 Node.js 标准库;仓库没有打包第三方 JavaScript、字体、图片或 CSS 资源。 + +GitHub Actions 工作流引用 `actions/checkout` 和 `actions/setup-node`。它们只在 CI 环境中运行,不进入应用安装包,其许可与使用条款以各自项目为准。 + +## 不随应用分发的外部能力 + +以下能力由用户自行开通、授权和配置,项目只通过公开接口或本机命令调用,不复制或再分发其服务端代码: + +- 火山方舟 Agent Plan 模型服务 +- 专业数据集(DataPro) +- 豆包搜索(联网搜索) +- Agent 记忆(OpenViking) +- AI Native 应用开发底座(Supabase) +- 飞书 CLI +- 火山引擎 Supabase CLI + +使用这些能力产生的费用、数据处理范围、服务可用性和许可约束,以用户账号对应的最新服务协议为准。 + +## 发布者责任 + +源码的使用与分发遵循根目录 [LICENSE](LICENSE)。贡献者和发布者应确保有权提交、分发相关 +代码与素材;外部服务仍受各自服务协议、数据处理条款和费用规则约束。 diff --git a/demohouse/sales-intelligence-workbench/UPSTREAM.json b/demohouse/sales-intelligence-workbench/UPSTREAM.json new file mode 100644 index 00000000..5672f6f3 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/UPSTREAM.json @@ -0,0 +1,6 @@ +{ + "repository": "https://github.com/3494036618-eng/sales-intelligence-workbench/", + "commit": "c9e3fe70824eae62d810d1d02186f616c4d2a041", + "version": "0.10.0", + "synced_at": "2026-08-21" +} diff --git a/demohouse/sales-intelligence-workbench/backend/.env.example b/demohouse/sales-intelligence-workbench/backend/.env.example new file mode 100644 index 00000000..8802c0ba --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/.env.example @@ -0,0 +1,116 @@ +# Copy to backend/.env.local only when running the backend without the Skill. +# Never commit real keys. + +# The public application has one fail-closed runtime and always uses Supabase. +REPOSITORY_MODE=supabase +HOST=127.0.0.1 +PORT=8787 + +# HTTP authentication and API boundary. +# Keep AUTH_COOKIE_SECURE=false only for loopback HTTP; use true behind HTTPS. +HTTP_AUTH_ENABLED=true +AUTH_BOOTSTRAP_ENABLED=true +AUTH_COOKIE_SECURE=false +AUTH_PROVIDER_TIMEOUT_MS=12000 +AUTH_SESSION_CACHE_TTL_MS=15000 +AUTH_REFRESH_COOKIE_MAX_AGE=31536000 +ALLOWED_ORIGINS=http://127.0.0.1:8787,http://localhost:8787 +TRUST_PROXY=false +API_MAX_BODY_BYTES=1048576 +API_RATE_LIMIT_PER_MIN=180 +API_WRITE_RATE_LIMIT_PER_MIN=60 +API_PAID_RATE_LIMIT_PER_MIN=12 +AUTH_RATE_LIMIT_PER_15_MIN=20 + +# Workspace-wide paid workflow guard. +# A workflow attempt may call one or more Agent Plan capabilities. +PAID_WORKFLOW_MAX_CONCURRENCY=2 +PAID_WORKFLOW_DAILY_LIMIT=100 +PAID_WORKFLOW_BUDGET_TIMEZONE=Asia/Shanghai +PAID_WORKFLOW_STALE_AFTER_SECONDS=1800 +ASYNC_JOBS_ENABLED=true +JOB_WORKER_POLL_MS=1000 +JOB_WORKER_LEASE_SECONDS=600 +PROVIDER_CIRCUIT_BREAKER_ENABLED=true +PROVIDER_CIRCUIT_BREAKER_FAILURE_THRESHOLD=5 +PROVIDER_CIRCUIT_BREAKER_COOLDOWN_SECONDS=60 + +# Optional company used only by live read-only DataPro/Web Search probes. +LIVE_PROBE_COMPANY=北京火山引擎科技有限公司 + +# Agent Plan API Key shared by model, DataPro and Doubao Search. +# Capability-specific keys below are optional advanced overrides. +AGENT_PLAN_API_KEY= + +# Web search provider +WEB_SEARCH_API_KEY= +WEB_SEARCH_BASE_URL=https://open.feedcoopapi.com/search_api/web_search +WEB_SEARCH_TRAFFIC_TAG=skill_web_search_common +WEB_SEARCH_MAX_COUNT=1 +WEB_SEARCH_RUN_ENABLED=true +WEB_SEARCH_TIMEOUT_MS=20000 +# Provider transport retries only cover transient timeout, rate-limit, network and upstream failures. +WEB_SEARCH_MAX_RETRIES=1 + +# DataPro provider +DATAPRO_API_KEY= +DATAPRO_MCP_URL=https://datapro.hqd.cn-beijing.volces.com/mcp +DATAPRO_RUN_ENABLED=true +DATAPRO_MAX_SOURCES=4 +DATAPRO_TIMEOUT_MS=45000 +DATAPRO_MAX_RETRIES=1 + +# Supabase repository / provider: business entities, dossier versions, jobs and sync metadata. +VOLCENGINE_ACCESS_KEY= +VOLCENGINE_SECRET_KEY= +VOLCENGINE_REGION=cn-beijing +SUPABASE_WORKSPACE_ID= +SUPABASE_BRANCH_ID= +SUPABASE_API_URL= +SUPABASE_SERVICE_ROLE_KEY= +SUPABASE_DATA_API_TIMEOUT_MS=15000 +APP_WORKSPACE_ID= +APP_WORKSPACE_SLUG=default +APP_WORKSPACE_NAME=Sales Workbench +APP_WORKSPACE_PLAN_MODE=agent_plan +SUPABASE_READ_ONLY=false +SUPABASE_RUN_ENABLED=true +SUPABASE_CLI_BIN=byted-supabase-cli +SUPABASE_CLI_PROFILE=current +SUPABASE_TIMEOUT_MS=30000 + +# OpenViking provider: imported Feishu content, QA sessions and long-term Agent memory. +# Do not ask the user to fill the internal fields below. The setup Skill uses the +# single Agent Plan Key to initialize a memory collection and writes them privately. +OPENVIKING_API_KEY= +OPENVIKING_BASE_URL=https://api.vikingdb.cn-beijing.volces.com/openviking +OPENVIKING_RESOURCE_ID= +OPENVIKING_COLLECTION_NAME= +OPENVIKING_CLI= +OPENVIKING_CLI_CONFIG= +OPENVIKING_AGENT_ID=default +OPENVIKING_RUN_ENABLED=true +OPENVIKING_SALES_ROOT_URI=viking://resources/sales-workbench +OPENVIKING_FIND_LIMIT=3 +OPENVIKING_TIMEOUT_MS=120000 +OPENVIKING_QA_AUTO_COMMIT_EVERY=4 +OPENVIKING_QA_KEEP_RECENT_MESSAGES=6 + +# Local Feishu CLI import. It is disabled until the operator explicitly enables it. +# The server invokes the authenticated local lark-cli process and never sends its credentials to the browser. +FEISHU_CLI_IMPORT_ENABLED= +FEISHU_CLI_IMPORT_TASK_LIMIT=100 + +# Model provider +MODEL_API_KEY= +MODEL_BASE_URL=https://ark.cn-beijing.volces.com/api/plan/v3 +MODEL_NAME=ark-code-latest +MODEL_RUN_ENABLED=true +MODEL_MAX_CARDS=2 +MODEL_MAX_TOKENS=700 +MODEL_TIMEOUT_MS=90000 +MODEL_MAX_RETRIES=1 +DOSSIER_AGENT_MAX_CALLS=3 +DOSSIER_CHECKPOINT_TTL_MS=1800000 +DOSSIER_DATAPRO_CONCURRENCY=2 +DOSSIER_WEB_CONCURRENCY=3 diff --git a/demohouse/sales-intelligence-workbench/backend/package.json b/demohouse/sales-intelligence-workbench/backend/package.json new file mode 100644 index 00000000..6991476b --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/package.json @@ -0,0 +1,37 @@ +{ + "name": "sales-intelligence-workbench-api", + "version": "0.10.0", + "private": true, + "type": "module", + "scripts": { + "dev": "node src/server.js", + "test": "node --test tests/*.test.mjs", + "release:secrets": "node scripts/check-release-secrets.mjs", + "release:verify": "node scripts/verify-release-local.mjs", + "doctor": "node scripts/doctor.mjs", + "doctor:live": "node scripts/baseline-real-readonly.mjs --live", + "db:migrate": "node scripts/migrate-supabase.mjs --apply", + "db:migrate:check": "node scripts/migrate-supabase.mjs", + "db:verify-qa-boundary": "node scripts/verify-openviking-qa-boundary.mjs", + "db:verify-security-boundary": "node scripts/verify-supabase-security-boundary.mjs", + "db:bootstrap-workspace": "node scripts/bootstrap-workspace.mjs", + "db:configure-data-api": "node scripts/configure-supabase-data-api.mjs", + "db:backup": "node scripts/backup-supabase.mjs", + "db:restore": "node scripts/restore-supabase.mjs", + "feishu:import": "node scripts/import-feishu-cli.mjs", + "baseline:real": "node scripts/baseline-real-readonly.mjs", + "baseline:real:live": "node scripts/baseline-real-readonly.mjs --live", + "verify:business": "node scripts/verify-business-chain.mjs", + "workspace:export": "node scripts/export-workspace.mjs", + "preflight:real": "node scripts/preflight-real.mjs", + "smoke:stage2-data-api": "node scripts/smoke-stage2-data-api.mjs", + "smoke:stage3-material-sync": "node scripts/smoke-stage3-material-sync.mjs", + "smoke:paid-workflow": "node scripts/smoke-paid-workflow-guard.mjs", + "smoke:async-job-queue": "node scripts/smoke-async-job-queue.mjs", + "smoke:stage2-api": "node scripts/smoke-stage2-api.mjs", + "smoke:stage2-backup-package": "node scripts/smoke-stage2-backup-package.mjs" + }, + "engines": { + "node": ">=20" + } +} diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/backup-supabase.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/backup-supabase.mjs new file mode 100644 index 00000000..998daabc --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/backup-supabase.mjs @@ -0,0 +1,174 @@ +import { randomUUID } from "node:crypto"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + BACKUP_FORMAT_VERSION, + WORKSPACE_TABLE_SPECS, + sha256File, + validateBackupPackage, +} from "../src/backup/supabaseBackup.js"; +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseDataProvider } from "../src/providers/supabaseDataProvider.js"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(scriptDir, "../.."); +const migrationsDir = resolve(repositoryRoot, "supabase/migrations"); +const env = createEnvReader(); +const provider = createSupabaseDataProvider({ env }); +const workspaceId = env.value("APP_WORKSPACE_ID"); +const cloudWorkspaceId = env.value("SUPABASE_WORKSPACE_ID"); +const branchId = env.value("SUPABASE_BRANCH_ID"); + +function option(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : ""; +} + +function timestamp() { + return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); +} + +function writePrivateJson(filePath, value) { + writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: "wx" }); + chmodSync(filePath, 0o600); +} + +async function readAll(table, options = {}) { + const rows = []; + const pageSize = 500; + let offset = 0; + while (true) { + const page = await provider.select(table, { + select: options.select || "*", + filters: options.filters || {}, + order: options.order, + limit: pageSize, + offset, + }); + if (!Array.isArray(page)) throw new Error(`Data API returned a non-array response for ${table}.`); + rows.push(...page); + if (page.length < pageSize) break; + offset += pageSize; + } + return rows; +} + +if (!provider.isConfigured() || !provider.isRunEnabled()) { + throw new Error("Configured and enabled Supabase Data API access is required for backup."); +} +if (!workspaceId || !cloudWorkspaceId || !branchId) { + throw new Error("APP_WORKSPACE_ID, SUPABASE_WORKSPACE_ID, and SUPABASE_BRANCH_ID are required for backup."); +} + +const backupId = `supabase-${timestamp()}-${randomUUID().slice(0, 8)}`; +const outputDir = resolve(option("--output-dir") || resolve(repositoryRoot, "backups/private/supabase", backupId)); +if (existsSync(outputDir)) throw new Error(`Backup directory already exists: ${outputDir}`); +mkdirSync(outputDir, { recursive: true, mode: 0o700 }); +chmodSync(outputDir, 0o700); + +const workspaceRows = await readAll("app_workspaces", { + filters: { id: `eq.${workspaceId}` }, + order: "id.asc", +}); +if (workspaceRows.length !== 1) throw new Error(`Expected exactly one application workspace, found ${workspaceRows.length}.`); + +const tables = { app_workspaces: workspaceRows }; +for (const spec of WORKSPACE_TABLE_SPECS) { + tables[spec.table] = await readAll(spec.table, { + filters: { workspace_id: `eq.${workspaceId}` }, + order: spec.order, + }); +} + +const memberUserIds = [...new Set((tables.app_workspace_members || []).map((row) => row.user_id).filter(Boolean))]; +tables.app_users = []; +for (const userId of memberUserIds) { + const users = await readAll("app_users", { filters: { id: `eq.${userId}` }, order: "id.asc" }); + tables.app_users.push(...users); +} + +const migrations = await readAll("schema_migrations", { order: "version.asc" }); +const appliedVersions = new Set(migrations.map((entry) => entry.version)); +const localMigrations = readdirSync(migrationsDir) + .filter((name) => /^\d+.*\.sql$/.test(name)) + .sort(); +for (const version of appliedVersions) { + if (!localMigrations.some((name) => name.startsWith(version))) { + throw new Error(`Applied migration ${version} is missing from the local repository.`); + } +} + +const backupMigrationsDir = resolve(outputDir, "migrations"); +mkdirSync(backupMigrationsDir, { mode: 0o700 }); +for (const migration of localMigrations.filter((name) => appliedVersions.has(name.slice(0, 12)))) { + const destination = resolve(backupMigrationsDir, migration); + copyFileSync(resolve(migrationsDir, migration), destination); + chmodSync(destination, 0o600); +} + +const exportedAt = new Date().toISOString(); +const data = { + format_version: BACKUP_FORMAT_VERSION, + backup_id: backupId, + exported_at: exportedAt, + source: { + cloud_workspace_id: cloudWorkspaceId, + branch_id: branchId, + app_workspace_id: workspaceId, + app_workspace_slug: env.value("APP_WORKSPACE_SLUG"), + }, + schema_migrations: migrations, + tables, +}; +const dataPath = resolve(outputDir, "data.json"); +writePrivateJson(dataPath, data); + +const files = [dataPath, ...readdirSync(backupMigrationsDir).sort().map((name) => resolve(backupMigrationsDir, name))] + .map((filePath) => ({ + path: relative(outputDir, filePath), + bytes: statSync(filePath).size, + sha256: sha256File(filePath), + })); +const rowCounts = Object.fromEntries(Object.entries(tables).map(([table, rows]) => [table, rows.length])); +const manifest = { + format_version: BACKUP_FORMAT_VERSION, + backup_id: backupId, + created_at: exportedAt, + source: data.source, + required_migrations: migrations.map((entry) => entry.version), + row_counts: rowCounts, + files, + notes: [ + "The package contains private application data and must not be committed.", + "Authentication users and provider secret values are not backed up by this package.", + ], +}; +const manifestPath = resolve(outputDir, "manifest.json"); +writePrivateJson(manifestPath, manifest); + +validateBackupPackage( + outputDir, + JSON.parse(readFileSync(manifestPath, "utf8")), + JSON.parse(readFileSync(dataPath, "utf8")), +); + +console.log(JSON.stringify({ + ok: true, + backup_id: backupId, + output_dir: outputDir, + cloud_workspace_id: cloudWorkspaceId, + app_workspace_id: workspaceId, + migration_count: migrations.length, + row_counts: rowCounts, + checksums_verified: true, +}, null, 2)); diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/baseline-real-readonly.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/baseline-real-readonly.mjs new file mode 100644 index 00000000..20c125db --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/baseline-real-readonly.mjs @@ -0,0 +1,244 @@ +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createRuntimePolicy, publicRuntimePolicy } from "../src/config/runtimePolicy.js"; +import { createDataProProvider } from "../src/providers/dataProProvider.js"; +import { createModelProvider } from "../src/providers/modelProvider.js"; +import { createOpenVikingProvider } from "../src/providers/openVikingProvider.js"; +import { createSupabaseDataProvider } from "../src/providers/supabaseDataProvider.js"; +import { createWebSearchProvider } from "../src/providers/webSearchProvider.js"; + +const live = process.argv.includes("--live"); +const onlyProviderIndex = process.argv.indexOf("--only-provider"); +const onlyProvider = onlyProviderIndex >= 0 ? String(process.argv[onlyProviderIndex + 1] || "").trim() : ""; +const supportedProviders = new Set(["model", "datapro", "web_search", "openviking", "supabase"]); +if (onlyProvider && !supportedProviders.has(onlyProvider)) { + throw new Error(`Unsupported --only-provider value: ${onlyProvider}`); +} +const env = createEnvReader(); +const runtimePolicy = createRuntimePolicy({ env }); + +function truthy(value) { + return ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase()); +} + +function safeError(result) { + if (!result?.error) return null; + return { + code: String(result.error.code || "error").slice(0, 100), + message: String(result.error.message || "").slice(0, 300), + http_status: result.http_status || null, + }; +} + +function compactUsage(usage) { + if (!usage || typeof usage !== "object") return null; + return { + prompt_tokens: usage.prompt_tokens ?? null, + completion_tokens: usage.completion_tokens ?? null, + total_tokens: usage.total_tokens ?? null, + }; +} + +function providerState(provider) { + return { + configured: Boolean(provider.isConfigured()), + enabled: Boolean(provider.isRunEnabled()), + }; +} + +function resultCount(value) { + if (Array.isArray(value)) return value.length; + if (Array.isArray(value?.items)) return value.items.length; + if (Array.isArray(value?.result)) return value.result.length; + return value ? 1 : 0; +} + +async function checked(name, fn) { + const startedAt = Date.now(); + try { + const result = await fn(); + const elapsedMs = Date.now() - startedAt; + return { + name, + called: true, + ok: Boolean(result?.ok), + provider_mode: result?.provider_mode || (result?.ok ? "real" : null), + request_id: result?.request_id || null, + raw_ref: result?.raw_ref || null, + latency_ms: result?.latency_ms ?? elapsedMs, + elapsed_ms: elapsedMs, + attempts: Math.max(1, Number(result?.attempts || 1)), + usage: compactUsage(result?.usage), + error: safeError(result), + result, + }; + } catch (error) { + return { + name, + called: true, + ok: false, + provider_mode: null, + request_id: null, + raw_ref: null, + latency_ms: Date.now() - startedAt, + elapsed_ms: Date.now() - startedAt, + attempts: 1, + usage: null, + error: { + code: "exception", + message: String(error?.message || error).slice(0, 300), + http_status: null, + }, + result: null, + }; + } +} + +function publicResult(check) { + if (!check) return null; + return { + called: check.called, + ok: check.ok, + provider_mode: check.provider_mode, + request_id: check.request_id, + raw_ref: check.raw_ref, + latency_ms: check.latency_ms, + elapsed_ms: check.elapsed_ms, + attempts: check.attempts, + usage: check.usage, + error: check.error, + }; +} + +const providers = { + model: createModelProvider(), + datapro: createDataProProvider(), + web_search: createWebSearchProvider(), + openviking: createOpenVikingProvider(), + supabase: createSupabaseDataProvider(), +}; + +const providerStates = Object.fromEntries( + Object.entries(providers).map(([name, provider]) => [name, providerState(provider)]), +); + +const runtime = { + app: publicRuntimePolicy(runtimePolicy), + repository_mode: env.value("REPOSITORY_MODE", "supabase"), + supabase_read_only: truthy(env.value("SUPABASE_READ_ONLY", "false")), +}; + +const startedAt = new Date().toISOString(); +const checks = {}; +const selected = (name) => !onlyProvider || onlyProvider === name; +const liveProbeCompany = process.env.LIVE_PROBE_COMPANY || "北京火山引擎科技有限公司"; + +if (live) { + if (selected("model") && providerStates.model.enabled) { + checks.model = await checked("model", () => providers.model.callJson({ + operation: "sales_workbench_readonly_baseline", + maxTokens: 80, + system: "你是只读连通性探针。只输出 JSON,不调用工具,不补充事实。", + payload: { + task: "返回指定结构", + output_schema: { ok: true, message: "ready" }, + }, + })); + } + + if (selected("datapro") && providerStates.datapro.enabled) { + checks.datapro = await checked( + "datapro", + () => providers.datapro.callTool(`${liveProbeCompany} 企业工商信息`), + ); + } + + if (selected("web_search") && providerStates.web_search.enabled) { + checks.web_search = await checked( + "web_search", + () => providers.web_search.search({ + query: "火山引擎 Agent Plan 官方文档", + count: 1, + need_summary: false, + }), + ); + } + + if (selected("openviking") && providerStates.openviking.enabled) { + const health = await checked("openviking_health", () => providers.openviking.health()); + let find = null; + if (health.ok) { + find = await checked( + "openviking_find", + () => providers.openviking.findMemories("销售工作台", { limit: 1 }), + ); + } + checks.openviking = { + health: publicResult(health), + find: publicResult(find), + find_result_count: find?.ok ? resultCount(find.result?.result) : 0, + ok: Boolean(health.ok && find?.ok), + }; + } + + if (selected("supabase") && providerStates.supabase.enabled) { + checks.supabase = await checked("supabase", () => providers.supabase.probe()); + } +} + +const blockers = []; + +blockers.push(...runtimePolicy.blockers); + +if (runtime.repository_mode !== "supabase") { + blockers.push("REPOSITORY_MODE is not supabase."); +} + +for (const [name, state] of Object.entries(providerStates).filter(([name]) => selected(name))) { + if (!state.configured) blockers.push(name + " is not configured."); + if (!state.enabled) blockers.push(name + " is not enabled."); +} + +if (live) { + for (const name of ["model", "datapro", "web_search", "supabase"].filter(selected)) { + if (!checks[name]?.ok) blockers.push(name + " live check failed."); + } + if (selected("openviking") && !checks.openviking?.ok) blockers.push("openviking live check failed."); +} + +const report = { + schema_version: 1, + check_type: live ? onlyProvider ? "read_only_live_partial" : "read_only_live" : "configuration_only", + selected_provider: onlyProvider || null, + started_at: startedAt, + read_only_contract: { + business_data_writes: false, + openviking_writes: false, + supabase_writes: false, + model_request: live && selected("model"), + datapro_request: live && selected("datapro"), + web_search_request: live && selected("web_search"), + }, + runtime, + providers: providerStates, + checks: { + model: publicResult(checks.model), + datapro: publicResult(checks.datapro), + web_search: checks.web_search + ? { + ...publicResult(checks.web_search), + result_count: checks.web_search.result?.result_count ?? 0, + } + : null, + openviking: checks.openviking || null, + supabase: publicResult(checks.supabase), + }, + runtime_ready: !onlyProvider && blockers.length === 0, + blockers, + finished_at: new Date().toISOString(), +}; + +console.log(JSON.stringify(report, null, 2)); + +if (live && blockers.length) { + process.exitCode = 1; +} diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/bootstrap-workspace.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/bootstrap-workspace.mjs new file mode 100644 index 00000000..a60dd1d9 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/bootstrap-workspace.mjs @@ -0,0 +1,44 @@ +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const env = createEnvReader(); +const provider = createSupabaseProvider({ + env: { + ...env, + value(name, fallback = "") { + if (name === "SUPABASE_READ_ONLY") return "false"; + return env.value(name, fallback); + }, + }, +}); +const workspaceId = env.value("APP_WORKSPACE_ID").trim(); +const slug = env.value("APP_WORKSPACE_SLUG", "default").trim(); +const name = env.value("APP_WORKSPACE_NAME", "Sales Workbench").trim(); +const planMode = env.value("APP_WORKSPACE_PLAN_MODE", "standard").trim(); + +if (!UUID_PATTERN.test(workspaceId)) throw new Error("APP_WORKSPACE_ID must be a valid UUID."); +if (!/^[a-z0-9][a-z0-9-]{1,62}$/.test(slug)) throw new Error("APP_WORKSPACE_SLUG must contain 2-63 lowercase letters, numbers or hyphens."); +if (!name) throw new Error("APP_WORKSPACE_NAME is required."); +if (!new Set(["standard", "agent_plan"]).has(planMode)) throw new Error("APP_WORKSPACE_PLAN_MODE must be standard or agent_plan."); + +const quote = (value) => `'${String(value).replace(/'/g, "''")}'`; +const result = provider.executeSqlSync(` + insert into public.app_workspaces (id, slug, name, plan_mode, settings_json) + values (${quote(workspaceId)}::uuid, ${quote(slug)}, ${quote(name)}, ${quote(planMode)}, '{}'::jsonb) + on conflict (id) do update set + slug = excluded.slug, + name = excluded.name, + plan_mode = excluded.plan_mode, + updated_at = now() + returning id, slug, name, plan_mode, created_at, updated_at; +`); +if (!result.ok) throw new Error(result.error?.message || "Application workspace bootstrap failed."); + +const verify = provider.executeSqlSync(` + select id, slug, name, plan_mode + from public.app_workspaces + where id = ${quote(workspaceId)}::uuid; +`); +if (!verify.ok || verify.rows?.length !== 1) throw new Error(verify.error?.message || "Application workspace verification failed."); +console.log(JSON.stringify({ ok: true, workspace: verify.rows[0] }, null, 2)); diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/check-release-secrets.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/check-release-secrets.mjs new file mode 100644 index 00000000..a1cdad54 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/check-release-secrets.mjs @@ -0,0 +1,136 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const defaultRoot = path.resolve(scriptDir, "../.."); + +const ignoredDirectories = new Set([ + ".git", + ".idea", + ".vscode", + "coverage", + "dist", + "node_modules", +]); + +const forbiddenSecretFiles = [ + /^\.env$/i, + /^\.env\.(?!example$|sample$)[^.]+$/i, + /^credentials(?:\.[^.]+)?$/i, + /^secrets?(?:\.[^.]+)?$/i, + /\.(?:key|pem|p12|pfx)$/i, +]; + +const contentRules = [ + { + id: "agent_plan_api_key", + pattern: /\bark-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}-[0-9a-f]{4,}\b/gi, + }, + { + id: "volcengine_access_key", + pattern: /\bAK(?:LT|TP)[A-Za-z0-9]{20,}\b/g, + }, + { + id: "jwt_or_supabase_key", + pattern: /\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b/g, + }, + { + id: "private_key", + pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g, + }, +]; + +const assignmentPattern = /^[ \t]*(AGENT_PLAN_API_KEY|ARK_API_KEY|VOLCENGINE_ACCESS_KEY_ID|VOLCENGINE_SECRET_ACCESS_KEY|SUPABASE_SERVICE_ROLE_KEY)[ \t]*=[ \t]*([^\r\n]*)[ \t]*$/gim; + +function normalizeAssignedValue(value) { + const withoutComment = String(value || "").replace(/\s+#.*$/, "").trim(); + return withoutComment.replace(/^(['"])(.*)\1$/, "$2").trim(); +} + +function isPlaceholder(value) { + const normalized = normalizeAssignedValue(value); + if (!normalized) return true; + if (/^(?:<.*>|\$\{.*\}|\*+|x+|your[-_ ]|replace[-_ ]|example|sample|test|mock)/i.test(normalized)) return true; + return normalized.length < 16; +} + +export function scanTextForSecrets(text, relativePath = "unknown") { + const findings = []; + + for (const rule of contentRules) { + rule.pattern.lastIndex = 0; + if (rule.pattern.test(text)) findings.push({ rule: rule.id, path: relativePath }); + } + + assignmentPattern.lastIndex = 0; + for (const match of text.matchAll(assignmentPattern)) { + if (!isPlaceholder(match[2])) { + findings.push({ rule: `configured_${match[1].toLowerCase()}`, path: relativePath }); + } + } + + return findings; +} + +function isForbiddenSecretFile(name) { + if (/\.example$|\.sample$/i.test(name)) return false; + return forbiddenSecretFiles.some((pattern) => pattern.test(name)); +} + +async function collectFiles(root, current = root, output = []) { + const entries = await fs.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) await collectFiles(root, path.join(current, entry.name), output); + continue; + } + if (entry.isFile()) output.push(path.join(current, entry.name)); + } + return output; +} + +export async function scanReleaseTree(root = defaultRoot) { + const absoluteRoot = path.resolve(root); + const files = await collectFiles(absoluteRoot); + const findings = []; + + for (const filePath of files) { + const relativePath = path.relative(absoluteRoot, filePath); + if (isForbiddenSecretFile(path.basename(filePath))) { + findings.push({ rule: "forbidden_secret_file", path: relativePath }); + continue; + } + + const stat = await fs.stat(filePath); + if (stat.size > 5 * 1024 * 1024) continue; + const bytes = await fs.readFile(filePath); + if (bytes.subarray(0, 4096).includes(0)) continue; + findings.push(...scanTextForSecrets(bytes.toString("utf8"), relativePath)); + } + + const unique = new Map(findings.map((finding) => [`${finding.rule}:${finding.path}`, finding])); + return [...unique.values()].sort((left, right) => left.path.localeCompare(right.path) || left.rule.localeCompare(right.rule)); +} + +async function main() { + const rootArgument = process.argv.find((argument) => argument.startsWith("--root=")); + const root = rootArgument ? rootArgument.slice("--root=".length) : defaultRoot; + const findings = await scanReleaseTree(root); + if (!findings.length) { + console.log("发布密钥扫描通过:未发现真实凭证或私钥文件。"); + return; + } + + console.error(`发布密钥扫描失败:发现 ${findings.length} 个风险位置。`); + for (const finding of findings) console.error(`- ${finding.rule}: ${finding.path}`); + process.exitCode = 1; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error?.message || String(error)); + process.exitCode = 1; + }); +} diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/configure-supabase-data-api.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/configure-supabase-data-api.mjs new file mode 100644 index 00000000..89a91622 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/configure-supabase-data-api.mjs @@ -0,0 +1,52 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, readFileSync, writeFileSync } from "node:fs"; +import { createEnvReader, loadLocalEnv, localEnvUrl } from "../src/config/runtimeEnv.js"; + +function setEnvLine(content, name, value) { + const line = `${name}=${value}`; + const pattern = new RegExp(`^${name}=.*$`, "m"); + if (pattern.test(content)) return content.replace(pattern, line); + return `${content.trimEnd()}\n${line}\n`; +} + +const localEnv = loadLocalEnv(); +const env = createEnvReader(localEnv); +const workspaceId = env.value("SUPABASE_WORKSPACE_ID"); +const branchId = env.value("SUPABASE_BRANCH_ID"); +const apiUrl = env.value("SUPABASE_API_URL").replace(/\/$/, ""); +const command = env.value("SUPABASE_CLI_BIN", "byted-supabase-cli"); +if (!workspaceId || !branchId || !apiUrl) { + throw new Error("SUPABASE_WORKSPACE_ID, SUPABASE_BRANCH_ID and SUPABASE_API_URL are required."); +} + +const runtimeEnv = { ...process.env, ...localEnv }; +const result = spawnSync(command, [ + "projects", "api-keys", + "--workspace-id", workspaceId, + "--branch-id", branchId, + "-o", "json", +], { encoding: "utf8", env: runtimeEnv }); +if (result.status !== 0) throw new Error(result.stderr || "Unable to read Supabase API keys."); +const keys = JSON.parse(result.stdout || "[]"); +const serviceKey = keys.find((item) => item.name === "ServiceRoleKey")?.api_key; +if (!serviceKey) throw new Error("ServiceRoleKey was not returned for the configured Supabase branch."); + +const response = await fetch(`${apiUrl}/rest/v1/app_workspaces?select=id&limit=1`, { + headers: { apikey: serviceKey, Authorization: `Bearer ${serviceKey}` }, +}); +if (!response.ok) throw new Error(`Supabase Data API probe failed with HTTP ${response.status}.`); + +let content = readFileSync(localEnvUrl, "utf8"); +content = setEnvLine(content, "SUPABASE_SERVICE_ROLE_KEY", serviceKey); +content = setEnvLine(content, "SUPABASE_DATA_API_TIMEOUT_MS", "15000"); +writeFileSync(localEnvUrl, content, { encoding: "utf8", mode: 0o600 }); +chmodSync(localEnvUrl, 0o600); + +console.log(JSON.stringify({ + ok: true, + api_url: apiUrl, + key_name: "ServiceRoleKey", + key_type: "Service", + probe_status: response.status, + stored_in: "backend/.env.local", +}, null, 2)); diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/doctor.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/doctor.mjs new file mode 100644 index 00000000..6085ff3e --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/doctor.mjs @@ -0,0 +1,30 @@ +import { getProviderStatus } from "../src/config/providerConfig.js"; +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createRuntimePolicy, publicRuntimePolicy } from "../src/config/runtimePolicy.js"; + +const env = createEnvReader(); +const runtimePolicy = createRuntimePolicy({ env }); +const providerStatus = getProviderStatus({ env, runtimePolicy }); +const requiredProviders = ["datapro", "web_search", "model", "openviking", "supabase"]; +const providers = Object.fromEntries(providerStatus.providers.map((provider) => [provider.id, { + status: provider.status, + run_enabled: provider.safe_config?.run_enabled ?? null, + missing: provider.missing, +} ])); +const providerBlockers = requiredProviders.filter((id) => providers[id]?.status !== "configured"); +const ok = runtimePolicy.ready && providerBlockers.length === 0; + +console.log(JSON.stringify({ + checked_at: new Date().toISOString(), + ok, + runtime: publicRuntimePolicy(runtimePolicy), + providers, + warnings: [], + blockers: [ + ...runtimePolicy.blockers, + ...providerBlockers.map((id) => `${id} is not configured`), + ], + live_check_command: "npm run doctor:live", +}, null, 2)); + +if (!ok) process.exitCode = 1; diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/export-workspace.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/export-workspace.mjs new file mode 100644 index 00000000..d2bea44a --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/export-workspace.mjs @@ -0,0 +1,119 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { backendFetch, readAuthSession } from "./import-feishu-cli.mjs"; + +const DEFAULT_API_URL = "http://127.0.0.1:8787"; + +function usage() { + return ` +导出当前工作区的可迁移业务数据(仅 owner) + +用法: + node scripts/export-workspace.mjs [--api-url ] [--auth-session ] [--output ] + +输出包含企业、目标、公开档案、资料正文、同步游标和问答,属于私密业务数据。 +不会包含密钥、Provider 原文、OpenViking 内部 URI、Worker、租约或运行诊断。 +`; +} + +function optionValue(argv, index, name) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${name} 缺少参数值。`); + return value; +} + +function parseArgs(argv) { + const options = { + apiUrl: process.env.SALES_WORKBENCH_API_URL || "", + authSession: process.env.SALES_WORKBENCH_AUTH_SESSION + || path.join(os.homedir(), ".local", "state", "sales-intelligence-workbench", "cli-session.json"), + output: "", + help: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--help" || argument === "-h") options.help = true; + else if (argument === "--api-url") { + options.apiUrl = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--auth-session") { + options.authSession = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--output") { + options.output = optionValue(argv, index, argument); + index += 1; + } else { + throw new Error(`未知参数:${argument}`); + } + } + return options; +} + +function assertPrivateSession(filePath) { + const session = readAuthSession(filePath); + if (!session) throw new Error("未找到 CLI 登录态。请先运行 Skill 的 login.mjs。"); + const mode = fs.statSync(filePath).mode & 0o077; + if (mode !== 0) throw new Error("CLI 会话文件权限不安全;请将其权限改为 0600 后重试。"); + return session; +} + +function defaultOutput() { + const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); + return path.join( + os.homedir(), + ".local", + "state", + "sales-intelligence-workbench", + "exports", + `workspace-${stamp}.json`, + ); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + process.stdout.write(usage().trimStart()); + return; + } + const session = assertPrivateSession(options.authSession); + options.apiUrl = String(options.apiUrl || session.api_url || DEFAULT_API_URL).replace(/\/$/, ""); + if (!/^https?:\/\/[^/]+/i.test(options.apiUrl)) throw new Error("--api-url 不是有效的 HTTP(S) 地址。"); + + const response = await backendFetch(`${options.apiUrl}/api/admin/workspace-export`, { + method: "GET", + }, options); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const requestId = payload?.meta?.request_id ? `,请求ID ${payload.meta.request_id}` : ""; + throw new Error(`${payload?.error?.message || `导出失败(HTTP ${response.status})`}${requestId}`); + } + const exported = payload.data; + if (exported?.format !== "sales-intelligence-workbench-workspace-export") { + throw new Error("服务端没有返回有效的工作区业务数据包。"); + } + + const outputPath = path.resolve(options.output || defaultOutput()); + fs.mkdirSync(path.dirname(outputPath), { recursive: true, mode: 0o700 }); + fs.chmodSync(path.dirname(outputPath), 0o700); + fs.writeFileSync(outputPath, `${JSON.stringify(exported, null, 2)}\n`, { mode: 0o600, flag: "wx" }); + fs.chmodSync(outputPath, 0o600); + process.stdout.write(`${JSON.stringify({ + ok: true, + output: outputPath, + goal_count: exported.goals?.length || 0, + enterprise_count: exported.enterprises?.length || 0, + contains_private_business_data: true, + }, null, 2)}\n`); +} + +export { assertPrivateSession, parseArgs }; + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`${JSON.stringify({ ok: false, error: { message: error.message } }, null, 2)}\n`); + process.exitCode = 1; + }); +} diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/import-feishu-cli.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/import-feishu-cli.mjs new file mode 100644 index 00000000..957587ff --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/import-feishu-cli.mjs @@ -0,0 +1,601 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const DEFAULT_API_URL = "http://127.0.0.1:8787"; +const DEFAULT_PAGE_SIZE = 20; + +function usage() { + return ` +Usage: + npm run feishu:import -- --company-id [sources] + +Sources: + --doc Import a Feishu/Lark doc as markdown. + --p2p-user Import direct messages with a person. + --chat-id Import messages from a chat. + --message-query Import message search results. + +Options: + --api-url Backend URL. Default: ${DEFAULT_API_URL} + --auth-session Local 0600 CLI session created by the Skill login command. + --start Explicit message start time. + --end Message end time. + --page-size Message page size, max 50. Default: ${DEFAULT_PAGE_SIZE} + --page-limit Page limit for chat pagination. Default: 1 + --title-prefix Prefix imported material titles. + --max-attempts Retry attempts for transient failures. Default: 3 + --retry-delay-ms Initial retry delay. Default: 800 + --no-incremental Ignore the saved backend checkpoint. + --resume-source Resume a paused source before importing. + --dry-run Fetch from Feishu but do not import to backend. + +Examples: + npm run feishu:import -- --company-id company_1 --p2p-user "联系人姓名" --start 2026-06-01 + npm run feishu:import -- --company-id company_1 --doc "https://example.feishu.cn/wiki/..." +`; +} + +function parseArgs(argv) { + const args = { + apiUrl: DEFAULT_API_URL, + companyId: "", + docs: [], + p2pUser: "", + chatId: "", + messageQuery: "", + start: "", + end: "", + pageSize: DEFAULT_PAGE_SIZE, + pageLimit: 1, + titlePrefix: "", + maxAttempts: 3, + retryDelayMs: 800, + incremental: true, + resumeSource: false, + dryRun: false, + authSession: process.env.SALES_WORKBENCH_AUTH_SESSION || "", + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const next = () => { + i += 1; + if (i >= argv.length) throw new Error(`Missing value for ${arg}`); + return argv[i]; + }; + + if (arg === "--help" || arg === "-h") args.help = true; + else if (arg === "--api-url") args.apiUrl = next(); + else if (arg === "--auth-session") args.authSession = next(); + else if (arg === "--company-id") args.companyId = next(); + else if (arg === "--doc") args.docs.push(next()); + else if (arg === "--p2p-user") args.p2pUser = next(); + else if (arg === "--chat-id") args.chatId = next(); + else if (arg === "--message-query") args.messageQuery = next(); + else if (arg === "--start") args.start = next(); + else if (arg === "--end") args.end = next(); + else if (arg === "--page-size") args.pageSize = Number(next()); + else if (arg === "--page-limit") args.pageLimit = Number(next()); + else if (arg === "--title-prefix") args.titlePrefix = next(); + else if (arg === "--max-attempts") args.maxAttempts = Number(next()); + else if (arg === "--retry-delay-ms") args.retryDelayMs = Number(next()); + else if (arg === "--no-incremental") args.incremental = false; + else if (arg === "--resume-source") args.resumeSource = true; + else if (arg === "--dry-run") args.dryRun = true; + else throw new Error(`Unknown argument: ${arg}`); + } + + if (args.help) return args; + if (!args.companyId) throw new Error("--company-id is required."); + if (!args.docs.length && !args.p2pUser && !args.chatId && !args.messageQuery) { + throw new Error("At least one source is required: --doc, --p2p-user, --chat-id, or --message-query."); + } + if (!Number.isFinite(args.pageSize) || args.pageSize < 1 || args.pageSize > 50) { + throw new Error("--page-size must be a number between 1 and 50."); + } + if (!Number.isFinite(args.pageLimit) || args.pageLimit < 1 || args.pageLimit > 40) { + throw new Error("--page-limit must be a number between 1 and 40."); + } + if (!Number.isFinite(args.maxAttempts) || args.maxAttempts < 1 || args.maxAttempts > 8) { + throw new Error("--max-attempts must be a number between 1 and 8."); + } + if (!Number.isFinite(args.retryDelayMs) || args.retryDelayMs < 0 || args.retryDelayMs > 30000) { + throw new Error("--retry-delay-ms must be a number between 0 and 30000."); + } + return args; +} + +function readAuthSession(filePath) { + if (!filePath) return null; + try { + const session = JSON.parse(fs.readFileSync(filePath, "utf8")); + return session?.access_token ? session : null; + } catch (error) { + if (error.code === "ENOENT") return null; + throw new Error(`Unable to read auth session: ${error.message}`); + } +} + +function writeAuthSession(filePath, session, apiUrl) { + if (!filePath) return; + fs.mkdirSync(path.dirname(path.resolve(filePath)), { recursive: true, mode: 0o700 }); + const issuedAt = Date.now(); + const current = readAuthSession(filePath) || {}; + const value = { + ...current, + api_url: apiUrl.replace(/\/$/, ""), + token_type: "bearer", + access_token: session.access_token, + refresh_token: session.refresh_token, + expires_in: Number(session.expires_in) || 3600, + issued_at: new Date(issuedAt).toISOString(), + expires_at: new Date(issuedAt + (Number(session.expires_in) || 3600) * 1000).toISOString(), + user: session.user || current.user || null, + }; + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + fs.chmodSync(filePath, 0o600); +} + +async function refreshAuthSession(options, current) { + if (!current?.refresh_token) return null; + const response = await fetch(`${options.apiUrl.replace(/\/$/, "")}/api/auth/cli-refresh`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: current.refresh_token }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) return null; + const session = payload.data || payload; + if (!session.access_token || !session.refresh_token) return null; + writeAuthSession(options.authSession, session, options.apiUrl); + return session; +} + +async function backendFetch(url, init, options, allowRefresh = true) { + const session = readAuthSession(options.authSession); + const headers = new Headers(init?.headers || {}); + if (session?.access_token) headers.set("Authorization", `Bearer ${session.access_token}`); + let response = await fetch(url, { ...init, headers }); + if (response.status !== 401 || !allowRefresh || !session?.refresh_token) return response; + const refreshed = await refreshAuthSession(options, session); + if (!refreshed?.access_token) return response; + const retryHeaders = new Headers(init?.headers || {}); + retryHeaders.set("Authorization", `Bearer ${refreshed.access_token}`); + response = await fetch(url, { ...init, headers: retryHeaders }); + return response; +} + +function retryable(error) { + const message = String(error?.message || error || ""); + return /timeout|timed out|network|fetch failed|temporar|connection reset|econn/i.test(message) + || /\b429\b|\b5\d\d\b/.test(message); +} + +async function withRetry(operation, options) { + let lastError; + let attempts = 0; + for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) { + attempts = attempt; + try { + return { value: await operation(), attempts: attempt }; + } catch (error) { + lastError = error; + if (attempt >= options.maxAttempts || !retryable(error)) break; + await delay(options.retryDelayMs * (2 ** (attempt - 1))); + } + } + const failure = lastError instanceof Error ? lastError : new Error(String(lastError)); + failure.attempts = attempts; + throw failure; +} + +async function runLark(args) { + const { stdout, stderr } = await execFileAsync("lark-cli", args, { + maxBuffer: 20 * 1024 * 1024, + }); + const text = stdout.trim(); + try { + return JSON.parse(text); + } catch { + const start = text.indexOf("{"); + const end = text.lastIndexOf("}"); + if (start >= 0 && end > start) return JSON.parse(text.slice(start, end + 1)); + throw new Error(`lark-cli returned non-JSON output: ${stderr || stdout}`); + } +} + +function textOf(value) { + if (value == null) return ""; + if (typeof value === "string") return value; + return JSON.stringify(value); +} + +function compact(value, max = 240) { + const text = textOf(value).replace(/\s+/g, " ").trim(); + if (text.length <= max) return text; + return `${text.slice(0, max - 1)}...`; +} + +function extractTitleFromDoc(content, fallback) { + const text = textOf(content); + const xmlTitle = text.match(/]*>(.*?)<\/title>/i)?.[1]; + if (xmlTitle) return compact(xmlTitle, 80); + const mdTitle = text.split(/\r?\n/).find((line) => /^#\s+/.test(line))?.replace(/^#\s+/, ""); + return mdTitle ? compact(mdTitle, 80) : fallback; +} + +function docExternalId(doc) { + return String(doc || "").match(/\/(?:wiki|docx)\/([^/?#]+)/i)?.[1] || String(doc || "").trim(); +} + +function extractDocUrl(doc) { + if (/^https?:\/\//.test(doc)) return doc; + return ""; +} + +function syncStateUrl(source, options) { + const url = new URL(`${options.apiUrl}/api/target-enterprises/${encodeURIComponent(options.companyId)}/materials/sync-state`); + url.searchParams.set("source_type", source.type); + url.searchParams.set("external_id", source.external_id); + url.searchParams.set("checkpoint_key", source.checkpoint_key || "latest"); + url.searchParams.set("display_name", source.display_name || source.external_id); + return url; +} + +async function getSyncState(source, options) { + if (!options.incremental) return null; + if (typeof options.syncStateLoader === "function") { + const state = await options.syncStateLoader(source); + if (state?.source?.status === "paused" && !options.resumeSource) { + throw new Error(`Sync source is paused: ${state.source_id}. Resume the source before importing.`); + } + return state; + } + const response = await backendFetch(syncStateUrl(source, options), {}, options); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(`Backend sync-state failed (${response.status}): ${JSON.stringify(payload)}`); + const state = payload.data || payload; + if (state.source?.status === "paused" && !options.resumeSource) { + throw new Error(`Sync source is paused: ${state.source_id}. Use --resume-source to continue.`); + } + return state; +} + +function checkpointStart(options, state) { + if (options.start) return options.start; + const value = String(state?.checkpoint?.checkpoint_value || "").trim(); + return /^\d{4}-\d{2}-\d{2}T/.test(value) ? value : ""; +} + +async function fetchDocMaterial(doc, options) { + const source = { + type: "feishu_doc", + external_id: docExternalId(doc), + display_name: `飞书云文档:${compact(docExternalId(doc), 60)}`, + checkpoint_key: "revision_id", + }; + await getSyncState(source, options); + const result = await runLark([ + "docs", "+fetch", "--api-version", "v2", "--as", "user", "--doc", doc, + "--doc-format", "markdown", "--format", "json", + ]); + if (!result.ok) throw new Error(`docs +fetch failed: ${JSON.stringify(result.error || result)}`); + + const document = result.data?.document || result.document || {}; + const content = document.content || result.data?.content || ""; + const title = extractTitleFromDoc(content, `飞书云文档:${compact(source.external_id, 40)}`); + source.display_name = title; + source.checkpoint_value = String(document.revision_id ?? result.data?.revision_id ?? ""); + source.version = source.checkpoint_value; + source.url = extractDocUrl(doc); + source.config = { + document_id: document.document_id || "", + revision_id: document.revision_id ?? null, + format: "markdown", + }; + return { + title: `${options.titlePrefix || ""}飞书云文档:${title}`, + source, + source_type: "feishu_doc", + source_url: source.url, + sync_mode: "full", + raw_text: content, + resume_source: options.resumeSource, + }; +} + +function normalizeUserId(value) { + return /^ou_[a-zA-Z0-9]+$/.test(value) ? value : ""; +} + +async function resolveUser(query) { + const direct = normalizeUserId(query); + if (direct) return { open_id: direct, localized_name: query, p2p_chat_id: "" }; + const result = await runLark([ + "contact", "+search-user", "--query", query, "--has-chatted", "--as", "user", "--format", "json", + ]); + const users = result.data?.users || result.users || []; + if (!users.length) throw new Error(`No Feishu user found for: ${query}`); + return users[0]; +} + +function senderName(message, targetUser) { + const sender = message.sender || {}; + if (sender.name) return sender.name; + if (targetUser?.open_id && sender.id === targetUser.open_id) return targetUser.localized_name || "对方"; + return "当前用户"; +} + +function messageItem(message, targetUser) { + return { + id: message.message_id || "", + occurred_at: message.create_time || null, + sender: senderName(message, targetUser), + content: textOf(message.content), + source_url: message.message_app_link || "", + deleted: Boolean(message.deleted), + }; +} + +function messageTimestamp(message) { + const raw = String(message?.create_time || "").trim(); + if (/^\d+$/.test(raw)) { + const value = Number(raw); + return raw.length <= 10 ? value * 1000 : value; + } + const parsed = Date.parse(raw); + return Number.isFinite(parsed) ? parsed : 0; +} + +async function listChatMessages({ chatId, userId, start, end, pageSize, pageLimit }) { + const messages = []; + let pageToken = ""; + for (let page = 0; page < pageLimit; page += 1) { + const args = [ + "im", "+chat-messages-list", chatId ? "--chat-id" : "--user-id", chatId || userId, + "--as", "user", "--order", "asc", "--page-size", String(pageSize), "--format", "json", + ]; + if (start) args.push("--start", start); + if (end) args.push("--end", end); + if (pageToken) args.push("--page-token", pageToken); + const result = await runLark(args); + if (!result.ok) throw new Error(`im +chat-messages-list failed: ${JSON.stringify(result.error || result)}`); + const data = result.data || result; + messages.push(...(data.messages || [])); + if (!data.has_more || !data.page_token) break; + pageToken = data.page_token; + } + return messages; +} + +function messageMaterial({ title, source, messages, targetUser, options, sourceUrl = "" }) { + if (!messages.length) return { skipped: true, reason: "no_new_messages", source }; + const orderedMessages = [...messages].sort((left, right) => { + const byTime = messageTimestamp(left) - messageTimestamp(right); + if (byTime) return byTime; + return String(left.message_id || "").localeCompare(String(right.message_id || "")); + }); + const first = orderedMessages[0]; + const last = orderedMessages[orderedMessages.length - 1]; + source.checkpoint_value = last.create_time || ""; + source.version = last.message_id || last.create_time || ""; + source.url = sourceUrl || first.message_app_link || ""; + source.config = { message_count: messages.length }; + return { + title: `${options.titlePrefix || ""}${title}`, + source, + source_type: source.type, + source_url: source.url, + sync_mode: "incremental", + occurred_at: first.create_time || null, + summary: `通过飞书 CLI 读取 ${messages.length} 条消息,时间范围 ${first.create_time || "未知"} 至 ${last.create_time || "未知"}。`, + source_items: orderedMessages.map((message) => messageItem(message, targetUser)), + resume_source: options.resumeSource, + }; +} + +async function fetchP2PMaterial(query, options) { + const user = await resolveUser(query); + const source = { + type: "feishu_p2p", + external_id: user.p2p_chat_id || user.open_id, + display_name: `飞书单聊:${user.localized_name || query}`, + checkpoint_key: "last_message_time", + }; + const state = await getSyncState(source, options); + const messages = await listChatMessages({ + chatId: user.p2p_chat_id || "", + userId: user.open_id, + start: checkpointStart(options, state), + end: options.end, + pageSize: options.pageSize, + pageLimit: options.pageLimit, + }); + return messageMaterial({ + title: `飞书单聊:${user.localized_name || query}`, + source, + messages, + targetUser: user, + options, + }); +} + +async function fetchChatMaterial(chatId, options) { + const source = { + type: "feishu_chat", + external_id: chatId, + display_name: `飞书群聊:${chatId}`, + checkpoint_key: "last_message_time", + }; + const state = await getSyncState(source, options); + const messages = await listChatMessages({ + chatId, + userId: "", + start: checkpointStart(options, state), + end: options.end, + pageSize: options.pageSize, + pageLimit: options.pageLimit, + }); + return messageMaterial({ title: `飞书群聊:${chatId}`, source, messages, targetUser: null, options }); +} + +async function fetchMessageSearchMaterial(query, options) { + const source = { + type: "feishu_search", + external_id: query, + display_name: `飞书消息搜索:${query}`, + checkpoint_key: "last_message_time", + }; + const state = await getSyncState(source, options); + const args = [ + "im", "+messages-search", "--query", query, "--as", "user", + "--page-size", String(options.pageSize), "--page-limit", String(options.pageLimit), "--format", "json", + ]; + const start = checkpointStart(options, state); + if (start) args.push("--start", start); + if (options.end) args.push("--end", options.end); + const result = await runLark(args); + if (!result.ok) throw new Error(`im +messages-search failed: ${JSON.stringify(result.error || result)}`); + const data = result.data || result; + const messages = data.messages || data.items || []; + return messageMaterial({ title: `飞书消息搜索:${query}`, source, messages, targetUser: null, options }); +} + +async function importMaterial(material, options) { + if (options.dryRun) { + return { + action: "dry_run", + material: { title: material.title }, + source: material.source, + }; + } + if (typeof options.materialImporter === "function") { + return options.materialImporter(material); + } + const response = await backendFetch(`${options.apiUrl}/api/target-enterprises/${encodeURIComponent(options.companyId)}/materials/import`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(material), + }, options); + const text = await response.text(); + let payload; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + payload = { raw: text }; + } + if (!response.ok) throw new Error(`Backend import failed (${response.status}): ${JSON.stringify(payload)}`); + return payload.data || payload; +} + +function descriptors(options) { + return [ + ...options.docs.map((doc) => ({ type: "feishu_doc", label: doc, fetch: () => fetchDocMaterial(doc, options) })), + ...(options.p2pUser ? [{ type: "feishu_p2p", label: options.p2pUser, fetch: () => fetchP2PMaterial(options.p2pUser, options) }] : []), + ...(options.chatId ? [{ type: "feishu_chat", label: options.chatId, fetch: () => fetchChatMaterial(options.chatId, options) }] : []), + ...(options.messageQuery ? [{ type: "feishu_search", label: options.messageQuery, fetch: () => fetchMessageSearchMaterial(options.messageQuery, options) }] : []), + ]; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(usage().trim()); + return; + } + + const result = await runFeishuImport(options); + console.log(JSON.stringify(result, null, 2)); + if (!result.ok) process.exitCode = 1; +} + +async function runFeishuImport(options) { + const imports = []; + for (const descriptor of descriptors(options)) { + const startedAt = Date.now(); + try { + const fetched = await withRetry(descriptor.fetch, options); + if (fetched.value.skipped) { + imports.push({ + source_type: descriptor.type, + source: descriptor.label, + action: "unchanged", + status: "skipped", + reason: fetched.value.reason, + fetch_attempts: fetched.attempts, + duration_ms: Date.now() - startedAt, + }); + continue; + } + const imported = await withRetry(() => importMaterial(fetched.value, options), options); + imports.push({ + source_type: descriptor.type, + source: descriptor.label, + title: fetched.value.title, + action: imported.value.action || "imported", + status: imported.value.openviking_record?.status || imported.value.material?.openviking_status || "ready", + imported_material_id: imported.value.material?.id || null, + source_id: imported.value.source?.id || fetched.value.source?.external_id || null, + content_hash: imported.value.material?.content_hash || null, + provider_run_id: imported.value.provider_run_id || null, + openviking_ref: imported.value.openviking_record?.raw_ref || null, + fetch_attempts: fetched.attempts, + import_attempts: imported.attempts, + duration_ms: Date.now() - startedAt, + }); + } catch (error) { + imports.push({ + source_type: descriptor.type, + source: descriptor.label, + action: "failed", + status: "failed", + attempts: error.attempts || 1, + duration_ms: Date.now() - startedAt, + error: { message: compact(error.message, 500) }, + }); + } + } + + const failed = imports.filter((item) => item.status === "failed").length; + return { + ok: failed === 0, + company_id: options.companyId, + source_count: imports.length, + summary: { + created: imports.filter((item) => item.action === "created").length, + updated: imports.filter((item) => item.action === "updated").length, + unchanged: imports.filter((item) => item.action === "unchanged").length, + failed, + }, + imports, + }; +} + +export { + backendFetch, + checkpointStart, + docExternalId, + extractDocUrl, + messageMaterial, + parseArgs, + readAuthSession, + retryable, + runFeishuImport, + withRetry, +}; + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(JSON.stringify({ + ok: false, + error: { message: error.message }, + }, null, 2)); + process.exit(1); + }); +} diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/migrate-supabase.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/migrate-supabase.mjs new file mode 100644 index 00000000..785d959a --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/migrate-supabase.mjs @@ -0,0 +1,52 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; + +const rootDir = resolve(fileURLToPath(new URL("../..", import.meta.url))); +const migrationsDir = resolve(rootDir, "supabase/migrations"); +const shouldApply = process.argv.includes("--apply"); +const baseEnv = createEnvReader(); +const env = { + ...baseEnv, + value(name, fallback = "") { + if (name === "SUPABASE_READ_ONLY") return "false"; + return baseEnv.value(name, fallback); + }, +}; +const provider = createSupabaseProvider({ env }); + +if (!provider.isConfigured()) { + throw new Error("Supabase CLI persistence is not configured. Check AK/SK, SUPABASE_WORKSPACE_ID and SUPABASE_CLI_BIN."); +} + +const migrationFiles = readdirSync(migrationsDir) + .filter((name) => /^\d+_.+\.sql$/.test(name)) + .sort(); + +const tableCheck = provider.executeSqlSync("select to_regclass('public.schema_migrations') as migration_table;"); +if (!tableCheck.ok) throw new Error(tableCheck.error?.message || "Unable to inspect Supabase migrations."); + +let applied = new Set(); +if (tableCheck.rows?.[0]?.migration_table) { + const result = provider.executeSqlSync("select version from public.schema_migrations order by version;"); + if (!result.ok) throw new Error(result.error?.message || "Unable to read Supabase migrations."); + applied = new Set((result.rows || []).map((row) => String(row.version))); +} + +const pending = migrationFiles.filter((name) => !applied.has(name.split("_")[0])); +if (!shouldApply) { + console.log(JSON.stringify({ ok: pending.length === 0, applied: [...applied], pending }, null, 2)); + if (pending.length) process.exitCode = 1; +} else { + for (const name of pending) { + const sql = readFileSync(resolve(migrationsDir, name), "utf8"); + const result = provider.executeSqlSync(sql); + if (!result.ok) throw new Error(`${name}: ${result.error?.message || "migration failed"}`); + console.log(`applied ${name}`); + } + const verify = provider.executeSqlSync("select version, description, applied_at from public.schema_migrations order by version;"); + if (!verify.ok) throw new Error(verify.error?.message || "Unable to verify Supabase migrations."); + console.log(JSON.stringify({ ok: true, migrations: verify.rows || [] }, null, 2)); +} diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/preflight-real.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/preflight-real.mjs new file mode 100644 index 00000000..2b928cac --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/preflight-real.mjs @@ -0,0 +1,187 @@ +import { createDataProProvider } from "../src/providers/dataProProvider.js"; +import { createModelProvider } from "../src/providers/modelProvider.js"; +import { createOpenVikingProvider } from "../src/providers/openVikingProvider.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; +import { createWebSearchProvider } from "../src/providers/webSearchProvider.js"; + +const liveProbeCompany = process.env.LIVE_PROBE_COMPANY || "北京火山引擎科技有限公司"; + +function safeError(result) { + if (!result?.error) return null; + return { + code: result.error.code || "error", + message: String(result.error.message || "").slice(0, 300), + http_status: result.http_status || null, + }; +} + +function status(ok, details = {}) { + return { + ok: Boolean(ok), + ...details, + }; +} + +async function checkModel() { + const provider = createModelProvider(); + if (!provider.isConfigured()) { + return status(false, { configured: false, error: { code: "missing_config", message: "MODEL_* 未配置完整。" } }); + } + const result = await provider.callJson({ + operation: "afp_preflight_model", + maxTokens: 60, + system: "你是连通性探针。只输出 JSON,不要输出 Markdown。", + payload: { + task: "请返回 {\"ok\":true,\"message\":\"model ready\"}", + output_schema: { ok: true, message: "model ready" }, + }, + }); + return status(result.ok, { + configured: true, + model: provider.modelName, + usage: result.usage || null, + request_id: result.request_id || null, + error: safeError(result), + }); +} + +async function checkDataPro() { + const provider = createDataProProvider(); + if (!provider.isConfigured()) { + return status(false, { configured: false, error: { code: "missing_config", message: "DATAPRO_* 未配置完整。" } }); + } + const result = await provider.callTool(`${liveProbeCompany} 企业工商信息`); + return status(result.ok, { + configured: true, + request_id: result.request_id || null, + summary: result.summary ? String(result.summary).slice(0, 220) : "", + error: safeError(result), + }); +} + +async function checkWebSearch() { + const provider = createWebSearchProvider(); + if (!provider.isConfigured()) { + return status(false, { configured: false, error: { code: "missing_config", message: "AGENT_PLAN_API_KEY 未配置。" } }); + } + const result = await provider.search({ + query: `${liveProbeCompany} 最新动态`, + count: 1, + need_summary: false, + }); + return status(result.ok, { + configured: true, + request_id: result.request_id || null, + result_count: result.result_count || 0, + first_result: result.results?.[0] + ? { + title: result.results[0].title, + url: result.results[0].url, + } + : null, + error: safeError(result), + }); +} + +async function checkOpenViking() { + const provider = createOpenVikingProvider(); + if (!provider.isConfigured()) { + return status(false, { configured: false, error: { code: "missing_config", message: "OpenViking 未配置。" } }); + } + const stamp = `afp-preflight-${Date.now()}`; + const health = await provider.health(); + if (!health.ok) { + return status(false, { + configured: true, + stage: "health", + error: safeError(health), + }); + } + const write = await provider.storeMemory([ + { + role: "user", + content: `AFP 预检测试记忆 ${stamp}。用于确认 OpenViking 当前库可以写入和检索,可在测试后清理。`, + }, + ]); + if (!write.ok) { + return status(false, { + configured: true, + stage: "write", + health: health.result || null, + error: safeError(write), + }); + } + const find = await provider.findMemories(stamp, { limit: 3 }); + const findPreview = JSON.stringify(find.result || null); + return status(find.ok, { + configured: true, + stage: find.ok ? "write_and_find" : "find", + stamp, + health: health.result || null, + write_ref: write.raw_ref || null, + find_ref: find.raw_ref || null, + find_exact_match: findPreview.includes(stamp), + find_result_preview: findPreview.slice(0, 500), + error: safeError(find), + }); +} + +async function checkSupabase() { + const provider = createSupabaseProvider(); + if (!provider.isConfigured()) { + return status(false, { + configured: false, + workspace_id: provider.workspaceId || "", + error: { code: "missing_config", message: "Supabase 工作区、AK/SK 或 skill 目录未配置完整。" }, + }); + } + const stamp = `afp-preflight-${Date.now()}`; + const result = await provider.executeSql(` + create temporary table afp_preflight_probe ( + id text primary key, + note text + ); + insert into afp_preflight_probe (id, note) values ('${stamp}', 'temporary write/read probe'); + select id, note from afp_preflight_probe where id = '${stamp}'; + `); + return status(result.ok, { + configured: true, + workspace_id: provider.workspaceId, + rows: result.rows || null, + error: safeError(result), + }); +} + +const checks = [ + ["model", checkModel], + ["datapro", checkDataPro], + ["web_search", checkWebSearch], + ["openviking", checkOpenViking], + ["supabase", checkSupabase], +]; + +const startedAt = new Date().toISOString(); +const results = {}; +for (const [name, fn] of checks) { + try { + results[name] = await fn(); + } catch (error) { + results[name] = status(false, { + error: { + code: "exception", + message: String(error?.message || error).slice(0, 300), + }, + }); + } +} + +const failed = Object.entries(results).filter(([, result]) => !result.ok).map(([name]) => name); +console.log(JSON.stringify({ + started_at: startedAt, + finished_at: new Date().toISOString(), + ok: failed.length === 0, + failed, + results, +}, null, 2)); + +if (failed.length) process.exitCode = 1; diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/real-chain-check.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/real-chain-check.mjs new file mode 100644 index 00000000..59c71285 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/real-chain-check.mjs @@ -0,0 +1,7 @@ +process.stderr.write([ + "此旧脚本已停用:它曾使用内存仓库和 Mock Provider,不能作为真实链路验收证据。", + "最小只读 Provider 诊断请运行:npm run doctor:live", + "完整业务链路验收请运行:npm run verify:business -- --help", + "", +].join("\n")); +process.exitCode = 1; diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/restore-supabase.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/restore-supabase.mjs new file mode 100644 index 00000000..7171a810 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/restore-supabase.mjs @@ -0,0 +1,148 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + RESTORE_ORDER, + WORKSPACE_TABLE_SPECS, + prepareRowsForRestore, + tableSpec, + validateBackupPackage, +} from "../src/backup/supabaseBackup.js"; +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseDataProvider } from "../src/providers/supabaseDataProvider.js"; + +function option(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : ""; +} + +function hasFlag(name) { + return process.argv.includes(name); +} + +async function readAll(provider, table, options = {}) { + const rows = []; + const pageSize = 500; + let offset = 0; + while (true) { + const page = await provider.select(table, { + select: options.select || "*", + filters: options.filters || {}, + order: options.order, + limit: pageSize, + offset, + }); + if (!Array.isArray(page)) throw new Error(`Data API returned a non-array response for ${table}.`); + rows.push(...page); + if (page.length < pageSize) break; + offset += pageSize; + } + return rows; +} + +async function writeBatches(provider, table, rows, onConflict) { + const batchSize = 200; + for (let index = 0; index < rows.length; index += batchSize) { + await provider.upsert(table, rows.slice(index, index + batchSize), { onConflict, returning: false }); + } +} + +const backupDir = resolve(option("--backup-dir") || ""); +if (!option("--backup-dir")) throw new Error("--backup-dir is required."); +const manifest = JSON.parse(readFileSync(resolve(backupDir, "manifest.json"), "utf8")); +const data = JSON.parse(readFileSync(resolve(backupDir, "data.json"), "utf8")); +validateBackupPackage(backupDir, manifest, data); + +if (!hasFlag("--apply")) { + console.log(JSON.stringify({ + ok: true, + mode: "validate-only", + backup_id: manifest.backup_id, + source: manifest.source, + required_migrations: manifest.required_migrations, + row_counts: manifest.row_counts, + checksums_verified: true, + apply_command: "npm run db:restore -- --backup-dir --target-workspace-id --target-branch-id --acknowledge-target --acknowledge-target-branch --target-app-workspace-id --apply", + }, null, 2)); + process.exit(0); +} + +const env = createEnvReader(); +const provider = createSupabaseDataProvider({ env }); +const targetCloudWorkspaceId = env.value("SUPABASE_WORKSPACE_ID"); +const targetBranchId = env.value("SUPABASE_BRANCH_ID"); +const requestedTarget = option("--target-workspace-id"); +const requestedTargetBranch = option("--target-branch-id"); +const acknowledgedTarget = option("--acknowledge-target"); +const acknowledgedTargetBranch = option("--acknowledge-target-branch"); +const targetAppWorkspaceId = option("--target-app-workspace-id") || env.value("APP_WORKSPACE_ID"); + +if (!provider.isConfigured() || !provider.isRunEnabled()) { + throw new Error("Configured and enabled target Supabase Data API access is required for restore."); +} +if (!targetCloudWorkspaceId || !targetBranchId || !targetAppWorkspaceId) { + throw new Error("Target SUPABASE_WORKSPACE_ID, SUPABASE_BRANCH_ID, and APP_WORKSPACE_ID are required."); +} +if (requestedTarget !== targetCloudWorkspaceId || acknowledgedTarget !== targetCloudWorkspaceId) { + throw new Error("Target confirmation failed. Both target arguments must exactly match configured SUPABASE_WORKSPACE_ID."); +} +if (requestedTargetBranch !== targetBranchId || acknowledgedTargetBranch !== targetBranchId) { + throw new Error("Target branch confirmation failed. Both branch arguments must exactly match configured SUPABASE_BRANCH_ID."); +} +if (targetCloudWorkspaceId === manifest.source.cloud_workspace_id && targetBranchId === manifest.source.branch_id) { + throw new Error("Restore to the source cloud workspace and branch is blocked. Configure a separate empty branch or workspace."); +} + +const appliedMigrations = await readAll(provider, "schema_migrations", { order: "version.asc" }); +const appliedVersions = new Set(appliedMigrations.map((entry) => entry.version)); +const missingMigrations = manifest.required_migrations.filter((version) => !appliedVersions.has(version)); +if (missingMigrations.length) { + throw new Error(`Target schema is missing migrations: ${missingMigrations.join(", ")}. Run db:migrate first.`); +} + +const targetWorkspaces = await provider.select("app_workspaces", { select: "id", limit: 2 }); +if (targetWorkspaces.some((row) => row.id !== targetAppWorkspaceId)) { + throw new Error("Target contains another application workspace. Restore requires a dedicated empty cloud workspace or branch."); +} +for (const spec of WORKSPACE_TABLE_SPECS) { + const existing = await provider.select(spec.table, { select: spec.table === "app_workspace_members" ? "user_id" : "id", limit: 1 }); + if (existing.length) throw new Error(`Target table ${spec.table} is not empty. Restore was not started.`); +} + +if (!targetWorkspaces.length) { + const workspaceRows = prepareRowsForRestore("app_workspaces", data.tables.app_workspaces || [], targetAppWorkspaceId); + if (workspaceRows.length !== 1) throw new Error("Backup does not contain exactly one application workspace."); + workspaceRows[0].slug = env.value("APP_WORKSPACE_SLUG", workspaceRows[0].slug); + workspaceRows[0].name = env.value("APP_WORKSPACE_NAME", workspaceRows[0].name); + await provider.insert("app_workspaces", workspaceRows, { returning: false }); +} + +const restoredCounts = { app_workspaces: 1, app_users: 0, app_workspace_members: 0 }; +for (const table of RESTORE_ORDER) { + const rows = prepareRowsForRestore(table, data.tables?.[table] || [], targetAppWorkspaceId); + if (rows.length) await writeBatches(provider, table, rows, tableSpec(table)?.onConflict || "id"); + restoredCounts[table] = rows.length; +} + +for (const table of RESTORE_ORDER) { + const spec = tableSpec(table); + const rows = await readAll(provider, table, { + filters: { workspace_id: `eq.${targetAppWorkspaceId}` }, + order: spec?.order, + }); + if (rows.length !== restoredCounts[table]) { + throw new Error(`Restore verification failed for ${table}: expected ${restoredCounts[table]}, got ${rows.length}.`); + } +} + +console.log(JSON.stringify({ + ok: true, + mode: "applied", + backup_id: manifest.backup_id, + target_cloud_workspace_id: targetCloudWorkspaceId, + target_branch_id: targetBranchId, + target_app_workspace_id: targetAppWorkspaceId, + restored_counts: restoredCounts, + checksums_verified: true, + auth_bindings_skipped: true, + provider_secrets_require_reconfiguration: true, +}, null, 2)); diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/smoke-async-job-queue.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/smoke-async-job-queue.mjs new file mode 100644 index 00000000..0d8cd7d9 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/smoke-async-job-queue.mjs @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; + +const rootDir = resolve(fileURLToPath(new URL("../..", import.meta.url))); +const baseEnv = createEnvReader(); +const env = { + ...baseEnv, + value(name, fallback = "") { + if (name === "SUPABASE_READ_ONLY") return "false"; + return baseEnv.value(name, fallback); + }, +}; +const provider = createSupabaseProvider({ env }); + +if (!provider.isConfigured()) { + throw new Error("Supabase control-plane SQL is not configured."); +} + +const sql = readFileSync( + resolve(rootDir, "supabase/tests/202607230003_async_job_queue_smoke.sql"), + "utf8", +); +const result = provider.executeSqlSync(sql); +if (!result.ok) { + throw new Error(result.error?.message || "Asynchronous job queue smoke test failed."); +} + +process.stdout.write(`${JSON.stringify({ + ok: true, + check: "async_job_queue", + transaction: "rolled_back", + provider_calls: 0, +}, null, 2)}\n`); diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/smoke-paid-workflow-guard.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/smoke-paid-workflow-guard.mjs new file mode 100644 index 00000000..92c4eeef --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/smoke-paid-workflow-guard.mjs @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; + +const rootDir = resolve(fileURLToPath(new URL("../..", import.meta.url))); +const baseEnv = createEnvReader(); +const env = { + ...baseEnv, + value(name, fallback = "") { + if (name === "SUPABASE_READ_ONLY") return "false"; + return baseEnv.value(name, fallback); + }, +}; +const provider = createSupabaseProvider({ env }); + +if (!provider.isConfigured()) { + throw new Error("Supabase control-plane SQL is not configured."); +} + +const sql = readFileSync( + resolve(rootDir, "supabase/tests/202607230002_paid_workflow_guard_smoke.sql"), + "utf8", +); +const result = provider.executeSqlSync(sql); +if (!result.ok) { + throw new Error(result.error?.message || "Paid workflow guard smoke test failed."); +} + +process.stdout.write(`${JSON.stringify({ + ok: true, + check: "paid_workflow_guard", + transaction: "rolled_back", + provider_calls: 0, +}, null, 2)}\n`); diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/smoke-stage2-api.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/smoke-stage2-api.mjs new file mode 100644 index 00000000..b1432e2c --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/smoke-stage2-api.mjs @@ -0,0 +1,134 @@ +import { randomUUID } from "node:crypto"; +import { createApp } from "../src/app.js"; +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; + +process.env.REPOSITORY_MODE = "supabase"; + +const env = createEnvReader(); +const provider = createSupabaseProvider({ env }); +const workspaceId = env.value("APP_WORKSPACE_ID"); +const suffix = `${Date.now()}_${randomUUID().slice(0, 8)}`; +const goalName = `Stage 2 API 持久化测试 ${suffix}`; +let goalId = ""; +let firstServer = null; +let secondServer = null; +let primaryError = null; +let report = null; + +function sqlString(value) { + if (value === null || value === undefined || value === "") return "null"; + return `'${String(value).replace(/'/g, "''")}'`; +} + +function assertOk(condition, message) { + if (!condition) throw new Error(`Stage 2 API smoke assertion failed: ${message}`); +} + +function executeSql(sql) { + const result = provider.executeSqlSync(sql); + if (!result.ok) throw new Error(result.error?.message || "Supabase SQL failed."); + return result.rows || []; +} + +function listen(server) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve(server.address().port)); + }); +} + +function close(server) { + if (!server?.listening) return Promise.resolve(); + return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); +} + +async function request(baseUrl, method, path, body) { + const response = await fetch(`${baseUrl}${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(`${method} ${path} returned ${response.status}: ${JSON.stringify(payload)}`); + } + return payload; +} + +if (!provider.isConfigured() || !provider.isRunEnabled() || provider.readOnly) { + throw new Error("Writable Supabase configuration is required for the Stage 2 API smoke test."); +} +if (!workspaceId) throw new Error("APP_WORKSPACE_ID is required for the Stage 2 API smoke test."); + +try { + firstServer = createApp(); + const firstPort = await listen(firstServer); + const firstBaseUrl = `http://127.0.0.1:${firstPort}`; + const health = await request(firstBaseUrl, "GET", "/api/health"); + assertOk(health.data?.runtime_ready === true, "first app instance is not runtime-ready"); + + const created = await request(firstBaseUrl, "POST", "/api/sales-goals", { + name: goalName, + description: "仅用于 Stage 2 HTTP 持久化测试,结束后自动删除。", + keywords: ["stage2", "api-smoke"], + }); + goalId = created.data?.id || ""; + assertOk(goalId, "POST /api/sales-goals did not return an id"); + + const firstRead = await request(firstBaseUrl, "GET", "/api/sales-goals"); + assertOk(firstRead.data?.some((goal) => goal.id === goalId), "first app instance cannot read the created goal"); + await close(firstServer); + firstServer = null; + + secondServer = createApp(); + const secondPort = await listen(secondServer); + const secondBaseUrl = `http://127.0.0.1:${secondPort}`; + const secondRead = await request(secondBaseUrl, "GET", "/api/sales-goals"); + assertOk(secondRead.data?.some((goal) => goal.id === goalId), "fresh app instance did not reload the goal from Supabase"); + + const databaseRows = executeSql(` + select id, name + from public.sales_goals + where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(goalId)}; + `); + assertOk(databaseRows.length === 1, "created API record is missing from Supabase"); + + report = { + ok: true, + test_run: suffix, + verified: { + fail_closed_path: true, + http_create_and_read: true, + fresh_app_instance_reload: true, + direct_database_record: true, + }, + }; +} catch (error) { + primaryError = error; + throw error; +} finally { + await close(firstServer); + await close(secondServer); + if (goalId) { + const cleanup = provider.executeSqlSync(` + delete from public.sales_goals + where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(goalId)}; + `); + if (!cleanup.ok) { + const cleanupError = new Error(`Stage 2 API smoke cleanup failed: ${cleanup.error?.message || "unknown error"}`); + if (!primaryError) throw cleanupError; + console.error(cleanupError.message); + } else { + const remaining = executeSql(` + select count(*)::int as count + from public.sales_goals + where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(goalId)}; + `); + assertOk(Number(remaining[0]?.count || 0) === 0, "temporary API record was not cleaned up"); + if (report) report.cleanup_verified = true; + } + } +} + +console.log(JSON.stringify(report, null, 2)); diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/smoke-stage2-backup-package.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/smoke-stage2-backup-package.mjs new file mode 100644 index 00000000..155cf696 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/smoke-stage2-backup-package.mjs @@ -0,0 +1,271 @@ +import { randomUUID } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createEnvReader, loadLocalEnv } from "../src/config/runtimeEnv.js"; +import { createSupabaseDataProvider } from "../src/providers/supabaseDataProvider.js"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(scriptDir, "../.."); +const env = createEnvReader(); +const provider = createSupabaseDataProvider({ env }); +const workspaceId = env.value("APP_WORKSPACE_ID"); +const suffix = `${Date.now()}_${randomUUID().slice(0, 8)}`; +const prefix = `s2_backup_${suffix}`; +const now = new Date().toISOString(); +const outputDir = resolve(repositoryRoot, "backups/private/supabase", `${prefix}_package`); +let report = null; +let primaryError = null; + +const ids = { + providerConnection: `${prefix}_provider`, + goal: `${prefix}_goal`, + company: `${prefix}_company`, + job: `${prefix}_job`, + run: `${prefix}_run`, + step: `${prefix}_step`, + target: `${prefix}_target`, + search: `${prefix}_search`, + progress: `${prefix}_progress`, + dossier: `${prefix}_dossier`, + citation: `${prefix}_citation`, + material: `${prefix}_material`, + openviking: `${prefix}_openviking`, + syncSource: `${prefix}_sync_source`, + checkpoint: `${prefix}_checkpoint`, + audit: `${prefix}_audit`, +}; + +function assertOk(condition, message) { + if (!condition) throw new Error(`Stage 2 backup package assertion failed: ${message}`); +} + +async function insert(table, row) { + await provider.insert(table, row, { returning: false }); +} + +async function remove(table, id) { + await provider.delete(table, { workspace_id: `eq.${workspaceId}`, id: `eq.${id}` }, { returning: false }); +} + +if (!workspaceId || !provider.isConfigured() || !provider.isRunEnabled()) { + throw new Error("Configured and enabled Supabase Data API access is required."); +} + +try { + await insert("provider_connections", { + id: ids.providerConnection, + workspace_id: workspaceId, + provider: `backup-smoke-${suffix}`, + status: "configured", + secret_ref: "secret://synthetic-test-only", + config_json: { synthetic: true }, + }); + await insert("sales_goals", { + id: ids.goal, + workspace_id: workspaceId, + name: "Stage 2 backup restore smoke", + description: "Synthetic data removed from the source after backup.", + keywords: ["stage2", "backup"], + payload_json: { synthetic: true }, + }); + await insert("sales_companies", { + id: ids.company, + workspace_id: workspaceId, + name: `Synthetic Restore Company ${suffix}`, + initial: "S", + industry: "automated_test", + location: "test", + tags: ["stage2", "backup"], + payload_json: { synthetic: true }, + }); + await insert("jobs", { + id: ids.job, + workspace_id: workspaceId, + job_type: "backup_restore_smoke", + status: "succeeded", + attempt_count: 1, + max_attempts: 1, + started_at: now, + finished_at: now, + payload_json: { synthetic: true }, + }); + await insert("provider_runs", { + id: ids.run, + workspace_id: workspaceId, + job_id: ids.job, + operation: "backup_restore_smoke", + status: "succeeded", + app_mode: "production", + entity_type: "company", + entity_id: ids.company, + started_at: now, + finished_at: now, + duration_ms: 1, + payload_json: { synthetic: true }, + }); + await insert("provider_run_steps", { + id: ids.step, + workspace_id: workspaceId, + provider_run_id: ids.run, + sequence: 1, + provider: "supabase", + operation: "backup_restore_smoke", + status: "succeeded", + input_summary: "Synthetic input.", + output_summary: "Synthetic output.", + attempts: 1, + started_at: now, + finished_at: now, + latency_ms: 1, + }); + await insert("sales_target_enterprises", { + id: ids.target, + workspace_id: workspaceId, + goal_id: ids.goal, + company_id: ids.company, + status: "new", + payload_json: { synthetic: true }, + }); + await insert("sales_company_search_results", { + id: ids.search, + workspace_id: workspaceId, + goal_id: ids.goal, + company_id: ids.company, + query: "synthetic backup restore query", + reason: "Automated verification only.", + payload_json: { synthetic: true }, + }); + await insert("sales_progress_snapshots", { + id: ids.progress, + workspace_id: workspaceId, + company_id: ids.company, + label: "new", + summary: "Synthetic progress snapshot.", + evidence: "automated_test", + payload_json: { synthetic: true }, + }); + await insert("sales_dossier_records", { + id: ids.dossier, + workspace_id: workspaceId, + company_id: ids.company, + title: "Synthetic dossier", + summary: "Automated restore verification.", + memory_summary: "Synthetic only.", + status: "completed", + provider_run_id: ids.run, + payload_json: { synthetic: true }, + }); + await insert("sales_dossier_citations", { + id: ids.citation, + workspace_id: workspaceId, + dossier_id: ids.dossier, + citation_no: "1", + label: "Synthetic citation", + source_kind: "automated_test", + url: "", + payload_json: { synthetic: true }, + }); + await insert("sales_materials", { + id: ids.material, + workspace_id: workspaceId, + company_id: ids.company, + title: "Synthetic material", + source_type: "automated_test", + content_hash: `sha256:${suffix}`, + summary: "Synthetic only.", + payload_json: { synthetic: true }, + }); + await insert("sales_openviking_refs", { + id: ids.openviking, + workspace_id: workspaceId, + company_id: ids.company, + related_type: "material", + related_id: ids.material, + ref_kind: "resource", + uri: `viking://synthetic/${suffix}`, + summary: "Synthetic only.", + payload_json: { synthetic: true }, + }); + await insert("sync_sources", { + id: ids.syncSource, + workspace_id: workspaceId, + source_type: "automated_test", + external_id: suffix, + display_name: "Synthetic sync source", + status: "active", + config_json: { synthetic: true }, + }); + await insert("sync_checkpoints", { + id: ids.checkpoint, + workspace_id: workspaceId, + source_id: ids.syncSource, + checkpoint_key: "cursor", + checkpoint_value: "synthetic-cursor", + content_hash: `sha256:${suffix}`, + last_success_at: now, + }); + await insert("audit_events", { + id: ids.audit, + workspace_id: workspaceId, + action: "backup_restore_smoke", + entity_type: "company", + entity_id: ids.company, + after_json: { synthetic: true }, + }); + + const child = spawnSync(process.execPath, [resolve(scriptDir, "backup-supabase.mjs"), "--output-dir", outputDir], { + cwd: resolve(scriptDir, ".."), + encoding: "utf8", + env: { ...process.env, ...loadLocalEnv() }, + }); + if (child.status !== 0) throw new Error(child.stderr || child.stdout || "Backup child process failed."); + const backup = JSON.parse(child.stdout.trim()); + const expectedTables = [ + "provider_connections", "sales_goals", "sales_companies", "jobs", "provider_runs", + "provider_run_steps", "sales_target_enterprises", "sales_company_search_results", + "sales_progress_snapshots", "sales_dossier_records", "sales_dossier_citations", + "sales_materials", "sales_openviking_refs", "sync_sources", + "sync_checkpoints", "audit_events", + ]; + for (const table of expectedTables) { + assertOk(backup.row_counts?.[table] >= 1, `backup did not capture ${table}`); + } + report = { + ok: true, + test_run: suffix, + backup_id: backup.backup_id, + output_dir: backup.output_dir, + verified_nonempty_tables: expectedTables, + checksums_verified: backup.checksums_verified === true, + }; +} catch (error) { + primaryError = error; + throw error; +} finally { + const cleanupTasks = [ + () => remove("provider_connections", ids.providerConnection), + () => remove("audit_events", ids.audit), + () => remove("sales_goals", ids.goal), + () => remove("sales_companies", ids.company), + () => remove("jobs", ids.job), + () => remove("sync_sources", ids.syncSource), + ]; + for (const cleanup of cleanupTasks) { + try { + await cleanup(); + } catch (error) { + if (!primaryError) throw error; + console.error(`Cleanup warning: ${error.message}`); + } + } + const remaining = await provider.select("sales_companies", { + select: "id", + filters: { workspace_id: `eq.${workspaceId}`, id: `eq.${ids.company}` }, + limit: 1, + }); + assertOk(remaining.length === 0, "synthetic source data was not cleaned up"); + if (report) report.source_cleanup_verified = true; +} + +console.log(JSON.stringify(report, null, 2)); diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/smoke-stage2-data-api.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/smoke-stage2-data-api.mjs new file mode 100644 index 00000000..1e312cdc --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/smoke-stage2-data-api.mjs @@ -0,0 +1,166 @@ +import { randomUUID } from "node:crypto"; +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { ProviderRunStore } from "../src/observability/providerRunStore.js"; +import { createSupabaseDataProvider } from "../src/providers/supabaseDataProvider.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; +import { SupabaseDataRepository } from "../src/repositories/supabaseDataRepository.js"; + +const env = createEnvReader(); +const adminProvider = createSupabaseProvider({ env }); +const dataProvider = createSupabaseDataProvider({ env }); +const workspaceId = env.value("APP_WORKSPACE_ID"); +const suffix = `${Date.now()}_${randomUUID().slice(0, 8)}`; +const companyId = `s2_data_${suffix}_company`; +const dossierId = `s2_data_${suffix}_dossier`; +const rejectedDossierId = `s2_data_${suffix}_rejected`; +let runId = ""; +let primaryError = null; +let report = null; + +function sqlString(value) { + if (value === null || value === undefined || value === "") return "null"; + return `'${String(value).replace(/'/g, "''")}'`; +} + +function assertOk(condition, message) { + if (!condition) throw new Error(`Stage 2 Data API smoke assertion failed: ${message}`); +} + +function executeSql(sql) { + const result = adminProvider.executeSqlSync(sql); + if (!result.ok) throw new Error(result.error?.message || "Supabase SQL failed."); + return result.rows || []; +} + +if (!workspaceId) throw new Error("APP_WORKSPACE_ID is required."); +if (!dataProvider.isConfigured()) throw new Error("Supabase Data API configuration is required."); +if (!adminProvider.isConfigured() || !adminProvider.isRunEnabled() || adminProvider.readOnly) { + throw new Error("Writable Supabase admin configuration is required for cleanup verification."); +} + +const repository = new SupabaseDataRepository({ + env, + supabaseDataProvider: dataProvider, + workspaceId, +}); + +try { + const now = new Date().toISOString(); + const company = { + id: companyId, + name: `Stage 2 Data API Test ${suffix}`, + initial: "S", + industry: "automated_test", + location: "test", + tags: ["stage2", "data-api"], + progress: { + label: "新商机", + summary: "Data API transaction smoke test.", + evidence: "automated_test", + updated_at: now, + }, + dossier_ids: [], + material_ids: [], + qa_session_id: `sales-${companyId}`, + created_at: now, + updated_at: now, + }; + await repository.persistSalesCompany(company); + + const runStore = new ProviderRunStore({ repository, failOnPersistenceError: true }); + const run = await runStore.startRun({ + operation: "stage2_data_api_rpc_smoke", + app_mode: "production", + entity_type: "target_enterprise", + entity_id: companyId, + }); + runId = run.id; + const step = await runStore.startStep(run.id, { + provider: "supabase", + operation: "transaction_rpc", + input_summary: "Verify provider run RPC persistence.", + }); + await runStore.finishStep(run.id, step.id, { + ok: true, + output_summary: "Provider run RPC persisted.", + usage: { total_tokens: 0 }, + }); + await runStore.completeRun(run.id, { result_ref: `stage2-data-api:${suffix}` }); + + const dossier = { + id: dossierId, + company_id: companyId, + provider_run_id: run.id, + title: "Stage 2 Data API Transaction Test", + summary: "Temporary automated test record.", + memory_summary: "Removed after validation.", + body: [{ text: "Transactional dossier body.", citation_ids: ["1"] }], + citations: [{ id: "1", label: "Automated test citation", source_kind: "test", url: "" }], + created_at: now, + }; + await repository.persistSalesDossier(dossier); + + const persistedRun = await repository.getProviderRun(run.id); + const state = await repository.getSalesState(); + assertOk(persistedRun?.status === "succeeded", "provider run RPC did not persist the terminal state"); + assertOk(persistedRun?.steps?.length === 1, "provider run RPC did not persist its step"); + assertOk(state.dossiers[dossierId]?.citations?.length === 1, "dossier RPC did not persist its citation"); + assertOk(state.dossiers[dossierId]?.provider_run_id === run.id, "dossier RPC did not retain provider_run_id"); + + let rejected = false; + try { + await repository.persistSalesDossier({ + ...dossier, + id: rejectedDossierId, + company_id: `missing-${suffix}`, + }); + } catch (error) { + rejected = /company was not found/i.test(error.message); + } + assertOk(rejected, "invalid dossier transaction was not rejected"); + const rejectedRows = executeSql(` + select count(*)::int as count + from public.sales_dossier_records + where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(rejectedDossierId)}; + `); + assertOk(Number(rejectedRows[0]?.count || 0) === 0, "rejected dossier left a partial record"); + + report = { + ok: true, + test_run: suffix, + verified: { + data_api_company_write: true, + provider_run_transaction_rpc: true, + dossier_and_citations_transaction_rpc: true, + provider_run_link: true, + failed_transaction_left_no_partial_record: true, + }, + }; +} catch (error) { + primaryError = error; + throw error; +} finally { + const cleanup = adminProvider.executeSqlSync(` + delete from public.provider_runs + where workspace_id = ${sqlString(workspaceId)}::uuid + and (id = ${sqlString(runId)} or entity_id = ${sqlString(companyId)}); + delete from public.sales_companies + where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(companyId)}; + `); + if (!cleanup.ok) { + const cleanupError = new Error(`Stage 2 Data API smoke cleanup failed: ${cleanup.error?.message || "unknown error"}`); + if (!primaryError) throw cleanupError; + console.error(cleanupError.message); + } else { + const remaining = executeSql(` + select + (select count(*)::int from public.sales_companies where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(companyId)}) as companies, + (select count(*)::int from public.sales_dossier_records where workspace_id = ${sqlString(workspaceId)}::uuid and id in (${sqlString(dossierId)}, ${sqlString(rejectedDossierId)})) as dossiers, + (select count(*)::int from public.provider_runs where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(runId)}) as runs; + `)[0]; + assertOk(Number(remaining.companies) === 0 && Number(remaining.dossiers) === 0 && Number(remaining.runs) === 0, "temporary Data API records were not cleaned up"); + if (report) report.cleanup_verified = true; + } +} + +console.log(JSON.stringify(report, null, 2)); diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/smoke-stage3-material-sync.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/smoke-stage3-material-sync.mjs new file mode 100644 index 00000000..b2eb5828 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/smoke-stage3-material-sync.mjs @@ -0,0 +1,219 @@ +import { randomUUID } from "node:crypto"; + +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseDataProvider } from "../src/providers/supabaseDataProvider.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; +import { SupabaseDataRepository } from "../src/repositories/supabaseDataRepository.js"; +import { + buildMaterialSyncIdentity, + makeMaterialContentHash, +} from "../src/sync/materialSync.js"; + +const env = createEnvReader(); +const adminProvider = createSupabaseProvider({ env }); +const dataProvider = createSupabaseDataProvider({ env }); +const workspaceId = env.value("APP_WORKSPACE_ID"); +const suffix = `${Date.now()}_${randomUUID().slice(0, 8)}`; +const companyId = `s3_sync_${suffix}_company`; +const externalId = `stage3-acceptance-${suffix}`; +const identity = buildMaterialSyncIdentity(companyId, { + title: "Stage 3 material sync acceptance", + source: { + type: "feishu_doc", + external_id: externalId, + }, +}); +const checkpointId = `${identity.source_id}:revision_id`; +let primaryError = null; +let report = null; + +function sqlString(value) { + if (value === null || value === undefined || value === "") return "null"; + return `'${String(value).replace(/'/g, "''")}'`; +} + +function assertOk(condition, message) { + if (!condition) throw new Error(`Stage 3 material sync smoke assertion failed: ${message}`); +} + +function executeSql(sql) { + const result = adminProvider.executeSqlSync(sql); + if (!result.ok) throw new Error(result.error?.message || "Supabase SQL failed."); + return result.rows || []; +} + +if (!workspaceId) throw new Error("APP_WORKSPACE_ID is required."); +if (!dataProvider.isConfigured()) throw new Error("Supabase Data API configuration is required."); +if (!adminProvider.isConfigured() || !adminProvider.isRunEnabled() || adminProvider.readOnly) { + throw new Error("Writable Supabase admin configuration is required for cleanup verification."); +} + +const repository = new SupabaseDataRepository({ + env, + supabaseDataProvider: dataProvider, + workspaceId, +}); + +try { + const now = new Date().toISOString(); + const company = { + id: companyId, + name: `Stage 3 Sync Test ${suffix}`, + initial: "S", + industry: "automated_test", + location: "test", + tags: ["stage3", "material-sync"], + progress: { + label: "新商机", + summary: "Stage 3 material sync acceptance.", + evidence: "automated_test", + updated_at: now, + }, + dossier_ids: [], + material_ids: [], + qa_session_id: `sales-${companyId}`, + created_at: now, + updated_at: now, + }; + await repository.persistSalesCompany(company); + + await repository.persistSyncSource({ + id: identity.source_id, + source_type: identity.source_type, + external_id: identity.external_id, + display_name: identity.display_name, + status: "active", + config: { format: "markdown", acceptance_test: true }, + last_synced_at: now, + created_at: now, + updated_at: now, + }); + + const firstContent = "Stage 3 material sync acceptance version 1."; + const firstHash = makeMaterialContentHash({ + title: "Stage 3 material sync acceptance", + text: firstContent, + }); + const firstMaterial = { + id: identity.material_id, + company_id: companyId, + title: "Stage 3 material sync acceptance", + source_type: identity.source_type, + source_url: "", + source_id: identity.source_id, + source_version: "1", + content_hash: firstHash, + summary: firstContent, + text: firstContent, + openviking_uri: "viking://resources/sales-workbench/stage3-acceptance/material.md", + openviking_status: "indexed", + last_synced_at: now, + created_at: now, + updated_at: now, + }; + await repository.persistSalesMaterial(firstMaterial); + await repository.persistSyncCheckpoint({ + id: checkpointId, + source_id: identity.source_id, + checkpoint_key: "revision_id", + checkpoint_value: "1", + content_hash: firstHash, + last_success_at: now, + created_at: now, + updated_at: now, + }); + + const firstState = await repository.getSalesState(); + assertOk(firstState.sync_sources[identity.source_id]?.status === "active", "sync source was not persisted"); + assertOk(firstState.sync_checkpoints[checkpointId]?.checkpoint_value === "1", "initial checkpoint was not persisted"); + assertOk(firstState.materials[identity.material_id]?.source_id === identity.source_id, "material was not linked to its source"); + assertOk(firstState.companies[companyId]?.material_ids?.includes(identity.material_id), "company did not expose the synced material"); + + const secondNow = new Date(Date.now() + 1000).toISOString(); + const secondContent = "Stage 3 material sync acceptance version 2."; + const secondHash = makeMaterialContentHash({ + title: firstMaterial.title, + text: secondContent, + }); + await repository.persistSalesMaterial({ + ...firstMaterial, + source_version: "2", + content_hash: secondHash, + summary: secondContent, + text: secondContent, + last_synced_at: secondNow, + updated_at: secondNow, + }); + await repository.persistSyncCheckpoint({ + id: checkpointId, + source_id: identity.source_id, + checkpoint_key: "revision_id", + checkpoint_value: "2", + content_hash: secondHash, + last_success_at: secondNow, + updated_at: secondNow, + }); + + const secondState = await repository.getSalesState(); + const materialRows = executeSql(` + select count(*)::int as count + from public.sales_materials + where workspace_id = ${sqlString(workspaceId)}::uuid + and company_id = ${sqlString(companyId)} + and source_id = ${sqlString(identity.source_id)} + and deleted_at is null; + `); + assertOk(Number(materialRows[0]?.count || 0) === 1, "source update created a duplicate material row"); + assertOk(secondState.materials[identity.material_id]?.source_version === "2", "material version was not updated"); + assertOk(secondState.materials[identity.material_id]?.content_hash === secondHash, "material content hash was not updated"); + assertOk(secondState.sync_checkpoints[checkpointId]?.checkpoint_value === "2", "checkpoint was not advanced"); + + await repository.softDeleteSalesMaterial(identity.material_id, secondNow); + const deletedState = await repository.getSalesState(); + assertOk(!deletedState.materials[identity.material_id], "soft-deleted material remained in business reads"); + + report = { + ok: true, + test_run: suffix, + verified: { + stable_source_and_material_identity: true, + source_material_foreign_key: true, + checkpoint_persistence: true, + same_row_update_without_duplicate: true, + soft_delete_filtered_from_reads: true, + }, + }; +} catch (error) { + primaryError = error; + throw error; +} finally { + const cleanup = adminProvider.executeSqlSync(` + delete from public.sales_companies + where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(companyId)}; + delete from public.sync_sources + where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(identity.source_id)}; + `); + if (!cleanup.ok) { + const cleanupError = new Error(`Stage 3 material sync smoke cleanup failed: ${cleanup.error?.message || "unknown error"}`); + if (!primaryError) throw cleanupError; + console.error(cleanupError.message); + } else { + const remaining = executeSql(` + select + (select count(*)::int from public.sales_companies where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(companyId)}) as companies, + (select count(*)::int from public.sales_materials where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(identity.material_id)}) as materials, + (select count(*)::int from public.sync_sources where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(identity.source_id)}) as sources, + (select count(*)::int from public.sync_checkpoints where workspace_id = ${sqlString(workspaceId)}::uuid and source_id = ${sqlString(identity.source_id)}) as checkpoints; + `)[0]; + assertOk( + Number(remaining.companies) === 0 + && Number(remaining.materials) === 0 + && Number(remaining.sources) === 0 + && Number(remaining.checkpoints) === 0, + "temporary material sync records were not cleaned up", + ); + if (report) report.cleanup_verified = true; + } +} + +console.log(JSON.stringify(report, null, 2)); diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/verify-business-chain.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/verify-business-chain.mjs new file mode 100644 index 00000000..6e08db44 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/verify-business-chain.mjs @@ -0,0 +1,524 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; + +import { backendFetch, readAuthSession } from "./import-feishu-cli.mjs"; + +const DEFAULT_API_URL = "http://127.0.0.1:8787"; +const TERMINAL_JOB_STATUSES = new Set(["succeeded", "failed", "cancelled"]); +const PRIVATE_KEYS = new Set([ + "access_token", + "api_key", + "lease_token", + "openviking_ref", + "openviking_uri", + "password", + "professional_source_ref", + "prompt", + "raw_ref", + "refresh_token", + "secret", + "secret_key", + "service_role_key", + "worker_id", +]); + +function usage() { + return ` +真实业务链路验收(会写入业务数据并产生 AFP/Token) + +用法: + npm run verify:business -- \\ + --goal-id <销售目标ID> \\ + --company-query <完整企业名称> \\ + --question <基于档案的验收问题> \\ + --confirm-live + +也可以验证已入池企业: + npm run verify:business -- \\ + --enterprise-id <企业ID> \\ + --question <基于档案的验收问题> \\ + --confirm-live + +选项: + --candidate-id <候选企业ID> 搜索结果不能按完整名称唯一匹配时,显式选择候选。 + --api-url 工作台 API 地址。 + --auth-session login.mjs 创建的 0600 CLI 会话文件。 + --timeout-ms 等待异步档案任务的最长时间,默认 300000。 + --poll-ms 任务轮询间隔,默认 1000。 + --confirm-live 必填;确认调用真实 Provider 并保留生成的业务数据。 + +安全约束: + 1. 必须使用已获授权的测试企业;脚本不会自动删除企业、档案或问答。 + 2. 不接受 API Key、Service Role 或密码作为命令行参数。 + 3. 只有真实 Provider Run、逐段引用和持久化检查全部通过,才会输出 ok=true。 +`; +} + +function optionValue(argv, index, name) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${name} 缺少参数值。`); + return value; +} + +function parseArgs(argv) { + const options = { + apiUrl: process.env.SALES_WORKBENCH_API_URL || "", + authSession: process.env.SALES_WORKBENCH_AUTH_SESSION + || path.join(os.homedir(), ".local", "state", "sales-intelligence-workbench", "cli-session.json"), + goalId: "", + companyQuery: "", + candidateId: "", + enterpriseId: "", + question: "", + timeoutMs: 300_000, + pollMs: 1_000, + confirmLive: false, + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--help" || argument === "-h") options.help = true; + else if (argument === "--confirm-live") options.confirmLive = true; + else if (argument === "--api-url") { + options.apiUrl = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--auth-session") { + options.authSession = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--goal-id") { + options.goalId = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--company-query") { + options.companyQuery = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--candidate-id") { + options.candidateId = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--enterprise-id") { + options.enterpriseId = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--question") { + options.question = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--timeout-ms") { + options.timeoutMs = Number(optionValue(argv, index, argument)); + index += 1; + } else if (argument === "--poll-ms") { + options.pollMs = Number(optionValue(argv, index, argument)); + index += 1; + } else { + throw new Error(`未知参数:${argument}`); + } + } + + if (options.help) return options; + if (!options.confirmLive) { + throw new Error("必须提供 --confirm-live,确认本次会调用真实 Provider、产生 AFP/Token 并保留业务数据。"); + } + if (Boolean(options.companyQuery) === Boolean(options.enterpriseId)) { + throw new Error("--company-query 与 --enterprise-id 必须且只能提供一个。"); + } + if (options.companyQuery && !options.goalId) { + throw new Error("使用 --company-query 时必须提供 --goal-id。"); + } + if (options.candidateId && !options.companyQuery) { + throw new Error("--candidate-id 只能与 --company-query 一起使用。"); + } + if (!options.question.trim()) { + throw new Error("必须提供 --question,以验证 Supabase 档案、OpenViking 资料召回与会话记忆,以及模型问答。"); + } + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 10_000 || options.timeoutMs > 900_000) { + throw new Error("--timeout-ms 必须在 10000 到 900000 之间。"); + } + if (!Number.isFinite(options.pollMs) || options.pollMs < 250 || options.pollMs > 5_000) { + throw new Error("--poll-ms 必须在 250 到 5000 之间。"); + } + return options; +} + +function normalizeIdentity(value) { + return String(value || "") + .normalize("NFKC") + .toLowerCase() + .replace(/[\s·•()()[\]【】_-]+/g, ""); +} + +function selectCandidate(candidates, options) { + if (!Array.isArray(candidates) || !candidates.length) { + throw new Error("专业数据集没有返回可选择的企业候选。"); + } + let matches = []; + if (options.candidateId) { + matches = candidates.filter((candidate) => candidate.id === options.candidateId); + } else { + const expected = normalizeIdentity(options.companyQuery); + matches = candidates.filter((candidate) => normalizeIdentity(candidate.name) === expected); + } + if (matches.length !== 1) { + const visibleCandidates = candidates.slice(0, 8) + .map((candidate) => `${candidate.name || "未命名企业"} (${candidate.id || "无ID"})`) + .join(";"); + throw new Error( + `无法唯一确定企业主体。请核对完整企业名称,或使用 --candidate-id 显式选择。候选:${visibleCandidates || "无"}`, + ); + } + const selected = matches[0]; + if (selected.identity_status !== "verified") { + throw new Error(`候选企业 ${selected.name || selected.id} 未通过专业数据集主体核验,不能进入生产验收。`); + } + return selected; +} + +function parsePayload(text) { + try { + return text ? JSON.parse(text) : {}; + } catch { + return {}; + } +} + +async function apiRequest(options, method, endpoint, body) { + const response = await backendFetch(`${options.apiUrl}${endpoint}`, { + method, + headers: body === undefined ? {} : { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }, options); + const payload = parsePayload(await response.text()); + if (!response.ok) { + const code = payload?.error?.code || `http_${response.status}`; + const message = payload?.error?.message || "工作台 API 请求失败。"; + const requestId = payload?.meta?.request_id ? `,请求ID ${payload.meta.request_id}` : ""; + throw new Error(`${code}: ${message}${requestId}`); + } + return payload.data; +} + +function assertPrivateSession(filePath) { + const session = readAuthSession(filePath); + if (!session) { + throw new Error("未找到有效 CLI 登录态。请先运行 Skill 的 login.mjs,密码不要发送到聊天或命令行参数。"); + } + const mode = fs.statSync(filePath).mode & 0o077; + if (mode !== 0) throw new Error("CLI 会话文件权限不安全;请将其权限改为 0600 后重试。"); + return session; +} + +async function pollJob(options, jobId, request = apiRequest) { + const deadline = Date.now() + options.timeoutMs; + let previousStage = ""; + while (Date.now() < deadline) { + const job = await request(options, "GET", `/api/jobs/${encodeURIComponent(jobId)}`); + if (job.stage !== previousStage) { + previousStage = job.stage; + process.stderr.write(`任务 ${job.id}:${job.stage_label || job.stage}(${job.progress ?? 0}%)\n`); + } + if (TERMINAL_JOB_STATUSES.has(job.status)) { + if (job.status !== "succeeded") { + throw new Error(`档案任务未成功:${job.status}${job.error?.code ? ` (${job.error.code})` : ""}`); + } + return job; + } + await delay(options.pollMs); + } + throw new Error(`等待档案任务超时(${options.timeoutMs}ms);任务仍可能在后台运行,请按 job_id 查询。`); +} + +function collectPrivatePaths(value, prefix = "$", result = []) { + if (Array.isArray(value)) { + value.forEach((item, index) => collectPrivatePaths(item, `${prefix}[${index}]`, result)); + return result; + } + if (!value || typeof value !== "object") return result; + for (const [key, nested] of Object.entries(value)) { + const nextPath = `${prefix}.${key}`; + if (PRIVATE_KEYS.has(key.toLowerCase())) result.push(nextPath); + collectPrivatePaths(nested, nextPath, result); + } + return result; +} + +function assertPublicPayload(value, label) { + const privatePaths = collectPrivatePaths(value); + if (privatePaths.length) { + throw new Error(`${label} 暴露了内部字段:${privatePaths.slice(0, 8).join("、")}`); + } +} + +function validateCitedParagraphs(paragraphs, citations, label) { + if (!Array.isArray(paragraphs) || !paragraphs.length) throw new Error(`${label}没有可验收的正文段落。`); + if (!Array.isArray(citations) || !citations.length) throw new Error(`${label}没有真实引用来源。`); + const allowedIds = new Set(citations.map((citation) => String(citation.id))); + for (const [index, paragraph] of paragraphs.entries()) { + const ids = Array.isArray(paragraph.citation_ids) ? paragraph.citation_ids.map(String) : []; + if (!String(paragraph.text || "").trim()) throw new Error(`${label}第 ${index + 1} 段正文为空。`); + if (!ids.length) throw new Error(`${label}第 ${index + 1} 段缺少引用。`); + const unknown = ids.filter((id) => !allowedIds.has(id)); + if (unknown.length) throw new Error(`${label}第 ${index + 1} 段引用了不存在的来源:${unknown.join("、")}`); + } +} + +function validateDossier(dossier, enterpriseId) { + assertPublicPayload(dossier, "档案公开响应"); + if (!dossier?.id || dossier.company_id !== enterpriseId) throw new Error("档案与目标企业不匹配。"); + validateCitedParagraphs(dossier.body, dossier.citations, "档案"); + const sourceKinds = [...new Set(dossier.citations.map((citation) => citation.source_kind).filter(Boolean))]; + if (!sourceKinds.some((kind) => /专业数据|工商|招投标/.test(kind))) { + throw new Error("档案缺少专业数据来源,不能作为生产验收结果。"); + } + if (!sourceKinds.some((kind) => /联网搜索|公开|新闻|公告|媒体|官网/.test(kind))) { + throw new Error("档案缺少联网公开来源,不能作为生产验收结果。"); + } + return { sourceKinds, citationCount: dossier.citations.length, paragraphCount: dossier.body.length }; +} + +function validateQa(result) { + assertPublicPayload(result, "资料问答公开响应"); + const message = result?.message; + if (!message?.id || message.role !== "assistant") throw new Error("资料问答没有返回有效的助手消息。"); + if (message.insufficient) throw new Error("资料问答返回资料不足,完整业务链路未通过。"); + validateCitedParagraphs(message.paragraphs, message.citations, "资料问答"); + return { message, citationCount: message.citations.length }; +} + +function assertProviderRun(run, expectedProviders, label) { + assertPublicPayload(run, `${label} Provider Run`); + if (!run?.id || !["succeeded", "succeeded_with_issues"].includes(run.status)) { + throw new Error(`${label} Provider Run 未成功。`); + } + const missing = []; + const failed = []; + for (const provider of expectedProviders) { + const steps = (run.steps || []).filter((step) => step.provider === provider); + if (!steps.length) { + missing.push(provider); + continue; + } + if (!steps.some((step) => step.status === "succeeded")) failed.push(provider); + } + if (missing.length || failed.length) { + throw new Error( + `${label} Provider 未完整通过` + + `${missing.length ? `;缺少:${missing.join("、")}` : ""}` + + `${failed.length ? `;未成功:${failed.join("、")}` : ""}`, + ); + } +} + +function assertDossierPersistenceBoundary(run) { + const step = (run?.steps || []).find((candidate) => ( + candidate.provider === "openviking" + && candidate.operation === "store_dossier_memory" + )); + if (!step) { + throw new Error("最新档案缺少 OpenViking 存储边界证据。"); + } + if (step.status !== "skipped" || !/Supabase/.test(String(step.output_summary || ""))) { + throw new Error("最新档案未遵守 Supabase 持久化、OpenViking 不重复存档的边界。"); + } +} + +function providerRunSummary(run) { + return { + id: run.id, + operation: run.operation, + status: run.status, + duration_ms: run.duration_ms, + steps: (run.steps || []).map((step) => ({ + provider: step.provider, + operation: step.operation, + status: step.status, + attempts: step.attempts, + latency_ms: step.latency_ms, + usage: step.usage || null, + error_code: step.error?.code || null, + })), + }; +} + +function usageSummary(runs) { + const summary = { + model: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + provider_attempts: {}, + }; + for (const run of runs.filter(Boolean)) { + for (const step of run.steps || []) { + summary.provider_attempts[step.provider] = (summary.provider_attempts[step.provider] || 0) + + Math.max(1, Number(step.attempts || 1)); + if (step.provider !== "model" || !step.usage) continue; + summary.model.prompt_tokens += Number(step.usage.prompt_tokens || 0); + summary.model.completion_tokens += Number(step.usage.completion_tokens || 0); + summary.model.total_tokens += Number(step.usage.total_tokens || 0); + } + } + return summary; +} + +async function findProviderRun(options, input, request = apiRequest) { + if (input.runId) { + return request(options, "GET", `/api/provider-runs/${encodeURIComponent(input.runId)}`); + } + const query = new URLSearchParams({ + operation: input.operation, + entity_id: input.entityId, + limit: "20", + }); + const runs = await request(options, "GET", `/api/provider-runs?${query}`); + const startedAfter = Date.parse(input.startedAfter || ""); + const match = (runs || []).find((run) => input.jobId && run.job_id === input.jobId) + || (runs || []).find((run) => !Number.isFinite(startedAfter) || Date.parse(run.started_at || "") >= startedAfter - 5_000); + if (!match) throw new Error(`找不到 ${input.operation} 的 Provider Run 证据。`); + return match; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + process.stdout.write(usage().trimStart()); + return; + } + + const session = assertPrivateSession(options.authSession); + options.apiUrl = String(options.apiUrl || session.api_url || DEFAULT_API_URL).replace(/\/$/, ""); + if (!/^https?:\/\/[^/]+/i.test(options.apiUrl)) throw new Error("--api-url 不是有效的 HTTP(S) 地址。"); + + process.stderr.write("开始真实业务验收:会保留 Supabase 企业/档案记录和 OpenViking 问答 Session,并产生真实 AFP/Token。\n"); + const runs = []; + let company; + let searchRun = null; + + if (options.companyQuery) { + const goals = await apiRequest(options, "GET", "/api/sales-goals"); + if (!(goals || []).some((goal) => goal.id === options.goalId)) { + throw new Error(`销售目标不存在或当前用户无权访问:${options.goalId}`); + } + const candidates = await apiRequest( + options, + "POST", + `/api/sales-goals/${encodeURIComponent(options.goalId)}/company-search`, + { query: options.companyQuery }, + ); + const selected = selectCandidate(candidates, options); + searchRun = await findProviderRun(options, { + runId: selected.provider_run_id, + operation: "sales_company_search", + entityId: options.goalId, + }); + assertProviderRun(searchRun, ["datapro", "web_search"], "企业搜索"); + runs.push(searchRun); + company = await apiRequest( + options, + "POST", + `/api/sales-goals/${encodeURIComponent(options.goalId)}/target-enterprises`, + { company_id: selected.id }, + ); + } else { + company = await apiRequest(options, "GET", `/api/target-enterprises/${encodeURIComponent(options.enterpriseId)}`); + } + + assertPublicPayload(company, "企业公开响应"); + if (!company?.id || company.identity_status !== "verified") { + throw new Error("目标企业未通过专业数据集主体核验,不能继续生产验收。"); + } + + const dossierStartedAt = new Date().toISOString(); + const dossierResponse = await apiRequest( + options, + "POST", + `/api/target-enterprises/${encodeURIComponent(company.id)}/dossiers`, + { idempotency_key: `release-acceptance-${Date.now()}` }, + ); + let dossierJob = null; + let dossierId = dossierResponse?.detail?.id || dossierResponse?.id || ""; + if (dossierResponse?.job_type || ["queued", "running"].includes(dossierResponse?.status)) { + dossierJob = await pollJob(options, dossierResponse.id); + dossierId = dossierJob.result?.dossier_id || ""; + if (dossierJob.result?.action !== "created") { + throw new Error("档案证据未变化,模型和持久化写入没有完整执行;本次不能作为完整生产验收。"); + } + } + if (!dossierId) throw new Error("档案任务成功但没有返回 dossier_id。"); + const dossier = await apiRequest(options, "GET", `/api/dossiers/${encodeURIComponent(dossierId)}`); + const dossierChecks = validateDossier(dossier, company.id); + const dossierRun = await findProviderRun(options, { + operation: "sales_dossier_generation", + entityId: company.id, + jobId: dossierJob?.id || dossierResponse?.job_id || "", + startedAfter: dossierStartedAt, + }); + assertProviderRun(dossierRun, ["datapro", "web_search", "model", "supabase"], "最新档案"); + assertDossierPersistenceBoundary(dossierRun); + runs.push(dossierRun); + + const qaResult = await apiRequest( + options, + "POST", + `/api/target-enterprises/${encodeURIComponent(company.id)}/qa`, + { question: options.question }, + ); + const qaChecks = validateQa(qaResult); + const qaRun = await findProviderRun(options, { + runId: qaResult.provider_run_id, + operation: "sales_qa", + entityId: company.id, + }); + assertProviderRun(qaRun, ["openviking", "model", "supabase"], "资料问答"); + runs.push(qaRun); + + process.stdout.write(`${JSON.stringify({ + ok: true, + mode: "real_business_chain", + finished_at: new Date().toISOString(), + writes_retained: true, + goal_id: options.goalId || company.goal_id || null, + enterprise: { + id: company.id, + name: company.name, + identity_status: company.identity_status, + }, + company_search: searchRun ? providerRunSummary(searchRun) : { status: "not_run_existing_enterprise" }, + dossier: { + id: dossier.id, + job_id: dossierJob?.id || null, + version_no: dossier.version_no, + citation_count: dossierChecks.citationCount, + paragraph_count: dossierChecks.paragraphCount, + source_kinds: dossierChecks.sourceKinds, + provider_run: providerRunSummary(dossierRun), + }, + qa: { + message_id: qaChecks.message.id, + citation_count: qaChecks.citationCount, + provider_run: providerRunSummary(qaRun), + }, + usage: usageSummary(runs), + }, null, 2)}\n`); +} + +export { + apiRequest, + assertDossierPersistenceBoundary, + assertProviderRun, + collectPrivatePaths, + findProviderRun, + normalizeIdentity, + parseArgs, + pollJob, + selectCandidate, + usageSummary, + validateDossier, + validateQa, +}; + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`${JSON.stringify({ + ok: false, + error: { message: error.message }, + }, null, 2)}\n`); + process.exitCode = 1; + }); +} diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/verify-openviking-qa-boundary.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/verify-openviking-qa-boundary.mjs new file mode 100644 index 00000000..605b6de4 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/verify-openviking-qa-boundary.mjs @@ -0,0 +1,54 @@ +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; + +const env = createEnvReader(); +const provider = createSupabaseProvider({ env }); + +if (!provider.isConfigured()) { + throw new Error("Supabase persistence is not configured."); +} + +function query(sql, label) { + const result = provider.executeSqlSync(sql); + if (!result.ok) throw new Error(`${label}: ${result.error?.message || "query failed"}`); + return result.rows || []; +} + +const [state] = query( + ` + select + to_regclass('public.sales_qa_messages') as active_table, + to_regclass('public.sales_qa_messages_legacy') as legacy_table, + has_table_privilege('anon', 'public.sales_qa_messages_legacy', 'select') as anon_select, + has_table_privilege('authenticated', 'public.sales_qa_messages_legacy', 'select') as authenticated_select, + has_table_privilege('service_role', 'public.sales_qa_messages_legacy', 'select') as service_role_select + `, + "Unable to inspect the QA storage boundary", +); +const [count] = query( + "select count(*)::integer as legacy_rows from public.sales_qa_messages_legacy", + "Unable to count legacy QA rows", +); +const [migration] = query( + "select version, description, applied_at from public.schema_migrations where version = '202607280001'", + "Unable to inspect the QA boundary migration", +); + +const checks = { + migration_applied: migration?.version === "202607280001", + active_table_removed: state?.active_table === null, + legacy_table_present: String(state?.legacy_table || "").endsWith("sales_qa_messages_legacy"), + anon_blocked: state?.anon_select === false, + authenticated_blocked: state?.authenticated_select === false, + service_role_can_audit: state?.service_role_select === true, +}; +const ok = Object.values(checks).every(Boolean); + +console.log(JSON.stringify({ + ok, + checks, + legacy_rows: Number(count?.legacy_rows || 0), + migration: migration || null, +}, null, 2)); + +if (!ok) process.exitCode = 1; diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/verify-release-local.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/verify-release-local.mjs new file mode 100644 index 00000000..8b4f1e57 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/verify-release-local.mjs @@ -0,0 +1,75 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const backendDir = path.resolve(scriptDir, ".."); +const projectRoot = path.resolve(backendDir, ".."); +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + +const steps = [ + { + name: "前端 JavaScript 语法", + command: process.execPath, + args: ["--check", "frontend/app.js"], + cwd: projectRoot, + }, + { + name: "前端文本格式化语法", + command: process.execPath, + args: ["--check", "frontend/text-format.js"], + cwd: projectRoot, + }, + { + name: "后端自动化测试", + command: npmCommand, + args: ["test"], + cwd: backendDir, + }, + { + name: "发布密钥扫描", + command: npmCommand, + args: ["run", "release:secrets"], + cwd: backendDir, + }, + { + name: "Skill 分发包一致性", + command: process.execPath, + args: ["skills/sales-intelligence-workbench/scripts/sync-assets.mjs", "--check"], + cwd: projectRoot, + }, + { + name: "Skill 隔离生命周期", + command: process.execPath, + args: ["skills/sales-intelligence-workbench/scripts/self-test.mjs"], + cwd: projectRoot, + }, +]; + +function runStep(step, index) { + console.log(`\n[${index + 1}/${steps.length}] ${step.name}`); + const result = spawnSync(step.command, step.args, { + cwd: step.cwd, + env: { + ...process.env, + NO_COLOR: process.env.NO_COLOR || "1", + }, + stdio: "inherit", + }); + + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${step.name} 未通过(退出码 ${result.status ?? "unknown"})。`); + } +} + +console.log("开始离线发布验收。本流程不访问外部 Provider,也不会产生 AFP。"); + +try { + steps.forEach(runStep); + console.log(`\n离线发布验收通过:${steps.length}/${steps.length} 项完成。`); +} catch (error) { + console.error(`\n离线发布验收失败:${error?.message || String(error)}`); + process.exitCode = 1; +} diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/verify-supabase-security-boundary.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/verify-supabase-security-boundary.mjs new file mode 100644 index 00000000..57b11cd2 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/verify-supabase-security-boundary.mjs @@ -0,0 +1,103 @@ +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; + +const env = createEnvReader(); +const provider = createSupabaseProvider({ env }); + +if (!provider.isConfigured()) { + throw new Error("Supabase persistence is not configured."); +} + +function query(sql, label) { + const result = provider.executeSqlSync(sql); + if (!result.ok) throw new Error(`${label}: ${result.error?.message || "query failed"}`); + return result.rows || []; +} + +const managementRoutines = [ + "persist_sales_dossier", + "persist_provider_run", + "reserve_paid_workflow", + "finish_paid_workflow", + "get_paid_workflow_usage", + "enqueue_sales_job", + "claim_sales_job", + "heartbeat_sales_job", + "release_sales_job_claim", + "request_cancel_sales_job", + "acknowledge_cancel_sales_job", + "retry_sales_job", +]; +const routineList = managementRoutines.map((name) => `'${name}'`).join(", "); +const platformManagedTables = new Set(["health_check"]); + +const tables = query( + ` + select + c.relname as table_name, + pg_get_userbyid(c.relowner) as owner, + c.relrowsecurity as rls_enabled, + has_table_privilege('anon', c.oid, 'select') as anon_select, + has_table_privilege('authenticated', c.oid, 'select') as authenticated_select + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + where n.nspname = 'public' and c.relkind in ('r', 'p') + order by c.relname + `, + "Unable to inspect public table RLS", +); +const ordinaryExecuteGrants = query( + ` + select distinct routine_name, grantee + from information_schema.routine_privileges + where routine_schema = 'public' + and routine_name in (${routineList}) + and privilege_type = 'EXECUTE' + and grantee in ('PUBLIC', 'anon', 'authenticated') + order by routine_name, grantee + `, + "Unable to inspect ordinary-role RPC grants", +); +const serviceRoleExecuteGrants = query( + ` + select distinct routine_name + from information_schema.routine_privileges + where routine_schema = 'public' + and routine_name in (${routineList}) + and privilege_type = 'EXECUTE' + and grantee = 'service_role' + order by routine_name + `, + "Unable to inspect service-role RPC grants", +); + +const projectTables = tables.filter((table) => !platformManagedTables.has(table.table_name)); +const platformTables = tables.filter((table) => platformManagedTables.has(table.table_name)); +const tablesWithoutRls = projectTables + .filter((table) => table.rls_enabled !== true) + .map((table) => table.table_name); +const exposedPlatformTables = platformTables + .filter((table) => table.anon_select === true || table.authenticated_select === true) + .map((table) => table.table_name); +const serviceRoleRoutines = new Set(serviceRoleExecuteGrants.map((row) => row.routine_name)); +const missingServiceRoleGrants = managementRoutines.filter((name) => !serviceRoleRoutines.has(name)); +const checks = { + project_public_tables_use_rls: tablesWithoutRls.length === 0, + platform_managed_tables_fail_closed: exposedPlatformTables.length === 0, + ordinary_roles_cannot_execute_management_rpcs: ordinaryExecuteGrants.length === 0, + service_role_can_execute_management_rpcs: missingServiceRoleGrants.length === 0, +}; +const ok = Object.values(checks).every(Boolean); + +console.log(JSON.stringify({ + ok, + checks, + inspected_project_tables: projectTables.length, + platform_managed_tables: platformTables, + tables_without_rls: tablesWithoutRls, + exposed_platform_tables: exposedPlatformTables, + ordinary_execute_grants: ordinaryExecuteGrants, + missing_service_role_grants: missingServiceRoleGrants, +}, null, 2)); + +if (!ok) process.exitCode = 1; diff --git a/demohouse/sales-intelligence-workbench/backend/src/agents/dossierAgent.js b/demohouse/sales-intelligence-workbench/backend/src/agents/dossierAgent.js new file mode 100644 index 00000000..6a0939e2 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/agents/dossierAgent.js @@ -0,0 +1,1385 @@ +import { + extractGroundingDates, + extractGroundingNumbers, + evidenceSpanErrors, + groundedTextErrors, +} from "../evidence/claimGrounding.js"; +import { + extractCriticalClaims, + hasHighRiskAssertion, +} from "../evidence/salesEvidence.js"; + +const SECTION_DEFINITIONS = Object.freeze([ + ["company_overview", "企业与业务概览"], + ["business_dynamics", "经营与业务动态"], + ["recent_public_updates", "近期公开动态"], + ["risk_attention", "风险与关注事项"], + ["sales_opportunity", "销售机会判断"], + ["recommended_actions", "建议行动"], +]); + +const PLAN_FUNCTION_NAME = "plan_sales_dossier"; +const MAX_AGENT_CITATIONS = 10; +const MAX_PROFESSIONAL_CITATIONS = 5; +const MAX_PUBLIC_CITATIONS = 5; +const PROFESSIONAL_SUMMARY_CHARS = 700; +const PUBLIC_SUMMARY_CHARS = 500; +const MAX_EVIDENCE_IDS_PER_SECTION = 3; +const MAX_EVIDENCE_ATOMS_PER_SECTION = 6; +const MAX_PLAN_ITEM_CHARS = 600; +const SUBJECT_BOUNDARY_TERMS = /(?:品牌|集团|相关业务|在华业务|中国业务|公开信息显示)/u; +const ANALYTICAL_RISK_TERMS = /(?:应|需|建议|核验|确认|关注|评估|避免|前置|待明确|待沟通|对接前)/u; + +const OUTPUT_BUDGET = Object.freeze({ + summary_max_chars: 160, + section_max_chars: 1000, + paragraph_max_chars: 600, + paragraphs_per_section: "1", + memory_summary_max_chars: 200, + recommended_action_count: "1", +}); + +function compact(value, maxLength) { + const normalized = String(value || "").replace(/\s+/gu, " ").trim(); + if (normalized.length <= maxLength) return normalized; + return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`; +} + +function sourceRank(citation = {}) { + const qualityTier = Number(citation.quality_tier); + const qualityScore = Number.isFinite(qualityTier) ? Math.max(0, 5 - qualityTier) * 10 : 0; + const freshnessScore = citation.freshness === "current" ? 18 : citation.freshness === "recent" ? 10 : 0; + const officialScore = citation.official ? 12 : 0; + const datedScore = citation.published_at ? 4 : 0; + return qualityScore + freshnessScore + officialScore + datedScore; +} + +function ranked(citations = []) { + return [...citations].sort((left, right) => ( + sourceRank(right) - sourceRank(left) + || String(right.published_at || "").localeCompare(String(left.published_at || "")) + || String(left.id || "").localeCompare(String(right.id || "")) + )); +} + +function citationIndependenceKey(citation = {}) { + return String( + citation.independence_key + || `${citation.source_kind || "source"}:${citation.id || citation.label || ""}`, + ); +} + +function distinctCitationCount(citations = []) { + return new Set(citations.map(citationIndependenceKey).filter(Boolean)).size; +} + +function sameCriticalClaim(left = {}, right = {}) { + return left.field === right.field + && left.normalized_value === right.normalized_value; +} + +function atomCriticalClaims(atom = {}) { + return extractCriticalClaims(String(atom.quote || "")); +} + +const SPECIFIC_RISK_TERMS = [ + "行政处罚", + "司法诉讼", + "失信被执行", + "限制高消费", + "经营异常", + "监管处罚", + "产品召回", + "安全事故", + "供应中断", + "交付延期", +]; + +function atomRiskSignature(atom = {}) { + const value = String(atom.quote || ""); + return { + terms: SPECIFIC_RISK_TERMS.filter((term) => value.includes(term)), + dates: extractGroundingDates(value), + numbers: extractGroundingNumbers(value), + }; +} + +function sameRiskSignature(left = {}, right = {}) { + if (!left.terms.length || !left.terms.every((term) => right.terms.includes(term))) return false; + if (left.dates.length && !left.dates.every((date) => right.dates.includes(date))) return false; + if (left.numbers.length && !left.numbers.every((number) => right.numbers.includes(number))) return false; + return true; +} + +function criticalClaimSupportingAtoms(atom = {}, atoms = [], citationById = new Map()) { + const claims = atomCriticalClaims(atom); + const riskSignature = atomRiskSignature(atom); + if (!claims.length && !riskSignature.terms.length) return []; + return atoms.filter((candidate) => { + const citation = citationById.get(String(candidate.citation_id || "")); + if (!citation) return false; + const candidateClaims = atomCriticalClaims(candidate); + if (claims.length) { + return claims.every((claim) => candidateClaims.some((candidateClaim) => ( + sameCriticalClaim(claim, candidateClaim) + ))); + } + return sameRiskSignature(riskSignature, atomRiskSignature(candidate)); + }); +} + +function hasSufficientCriticalClaimSupport(atom = {}, atoms = [], citationById = new Map()) { + const claims = atomCriticalClaims(atom); + if (!claims.length && !hasHighRiskAssertion(atom.quote)) return true; + const supporters = criticalClaimSupportingAtoms(atom, atoms, citationById); + const supportingCitations = [...new Map(supporters.map((candidate) => { + const citation = citationById.get(String(candidate.citation_id || "")); + return [String(candidate.citation_id || ""), citation]; + })).values()].filter(Boolean); + return distinctCitationCount(supportingCitations) >= 2 + && supportingCitations.some((citation) => Number(citation.quality_tier) === 1); +} + +export function buildDossierSourceUsageRequirements(citations = []) { + const professional = citations.filter((citation) => citation.source_kind === "专业数据集"); + const publicSources = citations.filter((citation) => citation.source_kind === "联网搜索"); + const availableDistinct = distinctCitationCount(citations); + const availableProfessional = distinctCitationCount(professional); + const availablePublic = distinctCitationCount(publicSources); + return { + available_distinct_source_count: availableDistinct, + required_distinct_source_count: 0, + available_professional_source_count: availableProfessional, + required_professional_source_count: 0, + available_public_source_count: availablePublic, + required_public_source_count: 0, + }; +} + +export function dossierSourceUsageErrors( + citationIds = [], + citations = [], + requirements = buildDossierSourceUsageRequirements(citations), + path = "整份档案", +) { + const citationById = new Map(citations.map((citation) => [String(citation?.id || ""), citation])); + const used = [...new Set(citationIds.map(String))] + .map((id) => citationById.get(id)) + .filter(Boolean); + const usedProfessional = used.filter((citation) => citation.source_kind === "专业数据集"); + const usedPublic = used.filter((citation) => citation.source_kind === "联网搜索"); + const actual = { + total: distinctCitationCount(used), + professional: distinctCitationCount(usedProfessional), + public: distinctCitationCount(usedPublic), + }; + const errors = []; + if (actual.total < Number(requirements.required_distinct_source_count || 0)) { + errors.push( + `${path}仅覆盖 ${actual.total} 个独立来源,当前证据允许覆盖至少 ${requirements.required_distinct_source_count} 个`, + ); + } + if (actual.professional < Number(requirements.required_professional_source_count || 0)) { + errors.push( + `${path}仅覆盖 ${actual.professional} 个独立专业来源,当前证据允许覆盖至少 ${requirements.required_professional_source_count} 个`, + ); + } + if (actual.public < Number(requirements.required_public_source_count || 0)) { + errors.push( + `${path}仅覆盖 ${actual.public} 个独立公开来源,当前证据允许覆盖至少 ${requirements.required_public_source_count} 个`, + ); + } + return errors; +} + +function selectedPolicy(policy = {}, selectedIds = new Set()) { + return Object.fromEntries(Object.entries(policy).map(([key, ids]) => [ + key, + (Array.isArray(ids) ? ids : []).map(String).filter((id) => selectedIds.has(id)), + ])); +} + +function compactCitation(citation = {}) { + const isPublic = citation.source_kind === "联网搜索"; + return { + id: String(citation.id || ""), + source_kind: String(citation.source_kind || ""), + label: compact(citation.label, 160), + summary: compact( + citation.summary || citation.excerpt, + isPublic ? PUBLIC_SUMMARY_CHARS : PROFESSIONAL_SUMMARY_CHARS, + ), + published_at: citation.published_at || null, + source_quality_label: compact(citation.source_quality_label, 60), + freshness_label: compact(citation.freshness_label, 60), + entity_match: compact(citation.entity_match, 40), + independence_key: compact(citation.independence_key, 160), + conflict_fields: (Array.isArray(citation.conflict_fields) ? citation.conflict_fields : []) + .map((item) => compact(item, 80)) + .filter(Boolean) + .slice(0, 6), + }; +} + +function compactEvidenceAtom(atom = {}) { + return { + id: String(atom.id || ""), + quote: compact(atom.quote, 360), + source_kind: String(atom.source_kind || ""), + source_type: String(atom.source_type || ""), + title: compact(atom.title, 160), + published_at: atom.published_at || null, + entity_match: compact(atom.entity_match, 40), + reliability: compact(atom.reliability, 40), + conflict_fields: (Array.isArray(atom.conflict_fields) ? atom.conflict_fields : []) + .map((item) => compact(item, 80)) + .filter(Boolean) + .slice(0, 6), + selection_scope: atom.selection_scope === "cross_section_grounding" + ? "cross_section_grounding" + : "section_candidate", + }; +} + +function evidenceAtomOrder(left, right) { + return Number(right.score || 0) - Number(left.score || 0) + || String(left.id || "").localeCompare(String(right.id || "")); +} + +function atomHasUsableEntityMatch(atom = {}, section = "") { + const entityMatch = String(atom.entity_match || ""); + if (section === "company_overview") return entityMatch === "verified"; + if (section === "risk_attention" && atom.selection_scope !== "cross_section_grounding") { + return ["verified", "company_scoped"].includes(entityMatch); + } + return entityMatch && entityMatch !== "unverified"; +} + +function sectionEvidenceCandidates(atoms = [], section = "") { + const usable = atoms + .filter((atom) => atom?.id && atom?.citation_id && atom?.quote) + .filter((atom) => atomHasUsableEntityMatch(atom, section)); + const direct = usable + .filter((atom) => ( + Array.isArray(atom.section_candidates) + && atom.section_candidates.includes(section) + )) + .sort(evidenceAtomOrder) + .map((atom) => ({ ...atom, selection_scope: "section_candidate" })); + if (direct.length) return direct; + + const professional = usable.filter((atom) => atom.source_kind === "professional"); + const publicSources = usable.filter((atom) => atom.source_kind === "public"); + let fallback = []; + if (section === "recent_public_updates") { + fallback = [ + ...professional.filter((atom) => atom.published_at || atom.source_updated_at), + ...professional, + ...publicSources, + ]; + } else if (section === "risk_attention") { + fallback = [ + ...professional.filter((atom) => ( + Array.isArray(atom.section_candidates) + && atom.section_candidates.includes("business_dynamics") + )), + ...professional.filter((atom) => ( + Array.isArray(atom.section_candidates) + && atom.section_candidates.includes("company_overview") + )), + ...professional, + ...publicSources, + ]; + } else { + fallback = [...professional, ...publicSources]; + } + return [...new Map( + fallback.map((atom) => [String(atom.id), atom]), + ).values()].map((atom) => ({ + ...atom, + selection_scope: "cross_section_grounding", + })); +} + +const SECTION_REQUIRED_SOURCE_POLICY = Object.freeze({ + company_overview: "business_database_ids", + business_dynamics: "business_dynamics_ids", + recent_public_updates: "web_search_ids", + risk_attention: "risk_database_ids", +}); + +function policyConstrainedSectionCandidates(candidates = [], section = "", policy = {}) { + const policyKey = SECTION_REQUIRED_SOURCE_POLICY[section]; + const policyValues = policyKey && Array.isArray(policy?.[policyKey]) + ? policy[policyKey] + : section === "business_dynamics" && Array.isArray(policy?.market_database_ids) + ? policy.market_database_ids + : []; + const requiredCitationIds = new Set( + policyValues + .map(String) + .filter(Boolean), + ); + if (!requiredCitationIds.size) return candidates; + return candidates.filter((atom) => requiredCitationIds.has(String(atom.citation_id || ""))); +} + +/** + * Build a deterministic, bounded evidence projection for the two-stage agent. + * The durable evidence pack remains complete and is still used by server-side + * validators after the model calls finish. + */ +export function buildDossierAgentContext({ + citations = [], + evidencePolicy = {}, + evidenceConflicts = [], + sourceSelectionPolicy = {}, + evidenceAtoms = [], + evidenceCoverage = {}, +} = {}) { + const excludedEntityCitationIds = new Set( + (Array.isArray(sourceSelectionPolicy.excluded_entity_citation_ids) + ? sourceSelectionPolicy.excluded_entity_citation_ids + : []) + .map(String) + .filter(Boolean), + ); + const usable = ranked(citations.filter((citation) => ( + citation?.id + && citation?.summary + && !excludedEntityCitationIds.has(String(citation.id)) + ))); + const byId = new Map(usable.map((citation) => [String(citation.id), citation])); + const usableAtoms = (Array.isArray(evidenceAtoms) ? evidenceAtoms : []) + .filter((atom) => atom?.id && atom?.citation_id && atom?.quote) + .filter((atom) => byId.has(String(atom.citation_id))); + const professional = usable.filter((citation) => citation.source_kind === "专业数据集"); + const publicSources = usable.filter((citation) => citation.source_kind === "联网搜索"); + const selected = []; + const selectedIds = new Set(); + + const add = (citation) => { + const id = String(citation?.id || ""); + if (!id || selectedIds.has(id) || selected.length >= MAX_AGENT_CITATIONS) return; + selectedIds.add(id); + selected.push(citation); + }; + const addPolicyHead = (key) => { + const first = (Array.isArray(sourceSelectionPolicy[key]) ? sourceSelectionPolicy[key] : []) + .map(String) + .map((id) => byId.get(id)) + .find(Boolean); + add(first); + }; + + // Preserve at least one strong source for every report section before the + // global context cap is filled. Otherwise a low-ranked but indispensable + // risk or recent source can be dropped even though collection succeeded. + SECTION_DEFINITIONS.forEach(([key]) => { + const head = sectionEvidenceCandidates(usableAtoms, key)[0]; + add(byId.get(String(head?.citation_id || ""))); + }); + addPolicyHead("business_database_ids"); + addPolicyHead("risk_database_ids"); + addPolicyHead("business_dynamics_ids"); + addPolicyHead("market_database_ids"); + professional.slice(0, MAX_PROFESSIONAL_CITATIONS).forEach(add); + publicSources.slice(0, MAX_PUBLIC_CITATIONS).forEach(add); + usable.forEach(add); + + const selectedProfessional = selected.filter((citation) => citation.source_kind === "专业数据集"); + const selectedPublic = selected.filter((citation) => citation.source_kind === "联网搜索"); + const compactCitations = selected.map(compactCitation); + const sourceUsageRequirements = buildDossierSourceUsageRequirements(compactCitations); + const policy = selectedPolicy(sourceSelectionPolicy, selectedIds); + const conflicts = evidenceConflicts + .map((conflict) => ({ + field: compact(conflict?.field, 80), + field_label: compact(conflict?.field_label, 120), + evidence_ids: [ + ...new Set((conflict?.values || []) + .flatMap((value) => value?.evidence_ids || []) + .map(String) + .filter((id) => selectedIds.has(id))), + ], + })) + .filter((conflict) => conflict.field && conflict.evidence_ids.length >= 2); + const selectedAtomCandidates = usableAtoms + .filter((atom) => selectedIds.has(String(atom.citation_id))); + const selectedCitationById = new Map( + selected.map((citation) => [String(citation.id || ""), citation]), + ); + const selectedAtoms = selectedAtomCandidates.filter((atom) => ( + hasSufficientCriticalClaimSupport(atom, selectedAtomCandidates, selectedCitationById) + )); + const evidenceBySection = Object.fromEntries(SECTION_DEFINITIONS.map(([key]) => { + const coverageIds = new Set( + Array.isArray(evidenceCoverage?.[key]?.atom_ids) + ? evidenceCoverage[key].atom_ids.map(String) + : [], + ); + let candidates = sectionEvidenceCandidates(selectedAtoms, key) + .filter((atom) => ( + atom.selection_scope === "cross_section_grounding" + || !coverageIds.size + || coverageIds.has(String(atom.id)) + )); + candidates = policyConstrainedSectionCandidates(candidates, key, policy); + return [key, candidates.slice(0, MAX_EVIDENCE_ATOMS_PER_SECTION)]; + })); + const normalizedCoverage = Object.fromEntries(SECTION_DEFINITIONS.map(([key]) => { + const original = evidenceCoverage?.[key]; + const atoms = evidenceBySection[key] || []; + const usesCrossSectionGrounding = atoms.some((atom) => ( + atom.selection_scope === "cross_section_grounding" + )); + if (atoms.length && (original?.status === "missing" || usesCrossSectionGrounding)) { + return [key, { + status: "partial", + atom_ids: atoms.map((atom) => String(atom.id)), + reasons: [...new Set([ + ...(Array.isArray(original?.reasons) ? original.reasons : []), + "cross_section_grounded_fallback", + ])], + }]; + } + return [key, original || { + status: atoms.length ? "supported" : "missing", + atom_ids: atoms.map((atom) => String(atom.id)), + reasons: atoms.length ? [] : ["no_relevant_atoms"], + }]; + })); + + return { + citations: compactCitations, + evidencePolicy: { + source_counts: { + professional: selectedProfessional.length, + public: selectedPublic.length, + }, + conflict_count: conflicts.length, + warnings: (Array.isArray(evidencePolicy?.warnings) ? evidencePolicy.warnings : []) + .map((warning) => compact(warning, 160)) + .filter(Boolean) + .slice(0, 6), + }, + evidenceConflicts: conflicts, + sourceSelectionPolicy: policy, + sourceUsageRequirements, + evidenceBySection, + evidenceCoverage: normalizedCoverage, + outputBudget: OUTPUT_BUDGET, + metrics: { + available_citation_count: usable.length, + selected_citation_count: compactCitations.length, + professional_count: selectedProfessional.length, + public_count: selectedPublic.length, + excluded_unsupported_critical_atom_count: selectedAtomCandidates.length - selectedAtoms.length, + excluded_unrelated_entity_citation_count: excludedEntityCitationIds.size, + selected_atom_count: new Set( + Object.values(evidenceBySection).flat().map((atom) => String(atom.id)), + ).size, + serialized_chars: JSON.stringify(compactCitations).length, + }, + }; +} + +function sectionPlanSchema(evidenceIds = [], description = "") { + return { + type: "object", + additionalProperties: false, + properties: { + text: { + type: "string", + minLength: 8, + maxLength: MAX_PLAN_ITEM_CHARS, + description, + }, + evidence_ids: { + type: "array", + minItems: 1, + maxItems: MAX_EVIDENCE_IDS_PER_SECTION, + uniqueItems: true, + items: { + type: "string", + enum: [...new Set(evidenceIds.map(String).filter(Boolean))], + }, + description: "直接支撑本章正文、且属于本章节允许集合的 Evidence Atom ID。", + }, + }, + required: ["text", "evidence_ids"], + }; +} + +export function buildDossierPlanSchema( + evidenceIdsBySection = {}, + sectionKeys = SECTION_DEFINITIONS.map(([key]) => key), +) { + const selectedKeys = new Set(sectionKeys.map(String)); + const selectedSections = SECTION_DEFINITIONS.filter(([key]) => selectedKeys.has(key)); + const properties = Object.fromEntries(selectedSections.map(([key]) => [ + key, + sectionPlanSchema( + Array.isArray(evidenceIdsBySection?.[key]) ? evidenceIdsBySection[key] : [], + key === "recommended_actions" + ? "一个可直接展示的完整行动段落,写清动作、对象和待核验事项。" + : "一个可直接展示的完整正文段落,只表达本章最重要且有直接证据的内容。", + ), + ])); + return { + type: "object", + additionalProperties: false, + properties: { + sections: { + type: "object", + additionalProperties: false, + properties, + required: selectedSections.map(([key]) => key), + }, + }, + required: ["sections"], + }; +} + +function planItemsForSection(plan = {}, key) { + const item = plan?.sections?.[key]; + return item && typeof item === "object" && item.text ? [item] : []; +} + +function normalizeClaimText(value) { + return String(value || "").replace(/\s+/gu, " ").trim(); +} + +function normalizeSectionClaimText(section, value) { + const normalized = normalizeClaimText(value); + if (section !== "company_overview") return normalized; + return normalized.replace( + /[,,]?(?:并|同时)(?:还)?(?:延伸|扩展)(?:到|至)/gu, + ",并包括", + ); +} + +function normalizedPlan( + parsed = {}, + evidenceAtoms = [], + citations = [], + allowedEvidenceBySection = {}, + sourceUsageRequirements = {}, + ignoredEntityNames = [], +) { + const citationById = new Map(citations.map((item) => [String(item?.id || ""), item])); + const atomById = new Map(evidenceAtoms.map((item) => [String(item?.id || ""), item])); + const errors = []; + const seen = new Set(); + const sections = {}; + + SECTION_DEFINITIONS.forEach(([key, title]) => { + const item = parsed?.sections?.[key]; + const text = normalizeSectionClaimText(key, item?.text); + const itemPath = `${title}第 1 条`; + const rawEvidenceIds = Array.isArray(item?.evidence_ids) + ? item.evidence_ids.map(String).filter(Boolean) + : []; + const evidenceIds = [...new Set(rawEvidenceIds)]; + const allowed = new Set( + Array.isArray(allowedEvidenceBySection?.[key]) + ? allowedEvidenceBySection[key].map(String) + : [], + ); + if (!item || typeof item !== "object") errors.push(`${title}必须提交一个完整章节对象`); + if (!text) errors.push(`${itemPath}内容为空`); + if (text.length > MAX_PLAN_ITEM_CHARS) { + errors.push(`${itemPath}超过 ${MAX_PLAN_ITEM_CHARS} 个字符`); + } + if (text && !/[。!?]$/u.test(text)) errors.push(`${itemPath}不是完整句子`); + if (!rawEvidenceIds.length) errors.push(`${itemPath}缺少 Evidence ID`); + if (rawEvidenceIds.length > MAX_EVIDENCE_IDS_PER_SECTION) { + errors.push(`${itemPath}最多使用 ${MAX_EVIDENCE_IDS_PER_SECTION} 个 Evidence ID`); + } + if (rawEvidenceIds.length !== evidenceIds.length) errors.push(`${itemPath}包含重复 Evidence ID`); + + const validAtoms = []; + for (const evidenceId of evidenceIds.slice(0, MAX_EVIDENCE_IDS_PER_SECTION)) { + const atom = atomById.get(evidenceId); + if (!atom) { + errors.push(`${itemPath}包含无效 Evidence ID:${evidenceId}`); + continue; + } + if (!allowed.has(evidenceId)) { + errors.push(`${itemPath}的 Evidence ID ${evidenceId} 不属于本章节允许集合`); + continue; + } + validAtoms.push(atom); + } + const textCriticalClaims = extractCriticalClaims(text); + if (textCriticalClaims.length && validAtoms.length) { + const candidateSupporters = (Array.isArray(allowedEvidenceBySection?.[key]) + ? allowedEvidenceBySection[key] + : []) + .map((id) => atomById.get(String(id || ""))) + .filter(Boolean) + .filter((atom) => { + const claims = atomCriticalClaims(atom); + return textCriticalClaims.every((claim) => claims.some((candidateClaim) => ( + sameCriticalClaim(claim, candidateClaim) + ))); + }); + const currentCitationIds = new Set(validAtoms.map((atom) => String(atom.citation_id || ""))); + for (const supporter of candidateSupporters) { + if (validAtoms.length >= MAX_EVIDENCE_IDS_PER_SECTION) break; + const citationId = String(supporter.citation_id || ""); + if (currentCitationIds.has(citationId)) continue; + validAtoms.push(supporter); + currentCitationIds.add(citationId); + const supportingCitations = validAtoms + .map((atom) => citationById.get(String(atom.citation_id || ""))) + .filter(Boolean); + if ( + distinctCitationCount(supportingCitations) >= 2 + && supportingCitations.some((citation) => Number(citation.quality_tier) === 1) + ) break; + } + } + const evidenceSpans = validAtoms.map((atom, index) => { + const citationId = String(atom.citation_id || ""); + const quote = normalizeClaimText(atom.quote); + const citation = citationById.get(citationId); + if (!citation) { + errors.push(`${itemPath}第 ${index + 1} 个 Evidence Atom 缺少对应引用`); + } else { + errors.push(...evidenceSpanErrors( + { citation_id: citationId, quote }, + citation, + `${itemPath}第 ${index + 1} 个 Evidence Atom`, + )); + } + return { + evidence_id: String(atom.id), + citation_id: citationId, + quote, + }; + }); + const citationIds = [...new Set( + evidenceSpans + .map((span) => span.citation_id) + .filter((id) => citationById.has(id)), + )]; + if (!validAtoms.length) errors.push(`${itemPath}缺少本章节允许的 Evidence Atom`); + if (!citationIds.length) errors.push(`${itemPath}缺少有效引用`); + errors.push(...groundedTextErrors({ + text, + evidenceTexts: evidenceSpans.map((span) => span.quote).filter(Boolean), + path: itemPath, + requireEventFamily: [ + "company_overview", + "business_dynamics", + "recent_public_updates", + ].includes(key) || (key === "risk_attention" && !ANALYTICAL_RISK_TERMS.test(text)), + ignoredEntityNames, + })); + const requiresSubjectBoundary = [ + "company_overview", + "business_dynamics", + "recent_public_updates", + "risk_attention", + ].includes(key) + && validAtoms.some((atom) => atom.entity_match === "alias_scoped") + && !validAtoms.some((atom) => atom.entity_match === "verified") + && !SUBJECT_BOUNDARY_TERMS.test(text); + const displayText = requiresSubjectBoundary ? `公开信息显示,${text}` : text; + const identity = displayText + .toLowerCase() + .replace(/[\s,。;:!?、,.!?;:'"“”‘’()()【】[\]《》<>-]/gu, ""); + if (identity && seen.has(identity)) errors.push(`${title}包含与其他章节重复的规划内容`); + if (identity) seen.add(identity); + sections[key] = { + id: `${key}_1`, + text: displayText, + evidence_ids: validAtoms.map((atom) => String(atom.id)), + citation_ids: citationIds, + evidence_spans: evidenceSpans, + }; + }); + + const plannedCitationIds = SECTION_DEFINITIONS.flatMap(([key]) => ( + planItemsForSection({ sections }, key).flatMap((item) => item.citation_ids || []) + )); + errors.push(...dossierSourceUsageErrors( + plannedCitationIds, + citations, + sourceUsageRequirements, + "事实规划", + )); + + return { plan: { sections }, errors }; +} + +function evidenceIdCombinations(values = [], maxItems = MAX_EVIDENCE_IDS_PER_SECTION) { + const unique = [...new Set(values.map(String).filter(Boolean))]; + const combinations = []; + const visit = (start, selected) => { + if (selected.length) combinations.push([...selected]); + if (selected.length >= maxItems) return; + for (let index = start; index < unique.length; index += 1) { + selected.push(unique[index]); + visit(index + 1, selected); + selected.pop(); + } + }; + visit(0, []); + return combinations; +} + +function sectionPlanningErrors(errors = [], title = "") { + return errors.filter((error) => String(error || "").startsWith(title)); +} + +function reselectGroundingEvidence( + parsed = {}, + evidenceAtoms = [], + citations = [], + allowedEvidenceBySection = {}, + sourceUsageRequirements = {}, + ignoredEntityNames = [], +) { + const next = JSON.parse(JSON.stringify(parsed || {})); + const knownAtomIds = new Set(evidenceAtoms.map((atom) => String(atom?.id || "")).filter(Boolean)); + let evaluated = normalizedPlan( + next, + evidenceAtoms, + citations, + allowedEvidenceBySection, + sourceUsageRequirements, + ignoredEntityNames, + ); + let changed = 0; + + for (const [key, title] of SECTION_DEFINITIONS) { + const currentSectionErrors = sectionPlanningErrors(evaluated.errors, title); + if (!currentSectionErrors.some((error) => /未出现在证据片段中|缺少可核验的证据片段/u.test(error))) { + continue; + } + const allowedIds = Array.isArray(allowedEvidenceBySection?.[key]) + ? allowedEvidenceBySection[key] + : []; + const allowedSet = new Set(allowedIds.map(String)); + const currentEvidenceIds = Array.isArray(next?.sections?.[key]?.evidence_ids) + ? next.sections[key].evidence_ids.map(String).filter(Boolean) + : []; + if ( + !currentEvidenceIds.length + || currentEvidenceIds.length > MAX_EVIDENCE_IDS_PER_SECTION + || new Set(currentEvidenceIds).size !== currentEvidenceIds.length + || currentEvidenceIds.some((id) => !knownAtomIds.has(id) || !allowedSet.has(id)) + ) { + continue; + } + const candidates = evidenceIdCombinations(allowedIds); + if (!candidates.length || !next?.sections?.[key]) continue; + + let best = null; + for (const evidenceIds of candidates) { + const candidateParsed = JSON.parse(JSON.stringify(next)); + candidateParsed.sections[key].evidence_ids = evidenceIds; + const candidateEvaluation = normalizedPlan( + candidateParsed, + evidenceAtoms, + citations, + allowedEvidenceBySection, + sourceUsageRequirements, + ignoredEntityNames, + ); + const candidateSectionErrors = sectionPlanningErrors(candidateEvaluation.errors, title); + if (candidateSectionErrors.length >= currentSectionErrors.length) continue; + const candidateScore = ( + candidateSectionErrors.length * 10_000 + + candidateEvaluation.errors.length * 100 + + evidenceIds.length + ); + if (!best || candidateScore < best.score) { + best = { + score: candidateScore, + parsed: candidateParsed, + evaluated: candidateEvaluation, + }; + } + } + if (!best) continue; + Object.assign(next, best.parsed); + evaluated = best.evaluated; + changed += 1; + } + return { parsed: next, evaluated, changed }; +} + +function reduceUnsupportedDatePrecision(parsed = {}, errors = [], evidenceAtoms = []) { + const next = JSON.parse(JSON.stringify(parsed || {})); + const atomById = new Map(evidenceAtoms.map((item) => [String(item?.id || ""), item])); + let changed = 0; + for (const error of errors) { + const match = String(error || "").match( + /^(.+?)第 (\d+) 条中的日期 (20\d{2})-(\d{2})-(\d{2}) 未出现在证据片段中$/u, + ); + if (!match) continue; + const [, title, itemNumberRaw, year, monthRaw, dayRaw] = match; + const section = SECTION_DEFINITIONS.find(([, sectionTitle]) => sectionTitle === title); + if (!section) continue; + const [key] = section; + if (Number(itemNumberRaw) !== 1) continue; + const item = next?.sections?.[key]; + if (!item) continue; + const support = (Array.isArray(item.evidence_ids) ? item.evidence_ids : []) + .map((id) => atomById.get(String(id || ""))) + .map((atom) => normalizeClaimText(atom?.quote)) + .filter(Boolean) + .join(" "); + const month = Number(monthRaw); + const day = Number(dayRaw); + const supportsMonthDay = [ + `${month}月${day}日`, + `${String(month).padStart(2, "0")}月${String(day).padStart(2, "0")}日`, + `${month}-${day}`, + `${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`, + `${month}/${day}`, + `${String(month).padStart(2, "0")}/${String(day).padStart(2, "0")}`, + ].some((value) => support.includes(value)); + if (!supportsMonthDay) continue; + const original = normalizeClaimText(item.text); + const replacement = `${month}月${day}日`; + const chineseDate = new RegExp(`${year}年0?${month}月0?${day}日`, "gu"); + const numericDate = new RegExp(`${year}[-/.]0?${month}[-/.]0?${day}`, "gu"); + const repaired = original.replace(chineseDate, replacement).replace(numericDate, replacement); + if (repaired === original) continue; + item.text = repaired; + changed += 1; + } + return { parsed: next, changed }; +} + +function generalizeUnsupportedActionAcronyms(parsed = {}, errors = []) { + const next = JSON.parse(JSON.stringify(parsed || {})); + const action = next?.sections?.recommended_actions; + if (!action || typeof action !== "object") return { parsed: next, changed: 0 }; + let changed = 0; + for (const error of errors) { + const match = String(error || "").match( + /^建议行动第 (\d+) 条中的实体 ([A-Z][A-Z0-9-]{2,}) 未出现在证据片段中$/u, + ); + if (!match || Number(match[1]) !== 1) continue; + const token = match[2].replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const original = normalizeClaimText(action.text); + const repaired = original + .replace(new RegExp(`\\b${token}\\b`, "gu"), "相关业务") + .replace(/相关业务业务/gu, "相关业务"); + if (repaired === original) continue; + action.text = repaired; + changed += 1; + } + return { parsed: next, changed }; +} + +function generalizeUnsupportedAnalyticalEvents(parsed = {}, errors = []) { + const next = JSON.parse(JSON.stringify(parsed || {})); + const allowedSections = new Set(["risk_attention", "sales_opportunity", "recommended_actions"]); + const replacements = new Map([ + ["合作", "对接"], + ["交付", "项目推进"], + ["签约", "事项确认"], + ["合同", "商务事项"], + ["部署", "应用"], + ["上线", "应用"], + ["落地", "实施"], + ]); + let changed = 0; + for (const error of errors) { + const match = String(error || "").match( + /^(.+?)第 (\d+) 条中的事件表述“([^”]+)”未出现在证据片段中$/u, + ); + if (!match || Number(match[2]) !== 1) continue; + const section = SECTION_DEFINITIONS.find(([, title]) => title === match[1]); + const replacement = replacements.get(match[3]); + if (!section || !replacement || !allowedSections.has(section[0])) continue; + const item = next?.sections?.[section[0]]; + if (!item || typeof item !== "object") continue; + const original = normalizeClaimText(item.text); + if (section[0] === "risk_attention" && !ANALYTICAL_RISK_TERMS.test(original)) continue; + const repaired = original + .split(match[3]).join(replacement) + .replace(/对接对接/gu, "对接") + .replace(/应用应用/gu, "应用") + .replace(/实施实施/gu, "实施"); + if (repaired === original) continue; + item.text = repaired; + changed += 1; + } + return { parsed: next, changed }; +} + +function boundedCompleteText(values = [], maxLength = 160) { + const candidates = values.map(normalizeClaimText).filter(Boolean); + let result = ""; + for (const candidate of candidates) { + const next = result ? `${result} ${candidate}` : candidate; + if (next.length <= maxLength) { + result = next; + continue; + } + if (result) break; + const slice = candidate.slice(0, Math.max(1, maxLength - 1)).trimEnd(); + const boundaries = ["。", "!", "?", ";", ","] + .map((mark) => slice.lastIndexOf(mark)); + const boundary = Math.max(...boundaries); + if (boundary >= 12) { + result = slice.slice(0, boundary + 1).replace(/[,;]$/u, "。"); + } else { + result = `${slice.replace(/[,;:、\s]+$/u, "")}。`; + } + break; + } + return result; +} + +function compiledDossierStructureErrors(submission = {}, { requirePlanItemIds = true } = {}) { + const errors = []; + const body = Array.isArray(submission?.body) ? submission.body : []; + if (body.length !== SECTION_DEFINITIONS.length) { + errors.push(`档案必须完整保留 ${SECTION_DEFINITIONS.length} 个固定章节`); + } + SECTION_DEFINITIONS.forEach(([, title], index) => { + const section = body[index] || {}; + const segments = Array.isArray(section.segments) ? section.segments : []; + const citationIds = Array.isArray(section.citation_ids) ? section.citation_ids : []; + if (!String(section.text || "").startsWith(`${title}:`)) { + errors.push(`${title}缺少固定章节标题`); + } + if (!segments.length) errors.push(`${title}缺少完整正文`); + if (!citationIds.length) errors.push(`${title}缺少可核验引用`); + if (/暂无|资料不足|未检索到|没有返回/u.test(String(section.text || ""))) { + errors.push(`${title}不能使用缺省占位内容代替正常正文`); + } + segments.forEach((segment, segmentIndex) => { + const path = `${title}第 ${segmentIndex + 1} 段`; + if (!normalizeClaimText(segment?.text)) errors.push(`${path}内容为空`); + if (!/[。!?]$/u.test(normalizeClaimText(segment?.text))) { + errors.push(`${path}不是完整句子`); + } + if ( + requirePlanItemIds + && (!Array.isArray(segment?.plan_item_ids) || !segment.plan_item_ids.length) + ) { + errors.push(`${path}缺少事实规划关联`); + } + if (!Array.isArray(segment?.citation_ids) || !segment.citation_ids.length) { + errors.push(`${path}缺少可核验引用`); + } + }); + }); + if (String(submission?.summary || "").length > OUTPUT_BUDGET.summary_max_chars) { + errors.push(`档案摘要超过 ${OUTPUT_BUDGET.summary_max_chars} 个字符`); + } + if (String(submission?.memory_summary || "").length > OUTPUT_BUDGET.memory_summary_max_chars) { + errors.push(`记忆摘要超过 ${OUTPUT_BUDGET.memory_summary_max_chars} 个字符`); + } + return [...new Set(errors)]; +} + +/** + * Compile the approved evidence plan into the public six-section dossier + * without another stochastic model-writing pass. Every successful dossier + * therefore keeps the fixed chapter contract and derives citations only from + * the plan items that already passed grounding checks. + */ +export function compileDossierFromPlan(plan = {}) { + const body = SECTION_DEFINITIONS.map(([key, title]) => { + const segments = planItemsForSection(plan, key).map((item) => ({ + text: normalizeClaimText(item?.text), + plan_item_ids: [String(item?.id || "")].filter(Boolean), + citation_ids: [...new Set( + (Array.isArray(item?.citation_ids) ? item.citation_ids : []).map(String).filter(Boolean), + )], + })); + return { + text: `${title}:${segments.map((segment) => segment.text).join("\n\n")}`, + citation_ids: [...new Set(segments.flatMap((segment) => segment.citation_ids))], + segments, + }; + }); + const recentAndOpportunity = [ + ...planItemsForSection(plan, "recent_public_updates"), + ...planItemsForSection(plan, "sales_opportunity"), + ].map((item) => item.text); + const memoryCandidates = [ + ...planItemsForSection(plan, "company_overview"), + ...planItemsForSection(plan, "recent_public_updates"), + ...planItemsForSection(plan, "sales_opportunity"), + ].map((item) => item.text); + const submission = { + summary: boundedCompleteText(recentAndOpportunity, OUTPUT_BUDGET.summary_max_chars), + body, + memory_summary: boundedCompleteText(memoryCandidates, OUTPUT_BUDGET.memory_summary_max_chars), + }; + return { + submission, + errors: compiledDossierStructureErrors(submission), + }; +} + +function shouldRetryCall(result) { + if (result?.ok) return false; + return Boolean(result?.error?.retryable) || [ + "incomplete_response", + "invalid_function_arguments", + "missing_function_call", + "unexpected_function_call", + ].includes(String(result?.error?.code || "")); +} + +function planningRepairDirectives(errors = []) { + const grouped = new Map(); + for (const error of errors) { + const raw = String(error || ""); + const itemMatch = raw.match(/^(.+?)第 (\d+) 条/u); + if (!itemMatch) continue; + const [, section, itemNumberRaw] = itemMatch; + const key = `${section}:${itemNumberRaw}`; + const existing = grouped.get(key) || { + section, + item_number: Number(itemNumberRaw), + unsupported_event_terms: [], + unsupported_numbers: [], + unsupported_dates: [], + unsupported_entities: [], + unsupported_organizations: [], + requires_supported_organization: false, + instruction: "删除不受支持的值或断言,或改选 quote 中逐字包含该值且直接支撑正文的 Evidence Atom;不得近似、补全、改写后保留或虚构替代值。", + }; + + const captures = [ + ["unsupported_event_terms", raw.match(/中的事件表述“([^”]+)”未出现在证据片段中$/u)?.[1]], + ["unsupported_numbers", raw.match(/中的数值 (.+?) 未出现在证据片段中$/u)?.[1]], + ["unsupported_dates", raw.match(/中的日期 (.+?) 未出现在证据片段中$/u)?.[1]], + ["unsupported_entities", raw.match(/中的实体 (.+?) 未出现在证据片段中$/u)?.[1]], + ["unsupported_organizations", raw.match(/中的机构名称“([^”]+)”未出现在证据片段中$/u)?.[1]], + ]; + let recognized = false; + for (const [field, value] of captures) { + if (!value) continue; + recognized = true; + if (!existing[field].includes(value)) existing[field].push(value); + } + if (/中的机构名称未出现在证据片段中$/u.test(raw)) { + existing.requires_supported_organization = true; + recognized = true; + } + if (!recognized) continue; + grouped.set(key, existing); + } + return [...grouped.values()].slice(0, SECTION_DEFINITIONS.length); +} + +function planningRepairSectionKeys(errors = []) { + const keys = new Set( + SECTION_DEFINITIONS + .filter(([, title]) => errors.some((error) => String(error || "").includes(title))) + .map(([key]) => key), + ); + for (const error of errors) { + const index = Number(String(error || "").match(/^body\[(\d+)\]/u)?.[1]); + if (Number.isInteger(index) && SECTION_DEFINITIONS[index]) { + keys.add(SECTION_DEFINITIONS[index][0]); + } + } + return keys.size ? [...keys] : SECTION_DEFINITIONS.map(([key]) => key); +} + +function planningFacingValidationErrors(errors = []) { + return errors.map((error) => { + const value = String(error || ""); + const segmentMatch = value.match(/^body\[(\d+)\]\.segments\[(\d+)\](.*)$/u); + if (segmentMatch) { + const section = SECTION_DEFINITIONS[Number(segmentMatch[1])]; + if (section) return `${section[1]}第 ${Number(segmentMatch[2]) + 1} 条${segmentMatch[3]}`; + } + const sectionMatch = value.match(/^body\[(\d+)\](.*)$/u); + if (sectionMatch) { + const section = SECTION_DEFINITIONS[Number(sectionMatch[1])]; + if (section) return `${section[1]}${sectionMatch[2]}`; + } + return value; + }); +} + +function planningRepairPreviousPlan(previousPlan = {}, sectionKeys = []) { + const sanitized = JSON.parse(JSON.stringify(previousPlan || {})); + if (!sanitized.sections || typeof sanitized.sections !== "object") sanitized.sections = {}; + for (const key of sectionKeys) { + sanitized.sections[key] = { + text: "", + evidence_ids: [], + }; + } + return sanitized; +} + +function planningForbiddenGroundingValues(directives = []) { + return [...new Set(directives.flatMap((directive) => [ + ...(directive.unsupported_event_terms || []), + ...(directive.unsupported_numbers || []), + ...(directive.unsupported_dates || []), + ...(directive.unsupported_entities || []), + ...(directive.unsupported_organizations || []), + ]).map(String).filter(Boolean))].slice(0, 24); +} + +function mergePlanningRepair(previousPlan = {}, repair = {}, sectionKeys = []) { + const merged = JSON.parse(JSON.stringify(previousPlan || {})); + if (!merged.sections || typeof merged.sections !== "object") merged.sections = {}; + for (const key of sectionKeys) { + const section = repair?.sections?.[key]; + if (section && typeof section === "object") merged.sections[key] = section; + } + return merged; +} + +export class DossierAgent { + constructor({ callModel, validate, maxCalls = 3 }) { + this.callModel = callModel; + this.validate = validate; + this.maxCalls = Math.max(1, Math.min(Number(maxCalls) || 3, 3)); + } + + async run(input = {}) { + const context = buildDossierAgentContext(input); + const evidenceIdsBySection = Object.fromEntries( + SECTION_DEFINITIONS.map(([key]) => [ + key, + (context.evidenceBySection[key] || []).map((atom) => String(atom.id)), + ]), + ); + const coverageErrors = SECTION_DEFINITIONS.flatMap(([key, title]) => ( + evidenceIdsBySection[key].length + ? [] + : [`${title}证据覆盖不足,缺少可用于本章节的 Evidence Atom`] + )); + if (coverageErrors.length) { + return { + ok: false, + stage: "evidence_coverage", + result: null, + context_metrics: context.metrics, + validation_errors: coverageErrors, + }; + } + let callCount = 0; + let planErrors = []; + let lastResult = null; + let planningAttempts = 0; + let previousPlanSubmission = null; + let failureStage = "planning"; + const ignoredEntityNames = [ + input.company?.name, + input.company?.legal_name, + ].filter(Boolean); + + const maxPlanningAttempts = this.maxCalls; + while (planningAttempts < maxPlanningAttempts && callCount < this.maxCalls) { + const revisingPlan = previousPlanSubmission !== null; + const repairDirectives = revisingPlan ? planningRepairDirectives(planErrors) : []; + const repairSectionKeys = revisingPlan + ? planningRepairSectionKeys(planErrors) + : SECTION_DEFINITIONS.map(([key]) => key); + const repairPreviousPlan = revisingPlan + ? planningRepairPreviousPlan(previousPlanSubmission, repairSectionKeys) + : null; + const forbiddenGroundingValues = revisingPlan + ? planningForbiddenGroundingValues(repairDirectives) + : []; + const planParameters = buildDossierPlanSchema(evidenceIdsBySection, repairSectionKeys); + planningAttempts += 1; + callCount += 1; + lastResult = await this.callModel({ + attempt: callCount, + operation: revisingPlan ? "sales_dossier_agent_replan" : "sales_dossier_agent_plan", + system: [ + ...input.instructions, + `你必须调用 ${PLAN_FUNCTION_NAME},一次提交六个章节可直接展示的正文与 Evidence ID,不能输出普通文本。`, + "固定六个章节必须全部保留且每章恰好提交 1 个完整段落,任何章节都不能删除、留空或用“暂无”“资料不足”等占位句代替。", + "每章 text 必须是可以直接进入报告正文的完整段落;可以包含 1-3 个紧密相关的完整句子,但只能围绕本章一个主要主题。建议行动章必须写清动作、对象和待核验事项。", + "采用紧凑规划:六章合计只提交 6 个段落,不得把同一事实拆成多个条目,也不得为凑长度添加弱相关内容。", + "每章只能返回 text 和 evidence_ids。不得输出 quote、citation_id、URL、引用位置、Evidence Atom 原文副本或其他字段。", + "evidence_ids 必须来自本章 allowed_evidence,且只选择直接支撑正文的最少 Atom。quote、citation_id、segment 和最终引用全部由服务端从 Atom 确定性派生。", + "allowed_evidence 的 selection_scope=section_candidate 表示证据直接匹配本章;selection_scope=cross_section_grounding 表示仅可基于已核验主体或经营事实作保守分析和核验建议,不得扩写成来源没有陈述的近期事件、风险事实、采购意向、预算或客户需求。", + "source_usage_requirements 只描述当前可用来源,不设置全局引用数量门槛。每条内容只使用直接支撑它的最少来源,把专业来源和公开来源分配到最匹配的章节,不得为覆盖数量加入弱相关引用。", + "正文中出现的完整日期、数值、机构和事件必须逐项出现在所选 Evidence Atom 的 quote 中;若 Atom 只有月日而没有年份,不得在正文补全年份。", + "事实章节必须沿用 Atom quote 中已经出现的事件关系词;不得把“入选、候选、公示、采购”改写或升级成“合作、签约、合同、交付、部署、上线、落地、发布产品”等更强关系。", + "信息量由证据决定:不得因为企业规模、章节字数或 Schema 上限而添加无来源内容,也不得用通用套话凑数量。", + "销售机会判断和建议行动只能由所选 Atom 中的事实直接推出,不能把销售建议写成客户已经存在的需求或预算。", + "同一事实只能规划到最匹配的一个章节。搜索标题、问句、关键词列表和检索状态都不是事实。", + ...(revisingPlan ? [ + "上一版规划或确定性组装结果没有通过质量门禁;previous_plan 保留其他已合格章节,但被点名章节的旧正文和证据 ID 已由服务端清空,防止复制已知错误。只重写 planning_errors 点名章节,不能新增证据外事实。", + "repair_directives 是必须逐项满足的修订合同。不得原样保留任何 unsupported_event_terms、unsupported_numbers、unsupported_dates、unsupported_entities 或 unsupported_organizations;只有重新选择的 Evidence Atom quote 确实逐字包含该值并直接支撑正文时,才允许继续使用。", + "forbidden_grounding_values 是上一版未获所选证据支持的值;本次重写不得再次输出这些值。若 allowed_evidence 中确有该值,也必须选择包含它的 Evidence ID 后才能使用。", + "本次只提交 repair_section_keys 指定的章节补丁,不得重复输出其他章节。服务端会把补丁与 previous_plan 的其余已合格章节确定性合并后重新执行完整六章门禁。", + "错误点名机构名称时,只能使用所选 Atom quote 中逐字出现的完整机构名称;找不到完整名称就删除该机构和对应断言,不得使用简称、补全名称或近义实体。", + "planning_errors 若指出高风险事实缺少双来源或关键数字未获得双来源一致支持,必须删除该高风险事实和数字,改写本章其他可由单个 Atom 直接支持的普通事实;不得只更换 Evidence ID 后保留原断言。", + "修订时仍必须保留六个完整章节;不能通过删除章节、清空章节或改写成缺省占位句来规避错误。", + ] : []), + ].join("\n"), + payload: { + task: revisingPlan ? "修订企业销售档案章节正文" : "生成企业销售档案章节正文", + company: input.company, + evidence_by_section: Object.fromEntries(repairSectionKeys.map((key) => [ + key, + { + title: SECTION_DEFINITIONS.find(([candidate]) => candidate === key)?.[1] || key, + coverage_status: context.evidenceCoverage[key]?.status || "missing", + coverage_reasons: context.evidenceCoverage[key]?.reasons || [], + allowed_evidence: (context.evidenceBySection[key] || []).map(compactEvidenceAtom), + }, + ])), + evidence_policy: context.evidencePolicy, + evidence_conflicts: context.evidenceConflicts, + source_selection_policy: context.sourceSelectionPolicy, + source_usage_requirements: context.sourceUsageRequirements, + ...(revisingPlan ? { + previous_plan: repairPreviousPlan, + planning_errors: planErrors, + repair_directives: repairDirectives, + forbidden_grounding_values: forbiddenGroundingValues, + repair_section_keys: repairSectionKeys, + } : {}), + }, + functionName: PLAN_FUNCTION_NAME, + functionDescription: "提交固定六章的可展示正文和每章使用的 Evidence Atom ID。", + parameters: planParameters, + maxTokens: 2400, + }); + if (!lastResult?.ok) { + planErrors = [`事实规划提交失败:${lastResult?.error?.code || "provider_error"}`]; + if ( + planningAttempts < maxPlanningAttempts + && callCount < this.maxCalls + && shouldRetryCall(lastResult) + ) continue; + return { + ok: false, + stage: "planning", + result: lastResult, + context_metrics: context.metrics, + validation_errors: planErrors, + }; + } + previousPlanSubmission = revisingPlan + ? mergePlanningRepair(previousPlanSubmission, lastResult.parsed, repairSectionKeys) + : lastResult.parsed; + let planned = normalizedPlan( + previousPlanSubmission, + input.evidenceAtoms, + input.citations, + evidenceIdsBySection, + context.sourceUsageRequirements, + ignoredEntityNames, + ); + const evidenceReselected = reselectGroundingEvidence( + previousPlanSubmission, + input.evidenceAtoms, + input.citations, + evidenceIdsBySection, + context.sourceUsageRequirements, + ignoredEntityNames, + ); + if (evidenceReselected.changed > 0) { + previousPlanSubmission = evidenceReselected.parsed; + planned = evidenceReselected.evaluated; + } + const dateReduced = reduceUnsupportedDatePrecision( + previousPlanSubmission, + planned.errors, + input.evidenceAtoms, + ); + if (dateReduced.changed > 0) { + previousPlanSubmission = dateReduced.parsed; + planned = normalizedPlan( + previousPlanSubmission, + input.evidenceAtoms, + input.citations, + evidenceIdsBySection, + context.sourceUsageRequirements, + ignoredEntityNames, + ); + } + const actionGeneralized = generalizeUnsupportedActionAcronyms( + previousPlanSubmission, + planned.errors, + ); + if (actionGeneralized.changed > 0) { + previousPlanSubmission = actionGeneralized.parsed; + planned = normalizedPlan( + previousPlanSubmission, + input.evidenceAtoms, + input.citations, + evidenceIdsBySection, + context.sourceUsageRequirements, + ignoredEntityNames, + ); + } + const analyticalEventGeneralized = generalizeUnsupportedAnalyticalEvents( + previousPlanSubmission, + planned.errors, + ); + if (analyticalEventGeneralized.changed > 0) { + const candidate = normalizedPlan( + analyticalEventGeneralized.parsed, + input.evidenceAtoms, + input.citations, + evidenceIdsBySection, + context.sourceUsageRequirements, + ignoredEntityNames, + ); + if (candidate.errors.length < planned.errors.length) { + previousPlanSubmission = analyticalEventGeneralized.parsed; + planned = candidate; + } + } + planErrors = planned.errors; + if (planErrors.length) { + failureStage = "planning"; + continue; + } + const compiled = compileDossierFromPlan(planned.plan); + const validated = this.validate(compiled.submission); + const validationErrors = planningFacingValidationErrors([ + ...compiled.errors, + ...(validated.errors || []), + ...compiledDossierStructureErrors({ + ...compiled.submission, + body: validated.body, + }, { requirePlanItemIds: false }), + ]); + if (!validationErrors.length) { + return { + ok: true, + stage: "complete", + result: lastResult, + submission: { + ...compiled.submission, + body: validated.body, + }, + approved_plan: planned.plan, + context_metrics: context.metrics, + validation_errors: [], + }; + } + planErrors = [...new Set(validationErrors)]; + failureStage = "validation"; + } + + return { + ok: false, + stage: failureStage, + result: lastResult, + context_metrics: context.metrics, + validation_errors: planErrors, + }; + } +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/app.js b/demohouse/sales-intelligence-workbench/backend/src/app.js new file mode 100644 index 00000000..51189ab1 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/app.js @@ -0,0 +1,98 @@ +import http from "node:http"; +import { fileURLToPath } from "node:url"; +import { getProviderStatus } from "./config/providerConfig.js"; +import { createEnvReader } from "./config/runtimeEnv.js"; +import { createRuntimePolicy } from "./config/runtimePolicy.js"; +import { createWebSearchProvider } from "./providers/webSearchProvider.js"; +import { createModelProvider } from "./providers/modelProvider.js"; +import { createDataProProvider } from "./providers/dataProProvider.js"; +import { createOpenVikingProvider } from "./providers/openVikingProvider.js"; +import { createSupabaseDataProvider } from "./providers/supabaseDataProvider.js"; +import { SupabaseDataRepository } from "./repositories/supabaseDataRepository.js"; +import { AdminStatusService } from "./services/adminStatusService.js"; +import { ProviderService } from "./services/providerService.js"; +import { FeishuImportTaskService } from "./services/feishuImportTaskService.js"; +import { SalesService } from "./services/salesService.js"; +import { createRouter } from "./routes/index.js"; +import { createStaticFrontend } from "./frontend/staticFrontend.js"; +import { createAuthService } from "./security/authService.js"; +import { createRateLimiters } from "./security/rateLimiter.js"; + +const defaultFrontendDir = fileURLToPath(new URL("../../frontend/", import.meta.url)); + +export function createRuntimeContext(options = {}) { + const env = options.env || createEnvReader(); + const runtimePolicy = options.runtimePolicy || createRuntimePolicy({ env }); + const webSearchProvider = createWebSearchProvider({ env }); + const modelProvider = createModelProvider({ env }); + const dataProProvider = createDataProProvider({ env }); + const openVikingProvider = createOpenVikingProvider({ env }); + const supabaseDataProvider = createSupabaseDataProvider({ env }); + const providerStatus = () => getProviderStatus({ env, runtimePolicy }); + const providerService = new ProviderService({ + getProviderStatus: providerStatus, + webSearchProvider, + modelProvider, + dataProProvider, + openVikingProvider, + supabaseDataProvider, + }); + const salesRepository = supabaseDataProvider.isConfigured() + ? new SupabaseDataRepository({ + env, + supabaseDataProvider, + workspaceId: env.value("APP_WORKSPACE_ID"), + }) + : null; + const salesService = new SalesService({ env, runtimePolicy, dataProProvider, webSearchProvider, modelProvider, openVikingProvider, repository: salesRepository }); + const feishuImportTaskService = new FeishuImportTaskService({ + env, + runtimePolicy, + salesService, + }); + const adminStatusService = new AdminStatusService({ env, runtimePolicy, getProviderStatus: providerStatus }); + const authService = options.authService || createAuthService({ env, dataProvider: supabaseDataProvider }); + const rateLimiters = options.rateLimiters || createRateLimiters(env); + return { + env, + runtimePolicy, + providerStatus, + salesRepository, + providerService, + salesService, + feishuImportTaskService, + adminStatusService, + authService, + rateLimiters, + }; +} + +export function createApp(options = {}) { + const context = options.context || createRuntimeContext(options); + const { + env, + runtimePolicy, + providerService, + salesService, + feishuImportTaskService, + adminStatusService, + authService, + rateLimiters, + } = context; + const staticFrontend = createStaticFrontend({ + rootDir: env.value("FRONTEND_DIR", defaultFrontendDir), + }); + const router = createRouter(providerService, { + salesService, + feishuImportTaskService, + adminStatusService, + runtimePolicy, + staticFrontend, + authService, + rateLimiters, + env, + }); + const server = http.createServer(router); + server.runtimeContext = context; + return server; +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/backup/supabaseBackup.js b/demohouse/sales-intelligence-workbench/backend/src/backup/supabaseBackup.js new file mode 100644 index 00000000..951afa95 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/backup/supabaseBackup.js @@ -0,0 +1,100 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { resolve, sep } from "node:path"; + +export const BACKUP_FORMAT_VERSION = 1; + +export const WORKSPACE_TABLE_SPECS = [ + { table: "app_workspace_members", order: "user_id.asc", onConflict: "workspace_id,user_id", authBound: true }, + { table: "provider_connections", order: "id.asc", onConflict: "id" }, + { table: "sales_goals", order: "id.asc", onConflict: "id" }, + { table: "sales_companies", order: "id.asc", onConflict: "id" }, + { table: "jobs", order: "id.asc", onConflict: "id" }, + { table: "provider_runs", order: "id.asc", onConflict: "id" }, + { table: "provider_run_steps", order: "id.asc", onConflict: "id" }, + { table: "sales_target_enterprises", order: "id.asc", onConflict: "id" }, + { table: "sales_company_search_results", order: "id.asc", onConflict: "id" }, + { table: "sales_progress_snapshots", order: "id.asc", onConflict: "id" }, + { table: "sales_dossier_records", order: "id.asc", onConflict: "id" }, + { table: "sales_dossier_citations", order: "id.asc", onConflict: "id" }, + { table: "sales_materials", order: "id.asc", onConflict: "id" }, + { table: "sales_openviking_refs", order: "id.asc", onConflict: "id" }, + { table: "sync_sources", order: "id.asc", onConflict: "id" }, + { table: "sync_checkpoints", order: "id.asc", onConflict: "id" }, + { table: "audit_events", order: "id.asc", onConflict: "id" }, +]; + +export const RESTORE_ORDER = [ + "provider_connections", + "sales_goals", + "sales_companies", + "jobs", + "provider_runs", + "provider_run_steps", + "sales_target_enterprises", + "sales_company_search_results", + "sales_progress_snapshots", + "sales_dossier_records", + "sales_dossier_citations", + "sales_materials", + "sales_openviking_refs", + "sync_sources", + "sync_checkpoints", + "audit_events", +]; + +const USER_REFERENCE_FIELDS = ["created_by", "updated_by", "actor_user_id"]; + +export function sha256File(filePath) { + return createHash("sha256").update(readFileSync(filePath)).digest("hex"); +} + +export function prepareRowsForRestore(table, rows, targetWorkspaceId) { + if (["app_users", "app_workspace_members"].includes(table)) return []; + + return rows.map((sourceRow) => { + const row = structuredClone(sourceRow); + if (table === "app_workspaces") { + row.id = targetWorkspaceId; + } else if (Object.hasOwn(row, "workspace_id")) { + row.workspace_id = targetWorkspaceId; + } + + for (const field of USER_REFERENCE_FIELDS) { + if (Object.hasOwn(row, field)) row[field] = null; + } + if (table === "sales_companies") delete row.normalized_name; + if (table === "provider_connections") { + row.secret_ref = null; + row.status = "needs_reconfiguration"; + } + return row; + }); +} + +export function validateBackupPackage(backupDir, manifest, data) { + if (manifest.format_version !== BACKUP_FORMAT_VERSION || data.format_version !== BACKUP_FORMAT_VERSION) { + throw new Error(`Unsupported backup format. Expected version ${BACKUP_FORMAT_VERSION}.`); + } + if (manifest.backup_id !== data.backup_id) throw new Error("Backup manifest and data identifiers do not match."); + + for (const [table, expected] of Object.entries(manifest.row_counts || {})) { + const actual = Array.isArray(data.tables?.[table]) ? data.tables[table].length : -1; + if (actual !== expected) throw new Error(`Backup row count mismatch for ${table}: expected ${expected}, got ${actual}.`); + } + + const root = resolve(backupDir); + for (const file of manifest.files || []) { + const filePath = resolve(root, file.path); + if (filePath !== root && !filePath.startsWith(`${root}${sep}`)) { + throw new Error(`Backup manifest contains an unsafe path: ${file.path}.`); + } + const actualHash = sha256File(filePath); + if (actualHash !== file.sha256) throw new Error(`Backup checksum mismatch for ${file.path}.`); + } + return true; +} + +export function tableSpec(table) { + return WORKSPACE_TABLE_SPECS.find((entry) => entry.table === table) || null; +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/config/providerConfig.js b/demohouse/sales-intelligence-workbench/backend/src/config/providerConfig.js new file mode 100644 index 00000000..5c7cbadb --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/config/providerConfig.js @@ -0,0 +1,191 @@ +import { existsSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { createEnvReader } from "./runtimeEnv.js"; +import { createRuntimePolicy, publicRuntimePolicy } from "./runtimePolicy.js"; + +const DEFAULTS = { + DATAPRO_MCP_URL: "https://datapro.hqd.cn-beijing.volces.com/mcp", + WEB_SEARCH_BASE_URL: "https://open.feedcoopapi.com/search_api/web_search", + OPENVIKING_AGENT_ID: "default", + SUPABASE_REGION: "cn-beijing", +}; + +function unique(values) { + return [...new Set(values)]; +} + +function commandExists(command) { + const value = String(command || "").trim(); + if (!value) return false; + if (value.includes("/")) return existsSync(value); + return String(process.env.PATH || "") + .split(delimiter) + .filter(Boolean) + .some((directory) => existsSync(join(directory, value))); +} + +function configStatus(env, requiredEnv, acceptedEnv = requiredEnv) { + if (!requiredEnv.length) return "ready"; + return requiredEnv.every((name) => env.hasAny(Array.isArray(name) ? name : [name])) ? "configured" : "missing_config"; +} + +function missingGroups(env, requiredEnv) { + return requiredEnv + .filter((name) => !env.hasAny(Array.isArray(name) ? name : [name])) + .map((name) => (Array.isArray(name) ? name.join(" or ") : name)); +} + +function provider(id, label, options) { + const { + status, + mode, + role, + required_env = [], + optional_env = [], + configured_from = [], + missing = [], + notes = [], + safe_config = {}, + } = options; + return { + id, + label, + status, + mode, + role, + required_env, + optional_env, + configured_from: unique(configured_from), + missing, + notes, + safe_config, + }; +} + +export function getProviderStatus(options = {}) { + const env = options.env || createEnvReader(); + const runtimePolicy = options.runtimePolicy || createRuntimePolicy({ env }); + const repositoryMode = env.value("REPOSITORY_MODE") || "supabase"; + + const webSearchRequired = [["WEB_SEARCH_API_KEY", "AGENT_PLAN_API_KEY", "ASK_ECHO_SEARCH_INFINITY_API_KEY"]]; + const dataProRequired = [["DATAPRO_API_KEY", "AGENT_PLAN_API_KEY"]]; + const supabaseRequired = ["SUPABASE_API_URL", "SUPABASE_SERVICE_ROLE_KEY", "APP_WORKSPACE_ID"]; + const supabaseAdminEnv = ["VOLCENGINE_ACCESS_KEY", "VOLCENGINE_SECRET_KEY", "SUPABASE_WORKSPACE_ID", "SUPABASE_BRANCH_ID", "SUPABASE_CLI_BIN"]; + const openVikingCliConfigPath = env.value("OPENVIKING_CLI_CONFIG") + || (process.env.HOME ? join(process.env.HOME, ".openviking", "ovcli.conf") : ""); + const openVikingConfigExists = Boolean(openVikingCliConfigPath && existsSync(openVikingCliConfigPath)); + const openVikingCliPath = env.value("OPENVIKING_CLI") || (process.env.HOME ? join(process.env.HOME, "bin", "ov") : "ov"); + const openVikingCliExists = commandExists(openVikingCliPath); + const openVikingHttpConfigured = env.hasAny(["OPENVIKING_API_KEY", "OPENVIKING_BEARER_TOKEN"]) + && env.hasAny(["OPENVIKING_BASE_URL"]); + const openVikingConfigured = openVikingHttpConfigured || openVikingConfigExists || openVikingCliExists; + const modelRequired = [["ARK_API_KEY", "VOLCENGINE_ARK_API_KEY", "MODEL_API_KEY", "AGENT_PLAN_API_KEY"]]; + + const providers = [ + provider("web_search", "联网搜索 Provider", { + status: configStatus(env, webSearchRequired), + mode: "real", + role: "公开来源发现:新闻、官网、文档、价格页、发布记录", + required_env: ["AGENT_PLAN_API_KEY(WEB_SEARCH_API_KEY 可作为高级覆盖)"], + optional_env: ["WEB_SEARCH_BASE_URL", "WEB_SEARCH_TRAFFIC_TAG", "WEB_SEARCH_MAX_COUNT", "WEB_SEARCH_RUN_ENABLED", "WEB_SEARCH_TIMEOUT_MS", "WEB_SEARCH_MAX_RETRIES"], + configured_from: env.sources(["WEB_SEARCH_API_KEY", "AGENT_PLAN_API_KEY", "ASK_ECHO_SEARCH_INFINITY_API_KEY", "WEB_SEARCH_BASE_URL", "WEB_SEARCH_TRAFFIC_TAG", "WEB_SEARCH_MAX_COUNT", "WEB_SEARCH_RUN_ENABLED", "WEB_SEARCH_TIMEOUT_MS", "WEB_SEARCH_MAX_RETRIES"]), + missing: missingGroups(env, webSearchRequired), + notes: ["状态接口只检查配置,不发起搜索请求。", "主流程调用由 WEB_SEARCH_RUN_ENABLED 控制,避免日常测试消耗额度。"], + safe_config: { + base_url: env.value("WEB_SEARCH_BASE_URL") || DEFAULTS.WEB_SEARCH_BASE_URL, + traffic_tag: env.value("WEB_SEARCH_TRAFFIC_TAG", "skill_web_search_common"), + max_count: env.number("WEB_SEARCH_MAX_COUNT", 3), + run_enabled: ["1", "true", "yes", "on"].includes(String(env.value("WEB_SEARCH_RUN_ENABLED", "false")).toLowerCase()), + timeout_ms: env.number("WEB_SEARCH_TIMEOUT_MS", 20000), + max_retries: env.number("WEB_SEARCH_MAX_RETRIES", 1), + }, + }), + provider("datapro", "DataPro Provider", { + status: configStatus(env, dataProRequired), + mode: "real", + role: "企业主体、工商事实、风险和知识产权数据核验", + required_env: ["DATAPRO_API_KEY or AGENT_PLAN_API_KEY"], + optional_env: ["DATAPRO_MCP_URL", "DATAPRO_RUN_ENABLED", "DATAPRO_MAX_SOURCES", "DATAPRO_TIMEOUT_MS", "DATAPRO_MAX_RETRIES"], + configured_from: env.sources(["DATAPRO_API_KEY", "AGENT_PLAN_API_KEY", "DATAPRO_MCP_URL", "DATAPRO_RUN_ENABLED", "DATAPRO_MAX_SOURCES", "DATAPRO_TIMEOUT_MS", "DATAPRO_MAX_RETRIES"]), + missing: missingGroups(env, dataProRequired), + notes: ["状态接口只检查配置,不调用 dataPro_search。", "主流程调用由 DATAPRO_RUN_ENABLED 控制,真实查询会消耗 AFP。"], + safe_config: { + mcp_url: env.value("DATAPRO_MCP_URL") || DEFAULTS.DATAPRO_MCP_URL, + run_enabled: ["1", "true", "yes", "on"].includes(String(env.value("DATAPRO_RUN_ENABLED", "false")).toLowerCase()), + max_sources: env.number("DATAPRO_MAX_SOURCES", 4), + timeout_ms: env.number("DATAPRO_TIMEOUT_MS", 45000), + max_retries: env.number("DATAPRO_MAX_RETRIES", 1), + }, + }), + provider("supabase", "Supabase Repository / Provider", { + status: configStatus(env, supabaseRequired), + mode: "real", + role: "业务状态持久化、SQL、Storage、Edge Functions 管理", + required_env: supabaseRequired, + optional_env: [...supabaseAdminEnv, "SUPABASE_READ_ONLY", "SUPABASE_RUN_ENABLED", "SUPABASE_TIMEOUT_MS", "SUPABASE_DATA_API_TIMEOUT_MS", "REPOSITORY_MODE"], + configured_from: env.sources([...supabaseRequired, ...supabaseAdminEnv, "SUPABASE_READ_ONLY", "SUPABASE_RUN_ENABLED", "SUPABASE_TIMEOUT_MS", "SUPABASE_DATA_API_TIMEOUT_MS", "REPOSITORY_MODE"]), + missing: missingGroups(env, supabaseRequired), + notes: ["状态接口不返回 Supabase API keys。", "销售工作台运行时使用 Data API;CLI 凭据仅用于迁移、备份和管理。"], + safe_config: { + workspace_id: env.value("SUPABASE_WORKSPACE_ID") || null, + branch_id: env.value("SUPABASE_BRANCH_ID") || null, + read_only: env.value("SUPABASE_READ_ONLY") || null, + region: env.value("VOLCENGINE_REGION") || DEFAULTS.SUPABASE_REGION, + run_enabled: ["1", "true", "yes", "on"].includes(String(env.value("SUPABASE_RUN_ENABLED", "false")).toLowerCase()), + app_workspace_id: env.value("APP_WORKSPACE_ID") || null, + cli_bin: env.value("SUPABASE_CLI_BIN") || "byted-supabase-cli", + data_api_timeout_ms: env.number("SUPABASE_DATA_API_TIMEOUT_MS", 15000), + }, + }), + provider("openviking", "OpenViking Provider", { + status: openVikingConfigured ? "configured" : "missing_config", + mode: "real", + role: "飞书资料正文、资料问答 Session、长期记忆与企业内资料召回", + required_env: ["OpenViking CLI 配置(默认 ~/.openviking/ovcli.conf),或 OPENVIKING_BASE_URL + OPENVIKING_API_KEY"], + optional_env: ["OPENVIKING_CLI", "OPENVIKING_CLI_CONFIG", "OPENVIKING_AGENT_ID", "OPENVIKING_RUN_ENABLED", "OPENVIKING_SALES_ROOT_URI", "OPENVIKING_FIND_LIMIT", "OPENVIKING_TIMEOUT_MS"], + configured_from: openVikingConfigured ? unique([...env.sources(["OPENVIKING_API_KEY", "OPENVIKING_BEARER_TOKEN", "OPENVIKING_BASE_URL", "OPENVIKING_CLI", "OPENVIKING_CLI_CONFIG", "OPENVIKING_AGENT_ID", "OPENVIKING_RUN_ENABLED", "OPENVIKING_SALES_ROOT_URI", "OPENVIKING_FIND_LIMIT", "OPENVIKING_TIMEOUT_MS"]), openVikingConfigExists ? "local_openviking_cli_config" : null, openVikingCliExists ? "local_openviking_cli" : null].filter(Boolean)) : [], + missing: openVikingConfigured ? [] : ["OpenViking CLI 配置,或 OPENVIKING_BASE_URL + OPENVIKING_API_KEY"], + notes: ["Agent Plan 套餐控制 AFP 抵扣,Agent 记忆(OpenViking)数据面仍使用内部访问凭证认证。", "状态接口不写入资料或会话。", "OpenViking 不承担企业、档案、任务和权限数据库角色。", "飞书正文与资料问答记忆写入由 OPENVIKING_RUN_ENABLED 控制。"], + safe_config: { + cli_path: openVikingCliPath, + agent_id: env.value("OPENVIKING_AGENT_ID") || DEFAULTS.OPENVIKING_AGENT_ID, + run_enabled: ["1", "true", "yes", "on"].includes(String(env.value("OPENVIKING_RUN_ENABLED", "false")).toLowerCase()), + sales_root_uri: env.value("OPENVIKING_SALES_ROOT_URI") || "viking://resources/sales-workbench", + find_limit: env.number("OPENVIKING_FIND_LIMIT", 3), + timeout_ms: env.number("OPENVIKING_TIMEOUT_MS", 120000), + }, + }), + provider("model", "Model Provider", { + status: configStatus(env, modelRequired), + mode: "real", + role: "基于 sources / facts 生成结构化变化卡、报告和问答", + required_env: ["AGENT_PLAN_API_KEY(MODEL_API_KEY 可作为高级覆盖)"], + optional_env: ["MODEL_NAME", "MODEL_BASE_URL", "MODEL_RUN_ENABLED", "MODEL_MAX_CARDS", "MODEL_MAX_TOKENS", "MODEL_TIMEOUT_MS"], + configured_from: env.sources(["ARK_API_KEY", "VOLCENGINE_ARK_API_KEY", "MODEL_API_KEY", "AGENT_PLAN_API_KEY", "MODEL_NAME", "MODEL_BASE_URL", "MODEL_RUN_ENABLED", "MODEL_MAX_CARDS", "MODEL_MAX_TOKENS", "MODEL_TIMEOUT_MS"]), + missing: missingGroups(env, modelRequired), + notes: ["模型输出必须经过后端 JSON 校验。", "主流程调用由 MODEL_RUN_ENABLED 控制,避免日常测试消耗额度。"], + safe_config: { + model_name: env.value("MODEL_NAME") || null, + base_url: env.value("MODEL_BASE_URL") || null, + run_enabled: ["1", "true", "yes", "on"].includes(String(env.value("MODEL_RUN_ENABLED", "false")).toLowerCase()), + max_cards: env.number("MODEL_MAX_CARDS", 2), + timeout_ms: env.number("MODEL_TIMEOUT_MS", 90000), + }, + }), + ]; + + return { + generated_at: new Date().toISOString(), + runtime: publicRuntimePolicy(runtimePolicy), + environment: { + local_env_loaded: env.hasLocalEnv, + local_env_path: "backend/.env.local", + }, + repository: { + active: repositoryMode, + status: configStatus(env, supabaseRequired), + notes: ["使用 Supabase Data API Repository,业务状态按 Workspace 读取并写回。"], + }, + providers, + }; +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/config/runtimeEnv.js b/demohouse/sales-intelligence-workbench/backend/src/config/runtimeEnv.js new file mode 100644 index 00000000..99ac4d4c --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/config/runtimeEnv.js @@ -0,0 +1,55 @@ +import { existsSync, readFileSync } from "node:fs"; + +export const localEnvUrl = new URL("../../.env.local", import.meta.url); + +function parseEnvValue(value) { + const trimmed = value.trim(); + if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +export function loadLocalEnv() { + if (!existsSync(localEnvUrl)) return {}; + const content = readFileSync(localEnvUrl, "utf8"); + const env = {}; + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const separatorIndex = line.indexOf("="); + if (separatorIndex < 1) continue; + const key = line.slice(0, separatorIndex).trim(); + const value = parseEnvValue(line.slice(separatorIndex + 1)); + env[key] = value; + } + return env; +} + +export function createEnvReader(localEnv = loadLocalEnv()) { + return { + hasLocalEnv: existsSync(localEnvUrl), + value(name, fallback = "") { + return process.env[name] || localEnv[name] || fallback; + }, + number(name, fallback) { + const value = Number(this.value(name)); + return Number.isFinite(value) ? value : fallback; + }, + source(name) { + if (process.env[name]) return "process.env"; + if (localEnv[name]) return "backend/.env.local"; + return null; + }, + sources(names) { + return names.map((name) => this.source(name)).filter(Boolean); + }, + hasAny(names) { + return names.some((name) => Boolean(this.value(name))); + }, + hasAll(names) { + return names.every((name) => Boolean(this.value(name))); + }, + }; +} + diff --git a/demohouse/sales-intelligence-workbench/backend/src/config/runtimePolicy.js b/demohouse/sales-intelligence-workbench/backend/src/config/runtimePolicy.js new file mode 100644 index 00000000..76e11acc --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/config/runtimePolicy.js @@ -0,0 +1,142 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { createEnvReader } from "./runtimeEnv.js"; + +const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); + +function isEnabled(value) { + return TRUE_VALUES.has(String(value || "").trim().toLowerCase()); +} + +function positiveInteger(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function validTimeZone(value) { + try { + new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date()); + return true; + } catch { + return false; + } +} + +function parseAbsoluteUrl(value) { + try { + return new URL(String(value || "").trim()); + } catch { + return null; + } +} + +function parseOrigins(value) { + return String(value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + .map(parseAbsoluteUrl); +} + +export function createRuntimePolicy(options = {}) { + const env = options.env || createEnvReader(); + const repositoryMode = String(env.value("REPOSITORY_MODE", "supabase")).trim().toLowerCase(); + const providerRuns = { + datapro: isEnabled(env.value("DATAPRO_RUN_ENABLED", "false")), + web_search: isEnabled(env.value("WEB_SEARCH_RUN_ENABLED", "false")), + model: isEnabled(env.value("MODEL_RUN_ENABLED", "false")), + openviking: isEnabled(env.value("OPENVIKING_RUN_ENABLED", "false")), + }; + const blockers = []; + const httpAuthEnabled = isEnabled(env.value("HTTP_AUTH_ENABLED", "true")); + const paidWorkflowLimits = Object.freeze({ + max_concurrent: positiveInteger(env.value("PAID_WORKFLOW_MAX_CONCURRENCY", "2"), 0), + daily_limit: positiveInteger(env.value("PAID_WORKFLOW_DAILY_LIMIT", "100"), 0), + timezone: String(env.value("PAID_WORKFLOW_BUDGET_TIMEZONE", "Asia/Shanghai") || "").trim(), + stale_after_seconds: positiveInteger(env.value("PAID_WORKFLOW_STALE_AFTER_SECONDS", "1800"), 0), + }); + const asyncJobsEnabled = isEnabled(env.value("ASYNC_JOBS_ENABLED", "true")); + const providerCircuitBreaker = Object.freeze({ + enabled: isEnabled(env.value("PROVIDER_CIRCUIT_BREAKER_ENABLED", "true")), + failure_threshold: positiveInteger(env.value("PROVIDER_CIRCUIT_BREAKER_FAILURE_THRESHOLD", "5"), 0), + cooldown_seconds: positiveInteger(env.value("PROVIDER_CIRCUIT_BREAKER_COOLDOWN_SECONDS", "60"), 0), + }); + + if (repositoryMode !== "supabase") blockers.push("REPOSITORY_MODE must be supabase"); + if (isEnabled(env.value("SUPABASE_READ_ONLY", "false"))) blockers.push("SUPABASE_READ_ONLY must be false"); + if (!env.hasAll(["SUPABASE_API_URL", "SUPABASE_SERVICE_ROLE_KEY", "APP_WORKSPACE_ID"])) { + blockers.push("Supabase Data API configuration is incomplete"); + } + if (!httpAuthEnabled) blockers.push("HTTP_AUTH_ENABLED must be true"); + if (!paidWorkflowLimits.max_concurrent) blockers.push("PAID_WORKFLOW_MAX_CONCURRENCY must be greater than 0"); + if (!paidWorkflowLimits.daily_limit) blockers.push("PAID_WORKFLOW_DAILY_LIMIT must be greater than 0"); + if (!paidWorkflowLimits.stale_after_seconds) blockers.push("PAID_WORKFLOW_STALE_AFTER_SECONDS must be greater than 0"); + if (!asyncJobsEnabled) blockers.push("ASYNC_JOBS_ENABLED must be true"); + if (!providerCircuitBreaker.enabled) blockers.push("PROVIDER_CIRCUIT_BREAKER_ENABLED must be true"); + if (!providerCircuitBreaker.failure_threshold) { + blockers.push("PROVIDER_CIRCUIT_BREAKER_FAILURE_THRESHOLD must be greater than 0"); + } + if (!providerCircuitBreaker.cooldown_seconds) { + blockers.push("PROVIDER_CIRCUIT_BREAKER_COOLDOWN_SECONDS must be greater than 0"); + } + if (positiveInteger(env.value("JOB_WORKER_LEASE_SECONDS", "600"), 0) < 60) { + blockers.push("JOB_WORKER_LEASE_SECONDS must be at least 60"); + } + if (!validTimeZone(paidWorkflowLimits.timezone)) blockers.push("PAID_WORKFLOW_BUDGET_TIMEZONE is invalid"); + + const host = String(env.value("HOST", "127.0.0.1")).trim().toLowerCase(); + const loopbackOnly = ["127.0.0.1", "::1", "localhost"].includes(host); + const trustProxy = isEnabled(env.value("TRUST_PROXY", "false")); + const secureCookie = isEnabled(env.value("AUTH_COOKIE_SECURE", "false")); + if ((!loopbackOnly || trustProxy) && !secureCookie) { + blockers.push("public or proxied deployments require AUTH_COOKIE_SECURE=true"); + } + if (trustProxy) { + const allowedOrigins = parseOrigins(env.value("ALLOWED_ORIGINS", "")); + if (!allowedOrigins.length || allowedOrigins.some((origin) => !origin || origin.protocol !== "https:")) { + blockers.push("proxied deployments require explicit HTTPS ALLOWED_ORIGINS"); + } + } + + if (!env.hasAny(["DATAPRO_API_KEY", "AGENT_PLAN_API_KEY"]) || !providerRuns.datapro) { + blockers.push("an enabled DataPro provider is required"); + } + if (!env.hasAny(["WEB_SEARCH_API_KEY", "AGENT_PLAN_API_KEY", "ASK_ECHO_SEARCH_INFINITY_API_KEY"]) || !providerRuns.web_search) { + blockers.push("an enabled web search provider is required"); + } + if (!env.hasAny(["MODEL_API_KEY", "AGENT_PLAN_API_KEY", "ARK_API_KEY", "VOLCENGINE_ARK_API_KEY"]) || !providerRuns.model) { + blockers.push("an enabled model provider is required"); + } + const openVikingCli = env.value("OPENVIKING_CLI") || (process.env.HOME ? join(process.env.HOME, "bin", "ov") : ""); + const openVikingCliConfig = env.value("OPENVIKING_CLI_CONFIG") + || (process.env.HOME ? join(process.env.HOME, ".openviking", "ovcli.conf") : ""); + const openVikingConfigured = ( + env.hasAny(["OPENVIKING_API_KEY", "OPENVIKING_BEARER_TOKEN"]) + && env.hasAny(["OPENVIKING_BASE_URL"]) + ) + || Boolean(openVikingCliConfig && existsSync(openVikingCliConfig)) + || Boolean(env.value("OPENVIKING_CLI") && openVikingCli && existsSync(openVikingCli)); + if (!openVikingConfigured || !providerRuns.openviking) { + blockers.push("an enabled OpenViking provider is required"); + } + return Object.freeze({ + fail_closed: true, + repository_mode: repositoryMode, + provider_runs: Object.freeze(providerRuns), + paid_workflow_limits: paidWorkflowLimits, + provider_circuit_breaker: providerCircuitBreaker, + async_jobs_enabled: asyncJobsEnabled, + http_auth_enabled: httpAuthEnabled, + blockers: Object.freeze(blockers), + ready: blockers.length === 0, + }); +} + +export function publicRuntimePolicy(policy) { + return { + ready: policy.ready, + fail_closed: true, + repository_mode: policy.repository_mode, + blockers: [...policy.blockers], + }; +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/evidence/claimGrounding.js b/demohouse/sales-intelligence-workbench/backend/src/evidence/claimGrounding.js new file mode 100644 index 00000000..70e47f62 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/evidence/claimGrounding.js @@ -0,0 +1,270 @@ +const EVENT_FAMILIES = Object.freeze([ + ["procurement", /中标|招标|采购|成交|候选|公示|入选|供应商/u], + ["cooperation", /合作|签署|协议|合同|战略伙伴/u], + ["delivery", /部署|上线|交付|投产|量产|扩产|建设|落地/u], + ["product", /发布|推出|升级|更新|研发|产品|解决方案/u], + ["finance", /融资|投资|回购|营收|收入|利润|估值/u], + ["risk", /处罚|诉讼|失信|异常|召回|事故|整改|监管/u], +]); + +const COMMON_UPPERCASE_TOKENS = new Set([ + "AI", + "API", + "B2B", + "CRM", + "ERP", + "HTTP", + "HTTPS", + "IT", + "RAG", + "SaaS", + "SQL", +]); + +function compact(value, maxLength = 4000) { + return String(value || "") + .normalize("NFKC") + .replace(/[\u0000-\u001F\u007F]/gu, " ") + .replace(/\s+/gu, " ") + .trim() + .slice(0, maxLength); +} + +function comparable(value) { + return compact(value, 8000) + .toLowerCase() + .replace(/\s+/gu, ""); +} + +function validCalendarDate(year, month, day) { + const timestamp = Date.UTC(Number(year), Number(month) - 1, Number(day)); + if (!Number.isFinite(timestamp)) return ""; + const date = new Date(timestamp); + if ( + date.getUTCFullYear() !== Number(year) + || date.getUTCMonth() + 1 !== Number(month) + || date.getUTCDate() !== Number(day) + ) { + return ""; + } + return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +export function extractGroundingDates(value) { + const input = compact(value, 12000); + const dates = new Set(); + const patterns = [ + /(? date.split("-").map((part) => String(Number(part)))), + ); + const numbers = new Set(); + const matches = input.matchAll(/(?= 3 || hasMaterialUnit) { + if (!dates.has(String(Number(digits)))) numbers.add(normalized); + } + } + return [...numbers]; +} + +function extractUppercaseAnchors(value) { + return [...new Set( + compact(value, 12000) + .match(/\b[A-Z][A-Z0-9-]{2,}\b/gu) || [], + )].filter((token) => !COMMON_UPPERCASE_TOKENS.has(token)); +} + +function extractOrganizationAnchorGroups(value) { + const input = compact(value, 12000); + const groups = []; + const suffixPattern = /(银行|大学|学院|研究院|委员会|法院|交易所|政府|集团)/gu; + for (const match of input.matchAll(suffixPattern)) { + const suffix = match[0]; + const start = match.index || 0; + const prefix = input + .slice(Math.max(0, start - 10), start) + .match(/[\p{Script=Han}]{2,10}$/u)?.[0] || ""; + if (prefix.length < 2) continue; + if ( + /(?:可能|或将|预计|将|会|易|可|仍)?(?:受|受到|影响|面向|针对|涉及|属于|依赖于|服务于|联系|核验|确认|关注|评估|建议|跟进|通过|基于|来自|进入|覆盖|支持|帮助|推动)$/u.test(prefix) + || /^(?:相关|所属|目标|客户|企业|公司|业务|产业|上述)$/u.test(prefix) + ) { + continue; + } + const candidates = []; + for (let length = 2; length <= Math.min(prefix.length, 8); length += 1) { + candidates.push(`${prefix.slice(-length)}${suffix}`); + } + groups.push([...new Set(candidates)]); + } + return groups; +} + +function eventFamilies(value) { + const input = compact(value, 12000); + return EVENT_FAMILIES + .filter(([, pattern]) => pattern.test(input)) + .map(([name]) => name); +} + +export function extractGroundingOrganizations(value) { + return [...new Set( + extractOrganizationAnchorGroups(value) + .map((candidates) => candidates.at(-1)) + .filter(Boolean), + )]; +} + +export function extractGroundingEventFamilies(value) { + return eventFamilies(value); +} + +function eventFamilyTerm(value, family) { + const input = compact(value, 12000); + const entry = EVENT_FAMILIES.find(([name]) => name === family); + return entry ? input.match(entry[1])?.[0] || "" : ""; +} + +function withoutIgnoredEntityNames(value, entityNames = []) { + let output = compact(value, 12000); + const names = [...new Set(entityNames.map((item) => compact(item, 200)).filter(Boolean))] + .sort((left, right) => right.length - left.length); + for (const name of names) output = output.split(name).join(" "); + return output; +} + +function appearsInSupport(anchor, supportTexts) { + const normalized = comparable(anchor); + return Boolean(normalized && supportTexts.some((value) => comparable(value).includes(normalized))); +} + +function numericAppearsInSupport(anchor, supportTexts) { + const normalized = comparable(anchor).replace(/[,,]/gu, ""); + return Boolean(normalized && supportTexts.some((value) => ( + comparable(value).replace(/[,,]/gu, "").includes(normalized) + ))); +} + +/** + * Deterministic claim-level guardrail. + * + * It does not pretend to solve full natural-language entailment. Instead it + * blocks the highest-risk forms of unsupported expansion that can be checked + * without another model call: new dates, material numbers, named uppercase + * entities, organization names and event-family changes. + */ +export function groundedTextErrors({ + text, + evidenceTexts = [], + path = "内容", + requireEventFamily = false, + checkOrganizations = true, + ignoredEntityNames = [], +} = {}) { + const content = compact(text, 12000); + const support = evidenceTexts.map((item) => compact(item, 12000)).filter(Boolean); + const errors = []; + if (!content || !support.length) return [`${path}缺少可核验的证据片段`]; + + for (const date of extractGroundingDates(content)) { + if (!appearsInSupport(date, support)) { + const chineseDate = date.replace(/^(\d{4})-(\d{2})-(\d{2})$/u, (_, year, month, day) => ( + `${year}年${Number(month)}月${Number(day)}日` + )); + if (!appearsInSupport(chineseDate, support)) errors.push(`${path}中的日期 ${date} 未出现在证据片段中`); + } + } + for (const number of extractGroundingNumbers(content)) { + if (!numericAppearsInSupport(number, support)) { + errors.push(`${path}中的数值 ${number} 未出现在证据片段中`); + } + } + for (const token of extractUppercaseAnchors(content)) { + if (!appearsInSupport(token, support)) errors.push(`${path}中的实体 ${token} 未出现在证据片段中`); + } + if (checkOrganizations) { + for (const candidates of extractOrganizationAnchorGroups(content)) { + if (!candidates.some((candidate) => appearsInSupport(candidate, support))) { + const label = candidates[0] || ""; + errors.push(label + ? `${path}中的机构名称“${label}”未出现在证据片段中` + : `${path}中的机构名称未出现在证据片段中`); + } + } + } + if (requireEventFamily) { + const eventContent = withoutIgnoredEntityNames(content, ignoredEntityNames); + const eventSupport = support.map((item) => withoutIgnoredEntityNames(item, ignoredEntityNames)); + const requiredFamilies = eventFamilies(eventContent); + const supportedFamilies = new Set(eventSupport.flatMap(eventFamilies)); + for (const family of requiredFamilies) { + if (!supportedFamilies.has(family)) { + const term = eventFamilyTerm(eventContent, family); + errors.push(term + ? `${path}中的事件表述“${term}”未出现在证据片段中` + : `${path}包含证据片段未支持的事件类型`); + } + } + } + return [...new Set(errors)]; +} + +export function evidenceSpanErrors(span = {}, citation = {}, path = "证据片段") { + const quote = compact(span.quote, 500); + const summary = compact(citation.summary || citation.excerpt, 4000); + if (!quote) return [`${path}缺少原文摘录`]; + if (quote.length < 8) return [`${path}的原文摘录少于 8 个字符`]; + if (!summary || !comparable(summary).includes(comparable(quote))) { + return [`${path}不是对应来源摘要中的连续原文`]; + } + return []; +} + +function validIso(value) { + const raw = compact(value, 100); + if (!raw) return ""; + const timestamp = new Date(raw).getTime(); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : ""; +} + +export function deriveEvidenceDataAsOf(evidence = [], generatedAt = new Date().toISOString()) { + const generatedTimestamp = new Date(generatedAt).getTime(); + const upperBound = Number.isFinite(generatedTimestamp) + ? generatedTimestamp + 24 * 60 * 60 * 1000 + : Number.POSITIVE_INFINITY; + const candidates = []; + for (const item of evidence || []) { + candidates.push(validIso(item?.published_at), validIso(item?.source_updated_at)); + if (/^(?:public|联网搜索)$/u.test(String(item?.source_kind || ""))) { + for (const date of extractGroundingDates(item?.summary || item?.excerpt || "")) { + candidates.push(`${date}T00:00:00.000Z`); + } + } + } + return candidates + .filter(Boolean) + .filter((value) => new Date(value).getTime() <= upperBound) + .sort() + .at(-1) || null; +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/evidence/dossierEvidenceCompiler.js b/demohouse/sales-intelligence-workbench/backend/src/evidence/dossierEvidenceCompiler.js new file mode 100644 index 00000000..220d013a --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/evidence/dossierEvidenceCompiler.js @@ -0,0 +1,774 @@ +import { createHash } from "node:crypto"; + +import { + extractGroundingDates, + extractGroundingEventFamilies, + extractGroundingNumbers, + extractGroundingOrganizations, +} from "./claimGrounding.js"; + +const SECTION_KEYS = Object.freeze([ + "company_overview", + "business_dynamics", + "recent_public_updates", + "risk_attention", + "sales_opportunity", + "recommended_actions", +]); + +const ENTITY_MATCHES = new Set([ + "verified", + "alias_scoped", + "query_bound", + "company_scoped", + "unverified", +]); + +const MAX_ATOM_CHARS = 360; +const MIN_ATOM_CHARS = 8; + +const NAVIGATION_OR_STATUS_PATTERNS = Object.freeze([ + /^(?:首页|当前位置|导航|菜单|产品中心)(?:\s*[>›»/|~-]\s*.*)+$/iu, + /^(?:正在|开始)?搜索(?:中|相关结果)?|请稍候|加载更多|暂无结果|点击查看|查看更多|返回首页/iu, + /(?:人机验证|安全验证|访问验证|验证码页面|页面不存在|内容已下线)/iu, +]); + +const SENSITIVE_CONTENT_PATTERNS = Object.freeze([ + /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/iu, + /\b(?:api[_ -]?key|service[_ -]?role(?:[_ -]?key)?|access[_ -]?token|refresh[_ -]?token|password|cookie|authorization|client[_ -]?secret)\s*[:=]\s*[^\s,。;;]{8,}/iu, + /[A-Za-z]:\\Users\\[^\s"',。;;)]+/iu, + /\bviking:\/\/[^\s"',。;;)]+/iu, +]); + +const PREDICATE_PATTERN = /公司名称|统一社会信用代码|法定代表人|注册资本|成立日期|经营范围|主营|是|为|于|在|由|有|提供|负责|发布|推出|完成|启动|计划|确认|记录|入选|中标|招标|采购|成交|候选|公示|供应|合作|签署|协议|合同|交付|部署|上线|投产|量产|扩产|建设|落地|融资|投资|回购|营收|收入|利润|估值|处罚|诉讼|失信|异常|召回|事故|整改|监管|受到|存在|显示|披露|增长|下降|达到|进入|核验|说明|通过/iu; +const ENGLISH_PREDICATE_PATTERN = /\b(?:is|are|was|were|has|have|will|remains|released|announced|provides|reported)\b/iu; + +const PROTECTED_VALUE_PATTERNS = Object.freeze([ + /20\d{2}年\d{1,2}月\d{1,2}日/gu, + /\b20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}\b/gu, + /(?:人民币|美元)?\s*\d[\d,.]*(?:\.\d+)?\s*(?:%|%|亿元|万元|元|亿|万|MW|MWh|GWh|GW|kW|kWh|套|项|个|条|份|家|台|辆|人|股|吨|亩|平方米|座|次)/giu, + /[0-9A-Z]{18}/gu, + /[\p{Script=Han}A-Za-z0-9()()·]{2,40}(?:股份有限公司|有限责任公司|集团有限公司|有限公司)/gu, +]); + +function digest(value) { + return createHash("sha256").update(String(value || ""), "utf8").digest("hex"); +} + +function stringValue(value, maxLength = 12000) { + return String(value ?? "") + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/gu, " ") + .slice(0, maxLength); +} + +function normalizedText(value, maxLength = 12000) { + return stringValue(value, maxLength * 2) + .normalize("NFKC") + .replace(/\s+/gu, " ") + .trim() + .slice(0, maxLength); +} + +function uniqueSorted(values = []) { + return [...new Set(values.filter(Boolean).map(String))].sort((left, right) => ( + left.localeCompare(right, "zh-CN") + )); +} + +function unixPathIsInsideHttpUrl(input, pathIndex) { + const prefix = input.slice(0, pathIndex); + return /https?:\/\/[^\s,。!?;;()()"'<>]*$/iu.test(prefix); +} + +function containsLocalAbsolutePath(value) { + const input = stringValue(value, 20000); + for (const marker of ["/Users/", "/home/"]) { + let index = input.indexOf(marker); + while (index >= 0) { + if (!unixPathIsInsideHttpUrl(input, index)) return true; + index = input.indexOf(marker, index + marker.length); + } + } + return /[A-Za-z]:\\Users\\[^\s"',。;;)]+/iu.test(input); +} + +function hasSensitiveContent(value) { + const input = stringValue(value, 20000); + return containsLocalAbsolutePath(input) + || SENSITIVE_CONTENT_PATTERNS.some((pattern) => pattern.test(input)); +} + +function safeIdentifier(value) { + const input = normalizedText(value, 240); + if (!input || hasSensitiveContent(input)) return ""; + return /^[A-Za-z0-9_.:-]+$/u.test(input) ? input : ""; +} + +function safeTitle(value) { + const input = normalizedText(value, 240); + if (!input || hasSensitiveContent(input)) return ""; + return input; +} + +function normalizedIso(value) { + const input = normalizedText(value, 100); + if (!input) return null; + const timestamp = new Date(input).getTime(); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null; +} + +function safeUrl(value) { + const input = normalizedText(value, 1200); + if (!/^https?:\/\//iu.test(input)) return null; + try { + const url = new URL(input); + if (/^(?:localhost|127(?:\.\d{1,3}){3}|\[?::1\]?)$/iu.test(url.hostname)) return null; + url.username = ""; + url.password = ""; + url.hash = ""; + for (const key of [...url.searchParams.keys()]) { + if ( + /^(?:utm_.*|spm|from|source)$/iu.test(key) + || /(?:token|key|secret|signature|credential|auth)/iu.test(key) + ) { + url.searchParams.delete(key); + } + } + return url.toString().replace(/\/$/u, ""); + } catch { + return null; + } +} + +function safeHostname(value) { + const url = safeUrl(value); + if (!url) return ""; + try { + return new URL(url).hostname.toLowerCase().replace(/^www\./u, ""); + } catch { + return ""; + } +} + +function normalizedSourceKind(value) { + const input = normalizedText(value, 80).toLowerCase(); + if (input === "professional" || input.includes("专业数据")) return "professional"; + if (input === "public" || input.includes("联网搜索") || input.includes("公开")) return "public"; + if (input === "internal" || input.includes("内部") || input.includes("飞书")) return "internal"; + return "unknown"; +} + +function sourceType(item = {}, sourceKind = "unknown") { + const explicit = normalizedText(item.source_type, 80).toLowerCase(); + if (["datapro", "web", "internal"].includes(explicit)) return explicit; + if (sourceKind === "professional") return "datapro"; + if (sourceKind === "public") return "web"; + if (sourceKind === "internal") return "internal"; + return "unknown"; +} + +function reliability(item = {}, sourceKind = "unknown") { + const quality = normalizedText(item.source_quality, 80).toLowerCase(); + if (quality === "official" || item.official === true) return "primary"; + if (quality === "professional" || sourceKind === "professional") return "professional"; + if (quality === "internal" || sourceKind === "internal") return "internal"; + if (quality === "traceable" || sourceKind === "public") return "public"; + return "limited"; +} + +function sourceTextSelection(item = {}) { + const summary = stringValue(item.summary, 20000); + if (summary.trim()) return { field: "summary", text: summary }; + const excerpt = stringValue(item.excerpt, 20000); + if (excerpt.trim()) return { field: "excerpt", text: excerpt }; + return { field: null, text: "" }; +} + +function sourceHash(item = {}, sourceTextField = "", sourceText = "") { + return digest(JSON.stringify({ + citation_id: normalizedText(item.id || item.evidence_id, 240), + source_key: normalizedText(item.source_key, 500), + source_kind: normalizedSourceKind(item.source_kind || item.source_kind_label), + title: safeTitle(item.label || item.title), + url: safeUrl(item.url), + published_at: normalizedIso(item.published_at), + source_updated_at: normalizedIso(item.source_updated_at), + source_text_field: sourceTextField, + source_text: sourceText, + })); +} + +function sourceIndependenceHash(item = {}, citationId = "") { + const explicit = normalizedText(item.independence_key, 1000).toLowerCase(); + if (explicit) return digest(`explicit:${explicit}`); + const host = safeHostname(item.url); + if (host) return digest(`host:${host}`); + const sourceKey = normalizedText(item.source_key, 1000).toLowerCase(); + if (sourceKey) return digest(`source_key:${sourceKey}`); + return digest(`citation_id:${citationId}`); +} + +function trimRange(sourceText, start, end) { + let nextStart = start; + let nextEnd = end; + while (nextStart < nextEnd && /\s/u.test(sourceText[nextStart])) nextStart += 1; + while (nextEnd > nextStart && /\s/u.test(sourceText[nextEnd - 1])) nextEnd -= 1; + return nextStart < nextEnd ? { start: nextStart, end: nextEnd } : null; +} + +function isListMarkerPeriod(sourceText, lineStart, index) { + return /^\s*\d+\.$/u.test(sourceText.slice(lineStart, index + 1)); +} + +function naturalRanges(sourceText) { + const ranges = []; + let lineStart = 0; + while (lineStart <= sourceText.length) { + const newline = sourceText.indexOf("\n", lineStart); + const rawLineEnd = newline === -1 ? sourceText.length : newline; + const lineEnd = rawLineEnd > lineStart && sourceText[rawLineEnd - 1] === "\r" + ? rawLineEnd - 1 + : rawLineEnd; + let segmentStart = lineStart; + for (let index = lineStart; index < lineEnd; index += 1) { + const character = sourceText[index]; + const alwaysBoundary = /[。!?!?;;]/u.test(character); + const periodBoundary = character === "." + && !isListMarkerPeriod(sourceText, lineStart, index) + && (index + 1 === lineEnd || /\s/u.test(sourceText[index + 1])) + && !(/\d/u.test(sourceText[index - 1] || "") && /\d/u.test(sourceText[index + 1] || "")); + if (!alwaysBoundary && !periodBoundary) continue; + const range = trimRange(sourceText, segmentStart, index + 1); + if (range) ranges.push(range); + segmentStart = index + 1; + } + const remaining = trimRange(sourceText, segmentStart, lineEnd); + if (remaining) ranges.push(remaining); + if (newline === -1) break; + lineStart = newline + 1; + } + return ranges; +} + +function protectedRanges(sourceText, start, end) { + const input = sourceText.slice(start, end); + const ranges = []; + for (const pattern of PROTECTED_VALUE_PATTERNS) { + const expression = new RegExp(pattern.source, pattern.flags); + for (const match of input.matchAll(expression)) { + const matchStart = start + Number(match.index || 0); + ranges.push({ start: matchStart, end: matchStart + match[0].length }); + } + } + return ranges.sort((left, right) => left.start - right.start || left.end - right.end); +} + +function boundaryInsideProtectedRange(boundary, ranges) { + return ranges.find((range) => boundary > range.start && boundary < range.end) || null; +} + +function boundedRanges(sourceText, range) { + if (range.end - range.start <= MAX_ATOM_CHARS) return [range]; + const protectedValues = protectedRanges(sourceText, range.start, range.end); + const ranges = []; + let cursor = range.start; + while (range.end - cursor > MAX_ATOM_CHARS) { + const minimum = cursor + Math.floor(MAX_ATOM_CHARS * 0.55); + const target = cursor + MAX_ATOM_CHARS; + let boundary = -1; + for (let index = target; index >= minimum; index -= 1) { + if (/[,,、::\s]/u.test(sourceText[index - 1] || "")) { + boundary = index; + break; + } + } + if (boundary < 0) boundary = target; + const protectedValue = boundaryInsideProtectedRange(boundary, protectedValues); + if (protectedValue) boundary = protectedValue.end; + if (boundary <= cursor) boundary = Math.min(range.end, cursor + MAX_ATOM_CHARS); + const next = trimRange(sourceText, cursor, boundary); + if (next) ranges.push(next); + cursor = boundary; + while (cursor < range.end && /\s/u.test(sourceText[cursor])) cursor += 1; + } + const remaining = trimRange(sourceText, cursor, range.end); + if (remaining) ranges.push(remaining); + return ranges; +} + +function segmentRanges(sourceText) { + return naturalRanges(sourceText).flatMap((range) => { + const bounded = range.end - range.start > MAX_ATOM_CHARS; + return boundedRanges(sourceText, range).map((item) => ({ ...item, bounded })); + }); +} + +function rejectionReason(quote, { bounded = false } = {}) { + const normalized = normalizedText(quote, MAX_ATOM_CHARS * 2); + if (!normalized) return "empty_content"; + if (hasSensitiveContent(quote)) return "sensitive_content"; + if (NAVIGATION_OR_STATUS_PATTERNS.some((pattern) => pattern.test(normalized))) { + return "navigation_or_search_status"; + } + if (normalized.length < MIN_ATOM_CHARS) return "non_substantive_fragment"; + const hasPredicate = PREDICATE_PATTERN.test(normalized) + || ENGLISH_PREDICATE_PATTERN.test(normalized); + const hasGroundingSignal = extractGroundingDates(normalized).length > 0 + || extractGroundingNumbers(normalized).length > 0 + || extractGroundingEventFamilies(normalized).length > 0; + return hasPredicate || hasGroundingSignal || bounded ? "" : "non_substantive_fragment"; +} + +function entityAliases(entity = {}) { + const values = [ + entity.canonical_name, + ...(Array.isArray(entity.strict_aliases) ? entity.strict_aliases : []), + ...(Array.isArray(entity.contextual_aliases) ? entity.contextual_aliases : []), + ...(Array.isArray(entity.aliases) ? entity.aliases : []), + ]; + return uniqueSorted(values.map((value) => normalizedText(value, 200))) + .sort((left, right) => right.length - left.length || left.localeCompare(right, "zh-CN")); +} + +function extractedCompanyOrganizations(quote) { + const organizations = []; + const suffixExpression = /股份有限公司|有限责任公司|集团有限公司|有限公司/gu; + for (const suffixMatch of quote.matchAll(suffixExpression)) { + const suffixStart = Number(suffixMatch.index || 0); + const contextStart = Math.max(0, suffixStart - 40); + const context = quote.slice(contextStart, suffixStart); + const boundaries = [ + ...context.matchAll(/[,。!?;;、::\s]|关注|涉及|关联|关于|公示|披露|显示|入选|中标|处罚|诉讼|与|和|对|由|及/gu), + ]; + const boundary = boundaries.at(-1); + const prefixSource = boundary + ? context.slice(Number(boundary.index || 0) + boundary[0].length) + : context; + const prefix = prefixSource.match(/[\p{Script=Han}A-Za-z0-9()()·]{2,30}$/u)?.[0] || ""; + if (prefix) organizations.push(`${prefix}${suffixMatch[0]}`); + } + return organizations; +} + +function companyOrganizations(quote, entity = {}) { + const values = [...extractedCompanyOrganizations(quote)]; + const canonicalName = normalizedText(entity.canonical_name, 200); + if (canonicalName && normalizedText(quote, 2000).includes(canonicalName)) { + values.push(canonicalName); + } + return uniqueSorted(values); +} + +function riskSubjectStronglyAnchored(quote, entity = {}) { + const canonicalName = normalizedText(entity.canonical_name, 200); + const eventIndex = quote.search(/处罚|诉讼|失信|异常|召回|事故|整改|监管/iu); + if (!canonicalName || eventIndex < 0) return false; + const organizations = uniqueSorted([ + ...extractGroundingOrganizations(quote), + ...companyOrganizations(quote, entity), + ]); + const preceding = organizations + .map((organization) => ({ + organization, + index: quote.lastIndexOf(organization, eventIndex), + })) + .filter((item) => item.index >= 0) + .sort((left, right) => right.index - left.index); + return preceding[0]?.organization === canonicalName; +} + +function entityMetadata(item = {}, quote = "", entity = {}, eventFamilies = []) { + const normalizedQuote = normalizedText(quote, 2000); + const canonicalName = normalizedText(entity.canonical_name, 200); + const creditCode = normalizedText(entity?.identifiers?.unified_social_credit_code, 80); + const aliases = entityAliases(entity); + const anchors = []; + if (canonicalName && normalizedQuote.includes(canonicalName)) anchors.push(canonicalName); + if (creditCode && normalizedQuote.includes(creditCode)) anchors.push(creditCode); + for (const alias of aliases) { + if ( + alias + && alias !== canonicalName + && normalizedQuote.includes(alias) + && !anchors.includes(alias) + ) { + anchors.push(alias); + } + } + + const provided = ENTITY_MATCHES.has(String(item.entity_match)) + ? String(item.entity_match) + : "unverified"; + const strongAnchor = Boolean( + (canonicalName && normalizedQuote.includes(canonicalName)) + || (creditCode && normalizedQuote.includes(creditCode)) + ); + if (eventFamilies.includes("risk")) { + if (provided === "company_scoped") { + return { entity_match: "company_scoped", entity_anchors: anchors }; + } + return { + entity_match: strongAnchor && riskSubjectStronglyAnchored(quote, entity) + ? "verified" + : "unverified", + entity_anchors: anchors, + }; + } + if (strongAnchor) return { entity_match: "verified", entity_anchors: anchors }; + if (anchors.length) return { entity_match: "alias_scoped", entity_anchors: anchors }; + if (provided === "verified" && normalizedSourceKind(item.source_kind) === "professional") { + return { entity_match: "verified", entity_anchors: [] }; + } + return { entity_match: provided, entity_anchors: [] }; +} + +function sectionCandidates({ + quote, + sourceContext, + sourceKind, + dates, + eventFamilies, + conflictFields, +} = {}) { + const text = normalizedText(quote, 2000); + const context = normalizedText(sourceContext, 500); + const selected = new Set(); + const overview = /公司名称|统一社会信用代码|法定代表人|注册资本|成立日期|注册地址|经营范围|主营业务|主营|企业简介/iu.test(text) + || /企业工商数据库|工商信息|business/iu.test(context); + const business = /经营|业务|项目|产品|产能|供应链|招标|中标|采购|成交|合作|签署|合同|交付|部署|上线|发布|推出|营收|收入|利润|融资|投资|回购/iu.test(text) + || eventFamilies.some((family) => family !== "risk") + || /产品|项目|合作|交付|经营|业务|更新/iu.test(context); + const recent = sourceKind === "public" + && ( + dates.length > 0 + || eventFamilies.length > 0 + || /近日|近期|公告|动态|进展/iu.test(text) + || /公告|动态|更新|进展/iu.test(context) + ); + const risk = eventFamilies.includes("risk") + || eventFamilies.includes("delivery") + || conflictFields.length > 0 + || /企业风险数据库|风险数据|风险记录|risk/iu.test(context) + || /风险|处罚|诉讼|失信|异常|召回|事故|整改|监管|争议/iu.test(text); + + if (overview) selected.add("company_overview"); + if (business) selected.add("business_dynamics"); + if (recent) selected.add("recent_public_updates"); + if (risk) selected.add("risk_attention"); + if (business && !risk) selected.add("sales_opportunity"); + if (business || risk || overview) selected.add("recommended_actions"); + + if (!selected.size && sourceKind === "professional") selected.add("company_overview"); + if (!selected.size && sourceKind === "public") selected.add("recent_public_updates"); + + return SECTION_KEYS.filter((key) => selected.has(key)); +} + +function atomScore({ + entityMatch, + reliabilityLabel, + url, + dates, + numbers, + organizations, + eventFamilies, + conflictFields, +} = {}) { + const entityScores = { + verified: 35, + company_scoped: 24, + alias_scoped: 18, + query_bound: 12, + unverified: -15, + }; + const reliabilityScores = { + primary: 30, + professional: 28, + internal: 20, + public: 18, + limited: 6, + }; + const value = Number(entityScores[entityMatch] || 0) + + Number(reliabilityScores[reliabilityLabel] || 0) + + (url ? 5 : 0) + + Math.min(8, dates.length * 4) + + Math.min(8, numbers.length * 2) + + Math.min(6, organizations.length * 2) + + Math.min(8, eventFamilies.length * 4) + - Math.min(12, conflictFields.length * 6); + return Math.max(0, Math.min(100, value)); +} + +function candidateOrder(left, right) { + return Number(right.score || 0) - Number(left.score || 0) + || String(left.source_hash).localeCompare(String(right.source_hash)) + || Number(left.quote_start || 0) - Number(right.quote_start || 0) + || String(left.id).localeCompare(String(right.id)); +} + +function rejectedOrder(left, right) { + return String(left.source_hash || "").localeCompare(String(right.source_hash || "")) + || Number(left.quote_start ?? -1) - Number(right.quote_start ?? -1) + || String(left.reason || "").localeCompare(String(right.reason || "")) + || String(left.citation_id || "").localeCompare(String(right.citation_id || "")); +} + +function diagnosticOrder(left, right) { + return String(left.code || "").localeCompare(String(right.code || "")) + || String(left.field || "").localeCompare(String(right.field || "")) + || String(left.citation_id || "").localeCompare(String(right.citation_id || "")) + || String(left.atom_id || "").localeCompare(String(right.atom_id || "")); +} + +function compileSource(item = {}, packEntity = {}) { + const citationId = safeIdentifier(item.id || item.evidence_id); + const selectedText = sourceTextSelection(item); + const computedSourceHash = sourceHash(item, selectedText.field || "", selectedText.text); + const rejected = []; + const diagnostics = []; + if (!citationId) { + rejected.push({ + citation_id: null, + source_hash: computedSourceHash, + source_text_field: selectedText.field, + quote_start: null, + quote_end: null, + reason: "unsafe_citation_id", + }); + return { candidates: [], rejected, diagnostics }; + } + if (!selectedText.field) { + rejected.push({ + citation_id: citationId, + source_hash: computedSourceHash, + source_text_field: null, + quote_start: null, + quote_end: null, + reason: "missing_source_text", + }); + return { candidates: [], rejected, diagnostics }; + } + + const sourceKind = normalizedSourceKind(item.source_kind || item.source_kind_label); + const sourceTypeValue = sourceType(item, sourceKind); + const reliabilityLabel = reliability(item, sourceKind); + const title = safeTitle(item.label || item.title); + const url = safeUrl(item.url); + const publishedAt = normalizedIso(item.published_at); + const sourceUpdatedAt = normalizedIso(item.source_updated_at); + const independenceHash = sourceIndependenceHash(item, citationId); + const conflictFields = uniqueSorted( + Array.isArray(item.conflict_fields) ? item.conflict_fields.map((field) => ( + safeIdentifier(field) + )) : [], + ); + const candidates = []; + + for (const range of segmentRanges(selectedText.text)) { + const quote = selectedText.text.slice(range.start, range.end); + const reason = rejectionReason(quote, range); + if (reason) { + rejected.push({ + citation_id: citationId, + source_hash: computedSourceHash, + source_text_field: selectedText.field, + quote_start: range.start, + quote_end: range.end, + reason, + }); + continue; + } + + const dates = uniqueSorted(extractGroundingDates(quote)); + const numbers = uniqueSorted(extractGroundingNumbers(quote)); + const eventFamilies = uniqueSorted(extractGroundingEventFamilies(quote)); + const entityResult = entityMetadata(item, quote, packEntity, eventFamilies); + const organizations = uniqueSorted([ + ...extractGroundingOrganizations(quote), + ...companyOrganizations(quote, packEntity), + ]); + const sections = sectionCandidates({ + quote, + sourceContext: `${title} ${item.purpose || ""} ${item.source_group || ""}`, + sourceKind, + dates, + eventFamilies, + conflictFields, + }); + const score = atomScore({ + entityMatch: entityResult.entity_match, + reliabilityLabel, + url, + dates, + numbers, + organizations, + eventFamilies, + conflictFields, + }); + const atomHash = digest(JSON.stringify({ + citation_id: citationId, + source_hash: computedSourceHash, + independence_hash: independenceHash, + source_text_field: selectedText.field, + quote_start: range.start, + quote_end: range.end, + quote, + })); + const atom = { + id: `E_${atomHash.slice(0, 20)}`, + citation_id: citationId, + source_hash: computedSourceHash, + independence_hash: independenceHash, + source_kind: sourceKind, + source_type: sourceTypeValue, + title, + url, + published_at: publishedAt, + source_updated_at: sourceUpdatedAt, + source_text_field: selectedText.field, + quote, + quote_start: range.start, + quote_end: range.end, + normalized_text: normalizedText(quote, MAX_ATOM_CHARS * 2), + entity_match: entityResult.entity_match, + entity_anchors: uniqueSorted(entityResult.entity_anchors), + section_candidates: sections, + dates, + numbers, + organizations, + event_families: eventFamilies, + conflict_fields: conflictFields, + reliability: reliabilityLabel, + score, + }; + if ( + eventFamilies.includes("risk") + && !["verified", "company_scoped"].includes(entityResult.entity_match) + ) { + diagnostics.push({ + level: "warning", + code: "risk_subject_not_strongly_anchored", + citation_id: citationId, + atom_id: atom.id, + }); + } + candidates.push(atom); + } + + return { candidates, rejected, diagnostics }; +} + +function deduplicateCandidates(candidates = []) { + const accepted = []; + const rejected = []; + const byContent = new Map(); + for (const candidate of [...candidates].sort(candidateOrder)) { + const key = [ + normalizedText(candidate.normalized_text, MAX_ATOM_CHARS * 2).toLowerCase(), + candidate.independence_hash, + ].join("\n"); + const duplicate = byContent.get(key); + if (duplicate) { + rejected.push({ + citation_id: candidate.citation_id, + source_hash: candidate.source_hash, + source_text_field: candidate.source_text_field, + quote_start: candidate.quote_start, + quote_end: candidate.quote_end, + reason: "duplicate_content", + duplicate_of: duplicate.id, + }); + continue; + } + byContent.set(key, candidate); + accepted.push(candidate); + } + return { atoms: accepted.sort(candidateOrder), rejected }; +} + +function buildCoverage(atoms = []) { + return Object.fromEntries(SECTION_KEYS.map((section) => { + const candidates = atoms.filter((atom) => atom.section_candidates.includes(section)); + const strong = candidates.filter((atom) => { + if (section === "company_overview") return atom.entity_match === "verified"; + if (section === "risk_attention") { + return ["verified", "company_scoped"].includes(atom.entity_match); + } + return atom.entity_match !== "unverified"; + }); + if (strong.length) { + return [section, { + status: "supported", + atom_ids: strong.map((atom) => atom.id), + reasons: [], + }]; + } + if (candidates.length) { + return [section, { + status: "partial", + atom_ids: candidates.map((atom) => atom.id), + reasons: ["only_weak_entity_matches"], + }]; + } + return [section, { + status: "missing", + atom_ids: [], + reasons: ["no_relevant_atoms"], + }]; + })); +} + +function uniqueDiagnostics(diagnostics = []) { + const byIdentity = new Map(); + for (const diagnostic of diagnostics) { + const identity = JSON.stringify(diagnostic); + if (!byIdentity.has(identity)) byIdentity.set(identity, diagnostic); + } + return [...byIdentity.values()].sort(diagnosticOrder); +} + +export function compileDossierEvidenceAtoms({ evidencePack = {} } = {}) { + const pack = evidencePack && typeof evidencePack === "object" ? evidencePack : {}; + const entity = pack.entity && typeof pack.entity === "object" ? pack.entity : {}; + const items = Array.isArray(pack.items) ? pack.items : []; + const compiled = items.map((item) => compileSource(item, entity)); + const deduplicated = deduplicateCandidates(compiled.flatMap((entry) => entry.candidates)); + const atoms = deduplicated.atoms; + const coverage = buildCoverage(atoms); + const diagnostics = [ + ...compiled.flatMap((entry) => entry.diagnostics), + ...(Array.isArray(pack.conflicts) ? pack.conflicts : []) + .map((conflict) => safeIdentifier(conflict?.field)) + .filter(Boolean) + .map((field) => ({ + level: "warning", + code: "source_conflict", + field, + })), + ...atoms.flatMap((atom) => atom.conflict_fields.map((field) => ({ + level: "warning", + code: "source_conflict", + field, + citation_id: atom.citation_id, + atom_id: atom.id, + }))), + ...Object.entries(coverage) + .filter(([, value]) => value.status !== "supported") + .map(([section, value]) => ({ + level: "info", + code: "coverage_gap", + section, + status: value.status, + })), + ]; + + return { + atoms, + rejected: [ + ...compiled.flatMap((entry) => entry.rejected), + ...deduplicated.rejected, + ].sort(rejectedOrder), + coverage, + diagnostics: uniqueDiagnostics(diagnostics), + }; +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/evidence/salesEvidence.js b/demohouse/sales-intelligence-workbench/backend/src/evidence/salesEvidence.js new file mode 100644 index 00000000..9adc78ad --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/evidence/salesEvidence.js @@ -0,0 +1,1247 @@ +import { createHash } from "node:crypto"; +import { deriveEvidenceDataAsOf } from "./claimGrounding.js"; + +const COMPANY_SUFFIXES = [ + "股份有限公司", + "有限责任公司", + "集团有限公司", + "有限公司", + "集团", + "公司", +]; + +const DAY_MS = 24 * 60 * 60 * 1000; +const OFFICIAL_PUBLIC_HOSTS = [ + "gov.cn", + "sse.com.cn", + "szse.cn", + "hkexnews.hk", + "cninfo.com.cn", +]; +const NON_SUBSTANTIVE_PUBLIC_CONTENT_PATTERNS = [ + /for better experience.{0,80}(?:verification|verify)/i, + /(?:complete|pass).{0,40}(?:the )?verification process/i, + /(?:verify you are human|captcha|access denied|robot check|security check)/i, + /(?:请|需要).{0,16}(?:完成|通过).{0,12}(?:人机|安全|访问|滑动)?验证/u, + /(?:人机验证|安全验证|访问验证|滑动验证|验证码页面|页面不存在|内容已下线)/u, +]; +const QA_GAP_HEADING_PATTERN = /^(?:缺口|资料缺口|信息缺口|证据缺口|覆盖缺口)[::]/u; +const QA_GAP_REQUEST_PATTERN = /缺口|缺失|不足|未覆盖|还缺|需要补充|哪些资料没有/u; +const QA_RISK_HEADING_PATTERN = /^(?:风险|主要风险|关注事项)[::]/u; +const QA_ACTION_HEADING_PATTERN = /^(?:跟进行动|行动|建议|下一步)(?:[一二三四五六七八九十]|\d+)?[::]/u; +const CRITICAL_FACT_PATTERNS = [ + { field: "registered_capital", label: "注册资本", pattern: /注册资本(?:为|是|达到|约为|约|[::])?\s*([+-]?\d[\d,.]*(?:\.\d+)?\s*(?:亿|万)?\s*(?:人民币|美元|元))/gi }, + { field: "revenue", label: "营业收入", pattern: /(?:营业收入|营收)(?:为|达到|约为|约|[::])?\s*([+-]?\d[\d,.]*(?:\.\d+)?\s*(?:亿|万)?\s*(?:人民币|美元|元|%|%))/gi }, + { field: "net_profit", label: "净利润", pattern: /(?:净利润|净亏损)(?:为|达到|约为|约|[::])?\s*([+-]?\d[\d,.]*(?:\.\d+)?\s*(?:亿|万)?\s*(?:人民币|美元|元|%|%))/gi }, + { field: "financing", label: "融资金额", pattern: /(?:融资金额|完成融资|获融资)(?:为|达到|约为|约|[::])?\s*([+-]?\d[\d,.]*(?:\.\d+)?\s*(?:亿|万)?\s*(?:人民币|美元|元))/gi }, + { field: "valuation", label: "估值", pattern: /估值(?:为|达到|约为|约|[::])?\s*([+-]?\d[\d,.]*(?:\.\d+)?\s*(?:亿|万)?\s*(?:人民币|美元|元))/gi }, +]; +const DOSSIER_SECTION_TITLES = [ + "企业与业务概览", + "经营与业务动态", + "近期公开动态", + "风险与关注事项", + "销售机会判断", + "建议行动", +]; +const QA_INTENT_RULES = [ + { + id: "risk", + pattern: /风险|处罚|诉讼|失信|异常|隐患|合规|顾虑|阻碍|问题/, + terms: ["风险", "关注事项", "处罚", "诉讼", "失信", "异常", "合规", "顾虑"], + }, + { + id: "timeline", + pattern: /时间|日期|何时|什么时候|节点|计划|周期|进度|最近|最新|先后|历史/, + terms: ["时间", "日期", "节点", "计划", "进度", "近期", "历史"], + }, + { + id: "people", + pattern: /谁|负责人|联系人|决策人|部门|角色|对接人/, + terms: ["负责人", "联系人", "决策人", "部门", "角色", "对接"], + }, + { + id: "requirement", + pattern: /需求|痛点|关注|目标|场景|想要|希望|要求|预算/, + terms: ["需求", "痛点", "关注", "目标", "场景", "希望", "要求", "预算"], + }, + { + id: "action", + pattern: /下一步|怎么推进|如何推进|建议|行动|跟进|切入|机会/, + terms: ["下一步", "建议行动", "推进", "跟进", "切入", "销售机会"], + }, + { + id: "overview", + pattern: /总结|概括|整体|情况|介绍|是什么|(?:企业|公司|客户).{0,4}怎么样/, + terms: ["概览", "总结", "企业与业务概览", "经营与业务动态"], + }, +]; + +function text(value, maxLength = 12000) { + return String(value || "") + .normalize("NFKC") + .replace(/[\u0000-\u001F\u007F]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxLength); +} + +function dossierEvidenceText(value, maxLength = 1600) { + return text(value, maxLength * 2) + .replace(/<[^>]+>/g, " ") + .replace(/(?:查看详情|查看更多|点击查看|立即注册|免费查看|登录后查看)\s*>*/gu, " ") + .replace(/(?:案号|序号|操作)复制/gu, "$1") + .replace(/\bUntitled\b/giu, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxLength); +} + +function digest(value) { + return createHash("sha256").update(String(value || ""), "utf8").digest("hex"); +} + +function qaText(value, maxLength = 20000) { + return String(value || "") + .normalize("NFKC") + .replace(/\r\n?/g, "\n") + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, " ") + .replace(/[ \t]+/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim() + .slice(0, maxLength); +} + +function qaLexemes(value) { + const normalized = qaText(value, 12000).toLowerCase(); + const lexemes = new Set(normalized.match(/[a-z][a-z0-9._-]{1,}|[0-9][0-9.,%+-]*/g) || []); + for (const sequence of normalized.match(/[\p{Script=Han}]{2,}/gu) || []) { + const compact = sequence.slice(0, 80); + for (let size = 2; size <= Math.min(4, compact.length); size += 1) { + for (let index = 0; index <= compact.length - size; index += 1) { + lexemes.add(compact.slice(index, index + size)); + } + } + } + return lexemes; +} + +function qaLexicalSimilarity(queryLexemes, candidateValue) { + if (!queryLexemes.size) return 0; + const candidateLexemes = qaLexemes(candidateValue); + if (!candidateLexemes.size) return 0; + const overlap = [...queryLexemes].filter((term) => candidateLexemes.has(term)).length; + const cosine = overlap / Math.sqrt(queryLexemes.size * candidateLexemes.size); + const queryCoverage = overlap / queryLexemes.size; + return Math.min(1, cosine * 0.65 + queryCoverage * 0.35); +} + +function meaningfulQaSummary(value) { + const visibleText = qaText(value, 2400) + .replace(/<[^>]+>/g, " ") + .replace(/https?:\/\/\S+/gi, " ") + .replace(/[-|#*_`~=::/\\\s]+/g, ""); + return /[\p{L}\p{N}]{2,}/u.test(visibleText); +} + +function qaEnumerationKey(value) { + return qaText(value, 500) + .toLowerCase() + .replace(/<[^>]+>/g, "") + .replace(/[*_`~]/g, "") + .replace(/[^\p{L}\p{N}]+/gu, ""); +} + +function qaEnumerationAliases(label) { + const cleaned = qaText(label, 240) + .replace(/<[^>]+>/g, "") + .replace(/[*_`~]/g, "") + .trim(); + const base = cleaned.split(/[::]/, 1)[0].trim(); + const parts = base.split(/[\/+、]|(?:\s+(?:及|与)\s+)/).map((item) => item.trim()); + return [...new Set([cleaned, base, ...parts].map(qaEnumerationKey))] + .filter((item) => item.length >= 4); +} + +function qaTableRowLabels(value) { + const source = qaText(value, 6000); + const separatorCell = (value) => /^:?-{1,}:?$/.test(String(value || "").trim()); + const cleanCell = (value, maxLength = 900) => ( + qaText(value, maxLength) + .replace(/<[^>]+>/g, "") + .replace(/[*_`~]/g, "") + .trim() + ); + const rowSegments = source.split(/\|\s+\|/).map((item) => item.trim()).filter(Boolean); + const tables = []; + for (let index = 0; index < rowSegments.length; index += 1) { + const separatorCells = rowSegments[index].split("|").map((item) => cleanCell(item, 180)); + if ( + separatorCells.length < 2 + || !separatorCells.every(separatorCell) + ) { + continue; + } + const columnCount = separatorCells.length; + const labels = []; + for (let rowIndex = index + 1; rowIndex < rowSegments.length; rowIndex += 1) { + const cells = rowSegments[rowIndex].split("|").map((item, cellIndex) => cleanCell( + item, + cellIndex ? 900 : 180, + )); + if (cells.length < columnCount) break; + const row = cells.slice(0, columnCount); + if (row.every(separatorCell)) break; + const label = row[0] + .replace(/<[^>]+>/g, "") + .replace(/[*_`~]/g, "") + .trim(); + const description = row.slice(1).join(" "); + const key = qaEnumerationKey(label); + if ( + !key + || /^#{1,6}\s/.test(label) + || /^---/.test(label) + || separatorCell(label) + || separatorCell(description) + ) { + break; + } + if (!labels.some((item) => qaEnumerationKey(item) === key)) labels.push(label); + } + if (labels.length) tables.push(labels); + } + return tables.sort((left, right) => right.length - left.length)[0] || []; +} + +function qaEnumerationSubject(value) { + const source = qaText(value, 1200); + const afterCue = source.match( + /(?:哪些|有哪(?:些)?|列出|列举|逐项(?:说明)?|所有|全部|包括什么|包含什么|多少(?:项|种|个))\s*([^,。?!?;;\n]{2,40})/, + )?.[1]; + const beforeCue = source.match( + /([^,。?!?;;\n]{2,40}?)(?:有哪些|有哪(?:些)?|包括什么|包含什么)/, + )?.[1]; + return qaText(afterCue || beforeCue || source, 80) + .replace(/^(?:这份|该|当前|上述|文档|资料|明确|使用了?)+/g, "") + .replace(/(?:请|并请|需要).*/g, "") + .trim(); +} + +function splitQaChunks(value, maxChars = 1100) { + const input = qaText(value); + if (!input) return []; + const overlapChars = Math.max(80, Math.min(180, Math.floor(maxChars * 0.16))); + const blocks = input + .split(/\n{2,}|(?=^#{1,6}\s)/m) + .map((item) => item.trim()) + .filter(Boolean); + const chunks = []; + let headingContext = ""; + for (const rawBlock of blocks) { + const headingOnly = rawBlock.match(/^(#{1,6}\s+[^\n]+)$/); + if (headingOnly) { + headingContext = headingOnly[1].trim(); + continue; + } + const leadingHeading = rawBlock.match(/^(#{1,6}\s+[^\n]+)\n+([\s\S]+)$/); + const block = leadingHeading ? leadingHeading[2].trim() : rawBlock; + if (leadingHeading) headingContext = leadingHeading[1].trim(); + const contextualize = (chunk) => ( + headingContext && !chunk.startsWith(headingContext) + ? `${headingContext}\n${chunk}` + : chunk + ); + if (block.length <= maxChars) { + chunks.push(contextualize(block)); + continue; + } + const sentences = block.split(/(?<=[。!?!?;;])\s*/).filter(Boolean); + let current = ""; + for (const sentence of sentences.length ? sentences : [block]) { + if (current && current.length + sentence.length + 1 > maxChars) { + chunks.push(contextualize(current.trim())); + current = ""; + } + if (sentence.length > maxChars) { + if (current) chunks.push(contextualize(current.trim())); + current = ""; + const stride = Math.max(1, maxChars - overlapChars); + for (let index = 0; index < sentence.length; index += stride) { + chunks.push(contextualize(sentence.slice(index, index + maxChars).trim())); + if (index + maxChars >= sentence.length) break; + } + } else { + current = `${current}${current ? " " : ""}${sentence}`; + } + } + if (current) chunks.push(contextualize(current.trim())); + } + if (!chunks.length && headingContext) chunks.push(headingContext); + const merged = []; + for (const chunk of chunks.filter(Boolean)) { + const heading = chunk.match(/^(#{1,6}\s+[^\n]+)\n/)?.[1] || ""; + const previous = merged.at(-1) || ""; + if ( + heading + && previous.startsWith(`${heading}\n`) + && previous.length + chunk.length - heading.length <= maxChars + heading.length + 1 + ) { + merged[merged.length - 1] = `${previous}\n${chunk.slice(heading.length).trim()}`; + } else { + merged.push(chunk); + } + } + return merged; +} + +function qaChunkContextWindow(chunks, index, maxChars = 1600) { + const selected = [{ index, text: chunks[index] }].filter((item) => item.text); + let currentLength = selected[0]?.text.length || 0; + for (const neighborIndex of [index - 1, index + 1]) { + const neighbor = chunks[neighborIndex]; + if (!neighbor) continue; + if (currentLength + neighbor.length + 2 > maxChars) continue; + selected.push({ index: neighborIndex, text: neighbor }); + currentLength += neighbor.length + 2; + } + return selected + .sort((left, right) => left.index - right.index) + .map((item) => item.text) + .join("\n\n"); +} + +export function analyzeQaQuestion(question, conversationHistory = []) { + const rawQuestion = qaText(question, 1800); + const recentContext = (conversationHistory || []) + .slice(-2) + .map((message) => qaText(message?.text || message?.content, 500)) + .filter(Boolean) + .join(" "); + const resolvedQuestion = /^(?:那|那么|这个|它|其|上述|刚才)|(?:下一步|然后呢|还有呢)/.test(rawQuestion) + ? qaText(`${recentContext} ${rawQuestion}`, 2200) + : rawQuestion; + const intents = QA_INTENT_RULES.filter((rule) => rule.pattern.test(resolvedQuestion)).map((rule) => rule.id); + const subqueries = [...new Set( + resolvedQuestion + .split(/[??;;]|\s+(?:以及|并且|同时|另外)\s+|(?:还要|还想|还需要)/) + .map((item) => qaText(item, 500)) + .filter((item) => item.length >= 2), + )].slice(0, 3); + return { + original_question: rawQuestion, + resolved_question: resolvedQuestion, + intents: intents.length ? intents : ["fact"], + subqueries: subqueries.length ? subqueries : [resolvedQuestion], + }; +} + +function qaRetrievalContextIdentity(context = {}) { + const uri = qaText(context.uri, 1000) + .replace(/[?#].*$/, "") + .replace(/\/+$/, "") + .toLowerCase(); + if (uri) return `uri:${uri}`; + const materialId = qaText(context.material_id, 240); + if (materialId) return `material:${materialId}`; + return `content:${digest(`${context.title || ""}\n${context.abstract || context.summary || ""}`)}`; +} + +export function fuseQaRetrievalContexts( + queryResults = [], + { + maxContexts = 10, + maxPerMaterial = 2, + rrfK = 60, + } = {}, +) { + const fused = new Map(); + for (const [queryIndex, queryResult] of (queryResults || []).entries()) { + const query = qaText(queryResult?.query, 1800) || `query-${queryIndex + 1}`; + const seenInQuery = new Set(); + for (const [resultIndex, context] of (queryResult?.contexts || []).entries()) { + const identity = qaRetrievalContextIdentity(context); + if (seenInQuery.has(identity)) continue; + seenInQuery.add(identity); + const rank = resultIndex + 1; + const score = Number(context?.score); + const previous = fused.get(identity); + const entry = previous || { + ...context, + fusion_score: 0, + query_hits: 0, + matched_queries: [], + best_rank: rank, + best_provider_score: Number.isFinite(score) ? score : null, + }; + entry.fusion_score += 1 / (Math.max(1, Number(rrfK || 60)) + rank); + entry.query_hits += 1; + entry.matched_queries.push(query); + entry.best_rank = Math.min(entry.best_rank, rank); + if (Number.isFinite(score)) { + entry.best_provider_score = entry.best_provider_score === null + ? score + : Math.max(entry.best_provider_score, score); + } + fused.set(identity, entry); + } + } + const ranked = [...fused.values()] + .map((context) => ({ + ...context, + score: context.best_provider_score ?? context.score ?? null, + fusion_score: Number(context.fusion_score.toFixed(8)), + matched_queries: [...new Set(context.matched_queries)], + })) + .sort((left, right) => ( + Number(right.fusion_score || 0) - Number(left.fusion_score || 0) + || Number(right.best_provider_score ?? -1) - Number(left.best_provider_score ?? -1) + || Number(left.best_rank || 999) - Number(right.best_rank || 999) + || qaRetrievalContextIdentity(left).localeCompare(qaRetrievalContextIdentity(right)) + )); + const limit = Math.max(1, Math.min(20, Number(maxContexts || 10))); + const perMaterialLimit = Math.max(1, Math.min(6, Number(maxPerMaterial || 2))); + const materialCounts = new Map(); + const selected = []; + for (const context of ranked) { + const materialKey = qaText(context.material_id, 240) + || qaRetrievalContextIdentity(context); + const count = materialCounts.get(materialKey) || 0; + if (count >= perMaterialLimit) continue; + selected.push(context); + materialCounts.set(materialKey, count + 1); + if (selected.length >= limit) break; + } + return selected; +} + +function qaEvidenceScore(questionPlan, item) { + const queryLexemes = qaLexemes(questionPlan.resolved_question); + const summaryText = String(item.retrieval_text || item.summary || ""); + const labelText = String(item.label || ""); + const leadText = summaryText.slice(0, 260); + const labelLexemes = qaLexemes(labelText); + const focusLexemes = new Set( + [...queryLexemes].filter((term) => !labelLexemes.has(term)), + ); + const summaryLexical = qaLexicalSimilarity(queryLexemes, summaryText); + const leadLexical = qaLexicalSimilarity(queryLexemes, leadText); + const labelLexical = qaLexicalSimilarity(queryLexemes, labelText); + const focusLexical = qaLexicalSimilarity(focusLexemes, summaryText); + const focusLeadLexical = qaLexicalSimilarity(focusLexemes, leadText); + const lexical = Math.min( + 1, + focusLexical * 0.62 + + focusLeadLexical * 0.14 + + summaryLexical * 0.14 + + leadLexical * 0.05 + + labelLexical * 0.05, + ); + const candidateText = `${labelText} ${summaryText}`; + const intentTerms = QA_INTENT_RULES + .filter((rule) => questionPlan.intents.includes(rule.id)) + .flatMap((rule) => rule.terms); + const intentMatches = intentTerms.filter((term) => candidateText.includes(term)).length; + const intent = intentTerms.length ? intentMatches / intentTerms.length : 0; + const semantic = Number.isFinite(Number(item.semantic_score)) + ? Math.max(0, Math.min(1, Number(item.semantic_score))) + : 0; + const exactSubquery = questionPlan.subqueries.some((query) => ( + query.length >= 4 && candidateText.includes(query) + )) ? 1 : 0; + const contentSignal = Math.max(focusLexical, focusLeadLexical); + const semanticWeight = contentSignal >= 0.02 || intent > 0 || exactSubquery > 0 ? 0.16 : 0.03; + return { + lexical_score: Number(lexical.toFixed(6)), + summary_lexical_score: Number(summaryLexical.toFixed(6)), + lead_lexical_score: Number(leadLexical.toFixed(6)), + label_lexical_score: Number(labelLexical.toFixed(6)), + focus_lexical_score: Number(focusLexical.toFixed(6)), + focus_lead_lexical_score: Number(focusLeadLexical.toFixed(6)), + intent_score: Number(intent.toFixed(6)), + semantic_score: Number(semantic.toFixed(6)), + exact_subquery_match: Boolean(exactSubquery), + retrieval_score: Number(( + lexical * 0.56 + + intent * 0.24 + + semantic * semanticWeight + + exactSubquery * 0.06 + ).toFixed(6)), + }; +} + +function canonicalUrl(value) { + const raw = text(value, 1000); + if (!/^https?:\/\//i.test(raw)) return raw; + try { + const url = new URL(raw); + url.hash = ""; + for (const key of [...url.searchParams.keys()]) { + if (/^(utm_|spm|from|source)/i.test(key)) url.searchParams.delete(key); + } + return url.toString().replace(/\/$/, ""); + } catch { + return raw; + } +} + +function normalizedCompanyName(value) { + return text(value, 160).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ""); +} + +function shortCompanyName(value) { + let name = text(value, 160); + for (const suffix of COMPANY_SUFFIXES) { + if (name.endsWith(suffix) && name.length > suffix.length) { + name = name.slice(0, -suffix.length); + break; + } + } + return normalizedCompanyName(name); +} + +function parentheticalBrandAlias(value) { + const match = text(value, 160).match(/^([^()()]{2,16})\s*[((]\s*(?:中国|China)\s*[))]/iu); + return normalizedCompanyName(match?.[1] || ""); +} + +function validIso(value) { + const raw = text(value, 80); + if (!raw) return null; + const timestamp = new Date(raw).getTime(); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null; +} + +function hostname(value) { + const url = canonicalUrl(value); + if (!url) return ""; + try { + return new URL(url).hostname.toLowerCase().replace(/^www\./, ""); + } catch { + return ""; + } +} + +function isOfficialPublicSource(source, url) { + if (source.official === true || /^(official|government)$/i.test(text(source.authority, 40))) return true; + const host = hostname(url); + return OFFICIAL_PUBLIC_HOSTS.some((suffix) => host === suffix || host.endsWith(`.${suffix}`)); +} + +function sourceQuality(kind, source, url) { + const isTestSource = /^(mock|demo|fixture)$/i.test(text(source.provider_mode, 40)); + if (isTestSource) { + return { source_quality: "limited", source_quality_label: "测试或占位来源", quality_tier: 3, official: false }; + } + if (kind === "professional") { + return { source_quality: "professional", source_quality_label: "专业权威来源", quality_tier: 1, official: true }; + } + if (kind === "internal") { + return { source_quality: "internal", source_quality_label: "内部授权资料", quality_tier: 2, official: false }; + } + if (isOfficialPublicSource(source, url)) { + return { source_quality: "official", source_quality_label: "官方公开来源", quality_tier: 1, official: true }; + } + if (hostname(url)) { + return { source_quality: "traceable", source_quality_label: "可追溯公开来源", quality_tier: 2, official: false }; + } + return { source_quality: "limited", source_quality_label: "来源信息有限", quality_tier: 3, official: false }; +} + +function sourceFreshness(kind, publishedAt, sourceUpdatedAt, generatedAt) { + const referenceDate = kind === "public" + ? publishedAt || sourceUpdatedAt + : sourceUpdatedAt || publishedAt; + if (!referenceDate) { + return { freshness: "unknown", freshness_label: "日期未知", age_days: null }; + } + const referenceTime = new Date(referenceDate).getTime(); + const generatedTime = new Date(generatedAt).getTime(); + const ageDays = Math.max(0, Math.floor((generatedTime - referenceTime) / DAY_MS)); + const currentDays = kind === "public" ? 180 : 365; + const staleDays = kind === "public" ? 365 : 730; + if (ageDays <= currentDays) return { freshness: "current", freshness_label: "近期资料", age_days: ageDays }; + if (ageDays <= staleDays) return { freshness: "aging", freshness_label: "较早资料", age_days: ageDays }; + return { freshness: "stale", freshness_label: "过期资料", age_days: ageDays }; +} + +function normalizeCriticalValue(value) { + return text(value, 80).replace(/[\s,,]/g, "").replace(/%/g, "%").toLowerCase(); +} + +export function extractCriticalClaims(value) { + const input = text(value, 4000); + const claims = []; + for (const definition of CRITICAL_FACT_PATTERNS) { + const expression = new RegExp(definition.pattern.source, definition.pattern.flags); + for (const match of input.matchAll(expression)) { + const normalizedValue = normalizeCriticalValue(match[1]); + if (!normalizedValue) continue; + claims.push({ + field: definition.field, + field_label: definition.label, + value: text(match[1], 80), + normalized_value: normalizedValue, + }); + } + } + return claims.filter((claim, index, values) => values.findIndex((item) => ( + item.field === claim.field && item.normalized_value === claim.normalized_value + )) === index); +} + +function sourceIndependenceKey(kind, source, identity, url) { + if (kind === "public") return hostname(url) || identity; + if (kind === "professional") { + return `${text(source.provider || "datapro", 80)}:${text(source.source_group || source.label || identity, 240)}`; + } + return text(source.uri, 1000) || identity; +} + +function evidenceDate(item) { + return validIso(item.published_at || item.source_updated_at); +} + +function evidenceConflicts(items) { + const byField = new Map(); + for (const item of items.filter((candidate) => candidate.source_kind !== "internal")) { + for (const claim of item.critical_claims || []) { + if (!byField.has(claim.field)) byField.set(claim.field, []); + byField.get(claim.field).push({ + ...claim, + evidence_id: item.id, + source_key: item.source_key, + source_date: evidenceDate(item), + }); + } + } + const conflicts = []; + for (const [field, claims] of byField) { + const distinctValues = [...new Set(claims.map((claim) => claim.normalized_value))]; + if (distinctValues.length < 2) continue; + const competing = claims.some((left, leftIndex) => claims.some((right, rightIndex) => { + if (rightIndex <= leftIndex || left.normalized_value === right.normalized_value) return false; + if (!left.source_date || !right.source_date) return true; + return Math.abs(new Date(left.source_date).getTime() - new Date(right.source_date).getTime()) <= 180 * DAY_MS; + })); + if (!competing) continue; + conflicts.push({ + field, + field_label: claims[0].field_label, + values: distinctValues.map((normalizedValue) => ({ + value: claims.find((claim) => claim.normalized_value === normalizedValue)?.value || normalizedValue, + evidence_ids: claims.filter((claim) => claim.normalized_value === normalizedValue).map((claim) => claim.evidence_id), + })), + }); + } + return conflicts; +} + +function evidenceAnchorsLegalEntity(item, entity) { + if (item?.source_kind !== "professional") return false; + const sourceText = normalizedCompanyName(`${item.label || ""} ${item.summary || ""}`); + const canonicalName = normalizedCompanyName(entity?.canonical_name || ""); + const creditCode = normalizedCompanyName(entity?.identifiers?.unified_social_credit_code || ""); + return Boolean( + (canonicalName && sourceText.includes(canonicalName)) + || (creditCode && sourceText.includes(creditCode)) + ); +} + +function evidencePolicy(items, conflicts, entity = {}) { + const counts = { professional: 0, public: 0, internal: 0 }; + for (const item of items) counts[item.source_kind] = Number(counts[item.source_kind] || 0) + 1; + const warnings = []; + const staleCount = items.filter((item) => item.freshness === "stale").length; + const unknownDateCount = items.filter((item) => item.freshness === "unknown").length; + if (staleCount) warnings.push(`${staleCount} 条来源已过期,不能作为最新动态依据。`); + if (unknownDateCount) warnings.push(`${unknownDateCount} 条来源缺少可核验日期。`); + if (conflicts.length) warnings.push(`${conflicts.length} 个关键数字存在来源冲突,不能直接选取单一值。`); + return { + schema_version: 1, + source_counts: counts, + authoritative_external_count: items.filter((item) => item.source_kind !== "internal" && item.quality_tier === 1).length, + legal_entity_anchor_count: items.filter((item) => evidenceAnchorsLegalEntity(item, entity)).length, + alias_scoped_count: items.filter((item) => item.entity_match === "alias_scoped").length, + traceable_public_count: items.filter((item) => item.source_kind === "public" && item.quality_tier <= 2 && hostname(item.url)).length, + current_public_count: items.filter((item) => item.source_kind === "public" && item.freshness === "current").length, + stale_count: staleCount, + unknown_date_count: unknownDateCount, + conflict_count: conflicts.length, + warnings, + }; +} + +function evidenceRejectionReason(item) { + if (item.entity_match === "unverified") return "entity_not_verified"; + if ( + item.source_kind === "public" + && NON_SUBSTANTIVE_PUBLIC_CONTENT_PATTERNS.some((pattern) => ( + pattern.test(`${item.label || ""} ${item.summary || ""}`) + )) + ) { + return "content_not_substantive"; + } + return ""; +} + +function sourceKindLabel(kind) { + if (kind === "professional") return "专业数据集"; + if (kind === "public") return "联网搜索"; + return "内部资料"; +} + +function evidenceIdentity(kind, source, entity) { + if (kind === "public") return canonicalUrl(source.url) || text(source.label || source.title, 240); + if (kind === "internal") return text(source.uri, 1000) || text(source.source_id || source.title, 240); + return text(source.source_key || source.label, 240) || `${entity.canonical_name}:professional`; +} + +function entityMatch(kind, source, entity) { + if (kind === "internal") return "company_scoped"; + const candidate = normalizedCompanyName(`${source.label || source.title || ""} ${source.summary || source.abstract || ""}`); + if (entity.strict_aliases.some((alias) => alias.length >= 2 && candidate.includes(alias))) return "verified"; + if (entity.contextual_aliases.some((alias) => alias.length >= 2 && candidate.includes(alias))) { + return "alias_scoped"; + } + const query = normalizedCompanyName(source.query || ""); + if (kind === "professional" && entity.aliases.some((alias) => alias.length >= 2 && query.includes(alias))) { + return "query_bound"; + } + return "unverified"; +} + +function normalizeEvidence(kind, source, entity, generatedAt) { + const summary = dossierEvidenceText(source.summary || source.abstract || source.text, 1600); + const identity = evidenceIdentity(kind, source, entity); + if (!summary || !identity) return null; + const publishedAt = validIso(source.published_at || source.publish_time || source.occurred_at); + const sourceUpdatedAt = validIso(source.last_synced_at || source.updated_at); + const match = entityMatch(kind, source, entity); + const url = canonicalUrl(source.url); + const quality = sourceQuality(kind, source, url); + const freshness = sourceFreshness(kind, publishedAt, sourceUpdatedAt, generatedAt); + return { + id: `evidence_${digest(`${kind}\n${identity}`).slice(0, 28)}`, + source_key: identity, + source_kind: kind, + source_kind_label: sourceKindLabel(kind), + label: text(source.label || source.title || identity, 240), + summary, + excerpt: text(source.excerpt || summary, 900), + url, + uri: text(source.uri, 1000), + provider: text(source.provider || (kind === "internal" ? "openviking" : kind === "public" ? "web_search" : "datapro"), 80), + provider_mode: text(source.provider_mode, 40), + raw_ref: text(source.raw_ref, 500), + query: text(source.query, 500), + purpose: text(source.purpose, 160), + site_name: kind === "public" ? text(source.site_name, 160) : "", + published_at: publishedAt, + source_updated_at: sourceUpdatedAt, + observed_at: validIso(source.observed_at) || generatedAt, + entity_match: match, + ...quality, + ...freshness, + independence_key: sourceIndependenceKey(kind, source, identity, url), + critical_claims: extractCriticalClaims(summary), + score: source.score !== null && source.score !== undefined && Number.isFinite(Number(source.score)) + ? Number(source.score) + : null, + }; +} + +function hashableEvidence(item) { + return { + id: item.id, + source_kind: item.source_kind, + source_key: item.source_key, + summary: item.summary, + published_at: item.published_at, + source_updated_at: item.source_updated_at, + entity_match: item.entity_match, + }; +} + +export function resolveCompanyEntity(company = {}) { + const canonicalName = text(company.name, 160); + const strictAliases = [ + normalizedCompanyName(canonicalName), + shortCompanyName(canonicalName), + ].filter((item, index, values) => item && values.indexOf(item) === index); + const contextualAliases = [ + ...(Array.isArray(company.aliases) ? company.aliases.map(normalizedCompanyName) : []), + parentheticalBrandAlias(canonicalName), + ] + .filter((item, index, values) => ( + item + && !strictAliases.includes(item) + && values.indexOf(item) === index + )); + const aliases = [...strictAliases, ...contextualAliases]; + return { + id: text(company.id, 200), + canonical_name: canonicalName, + normalized_name: normalizedCompanyName(canonicalName), + aliases, + strict_aliases: strictAliases, + contextual_aliases: contextualAliases, + identifiers: { + unified_social_credit_code: text(company.unified_social_credit_code || company.credit_code, 80) || null, + }, + }; +} + +export function buildDossierEvidencePack({ company, collected = {}, memoryContexts = [], generatedAt = new Date().toISOString() } = {}) { + const entity = resolveCompanyEntity(company); + if (!entity.id || !entity.canonical_name) throw new Error("company id and name are required for an evidence pack."); + const candidates = [ + ...(collected.professional || []).map((source) => normalizeEvidence("professional", source, entity, generatedAt)), + ...(collected.public_sources || []).map((source) => normalizeEvidence("public", source, entity, generatedAt)), + ...(memoryContexts || []).map((source) => normalizeEvidence("internal", source, entity, generatedAt)), + ].filter(Boolean); + const rejected = candidates + .map((item) => ({ + id: item.id, + label: item.label, + reason: evidenceRejectionReason(item), + })) + .filter((item) => item.reason); + let items = candidates + .filter((item) => !evidenceRejectionReason(item)) + .sort((a, b) => a.source_kind.localeCompare(b.source_kind) || a.id.localeCompare(b.id)); + const conflicts = evidenceConflicts(items); + const conflictFieldsByEvidence = new Map(); + for (const conflict of conflicts) { + for (const value of conflict.values) { + for (const evidenceId of value.evidence_ids) { + if (!conflictFieldsByEvidence.has(evidenceId)) conflictFieldsByEvidence.set(evidenceId, []); + conflictFieldsByEvidence.get(evidenceId).push(conflict.field); + } + } + } + items = items.map((item) => ({ + ...item, + conflict_fields: [...new Set(conflictFieldsByEvidence.get(item.id) || [])], + })); + const evidenceHash = digest(JSON.stringify(items.map(hashableEvidence))); + const dataAsOf = deriveEvidenceDataAsOf(items, generatedAt); + return { + entity, + items, + rejected, + evidence_hash: evidenceHash, + data_as_of: dataAsOf, + collected_at: generatedAt, + conflicts, + policy: evidencePolicy(items, conflicts, entity), + }; +} + +export function validateProductionEvidencePack(pack = {}) { + const policy = pack.policy || evidencePolicy(pack.items || [], pack.conflicts || [], pack.entity || {}); + const errors = []; + if (!policy.legal_entity_anchor_count) { + errors.push("缺少能够用法定名称或统一社会信用代码确认目标主体的专业来源"); + } + return { ok: errors.length === 0, errors, policy }; +} + +export function evidencePackCitations(pack = {}) { + return (pack.items || []).map((item) => ({ + id: item.id, + evidence_id: item.id, + label: item.label, + source_kind: item.source_kind_label, + url: item.url, + uri: item.uri, + summary: item.summary, + excerpt: item.excerpt, + provider: item.provider, + provider_mode: item.provider_mode, + raw_ref: item.raw_ref, + query: item.query, + purpose: item.purpose, + site_name: item.site_name, + published_at: item.published_at, + source_updated_at: item.source_updated_at, + entity_match: item.entity_match, + source_quality: item.source_quality, + source_quality_label: item.source_quality_label, + quality_tier: item.quality_tier, + official: item.official, + freshness: item.freshness, + freshness_label: item.freshness_label, + age_days: item.age_days, + independence_key: item.independence_key, + critical_claims: item.critical_claims, + conflict_fields: item.conflict_fields, + })); +} + +export function makeDossierFingerprint(dossier = {}) { + const canonical = { + title: text(dossier.title, 240), + summary: text(dossier.summary, 1000), + body: (dossier.body || []).map((paragraph) => ({ + text: text(paragraph.text, 1600), + citation_ids: [...new Set((paragraph.citation_ids || []).map(String))].sort(), + })), + citations: (dossier.citations || []).map((citation) => ({ + id: String(citation.id || citation.evidence_id || ""), + summary: text(citation.summary || citation.excerpt, 1600), + })).sort((a, b) => a.id.localeCompare(b.id)), + }; + return digest(JSON.stringify(canonical)); +} + +export function buildQaEvidence({ + dossier = null, + contexts = [], + question = "", + conversationHistory = [], + maxItems = 12, +} = {}) { + const candidates = []; + const questionPlan = analyzeQaQuestion(question, conversationHistory); + if (dossier?.id) { + const versionLabel = text(`${dossier.title || "企业档案"} V${Number(dossier.version_no || 1)}`, 240); + const dossierParagraphs = (dossier.body || []) + .map((paragraph) => text(paragraph?.text, 1800)) + .filter(Boolean); + const chunks = dossierParagraphs.length + ? dossierParagraphs + : splitQaChunks([dossier.summary, dossier.title].filter(Boolean).join("\n"), 1200); + chunks.forEach((chunk, index) => { + const section = DOSSIER_SECTION_TITLES.find((title) => ( + chunk.startsWith(`${title}:`) || chunk.startsWith(`${title}:`) + )) || `章节 ${index + 1}`; + candidates.push({ + id: `evidence_dossier_${digest(`${dossier.id}\n${index}\n${chunk}`).slice(0, 24)}`, + label: text(`${versionLabel} · ${section}`, 240), + source_kind: "企业档案", + summary: text(chunk, 1800), + url: "", + uri: "", + source_quality: "verified_dossier", + source_quality_label: "已核验企业档案", + quality_tier: 1, + freshness: "current", + freshness_label: "当前档案", + independence_key: `dossier:${dossier.id}:${index}`, + critical_claims: extractCriticalClaims(chunk), + semantic_score: null, + chunk_index: index, + }); + }); + } + for (const context of contexts || []) { + const identity = text(context.material_id, 240) + || text(context.uri, 1000) + || text(context.title, 240); + if (!identity) continue; + const content = qaText( + context.content + || context.text + || context.abstract + || context.summary, + ); + const chunks = splitQaChunks(content, 1100); + chunks.forEach((chunk, index) => { + candidates.push({ + id: `evidence_${digest(`internal\n${identity}\n${index}\n${chunk}`).slice(0, 28)}`, + label: text(context.title || identity, 240), + source_kind: text(context.source_kind || "内部资料", 80), + summary: text(qaChunkContextWindow(chunks, index), 1600), + retrieval_text: text(chunk, 1600), + url: "", + uri: text(context.uri, 1000), + source_quality: "internal", + source_quality_label: "内部授权资料", + quality_tier: 2, + freshness: "unknown", + freshness_label: "日期未知", + independence_key: `${identity}:${index}`, + critical_claims: extractCriticalClaims(chunk), + semantic_score: context.score ?? null, + chunk_index: index, + material_id: text(context.material_id, 240), + }); + }); + } + const deduped = [...new Map( + candidates + .filter((item) => item.summary && meaningfulQaSummary(item.summary)) + .map((item) => [digest(`${item.source_kind}\n${item.retrieval_text || item.summary}`), item]), + ).values()].map((item) => ({ + ...item, + ...qaEvidenceScore(questionPlan, item), + })); + const ranked = deduped.sort((left, right) => ( + Number(right.retrieval_score || 0) - Number(left.retrieval_score || 0) + || Number(left.quality_tier || 9) - Number(right.quality_tier || 9) + || left.id.localeCompare(right.id) + )); + const limit = Math.max(2, Math.min(20, Number(maxItems || 12))); + const topScore = Number(ranked[0]?.retrieval_score || 0); + const relevanceFloor = topScore >= 0.08 + ? Math.max(0.025, topScore * 0.3) + : 0; + const eligible = ranked.filter((item) => ( + Number(item.retrieval_score || 0) >= relevanceFloor + )); + const selected = []; + const sourceCounts = new Map(); + while (selected.length < limit) { + const remaining = eligible.filter((candidate) => ( + !selected.some((item) => item.id === candidate.id) + )); + if (!remaining.length) break; + const next = remaining + .map((candidate) => { + const sourceIdentity = candidate.material_id + ? `material:${candidate.material_id}` + : candidate.source_kind === "企业档案" + ? `dossier:${dossier?.id || candidate.label}` + : `source:${candidate.uri || candidate.label}`; + const sourceCount = sourceCounts.get(sourceIdentity) || 0; + return { + candidate, + sourceIdentity, + diversifiedScore: Number(candidate.retrieval_score || 0) - sourceCount * 0.025, + }; + }) + .filter((item) => (sourceCounts.get(item.sourceIdentity) || 0) < 3) + .sort((left, right) => ( + right.diversifiedScore - left.diversifiedScore + || Number(right.candidate.retrieval_score || 0) + - Number(left.candidate.retrieval_score || 0) + || left.candidate.id.localeCompare(right.candidate.id) + ))[0]; + if (!next) break; + selected.push(next.candidate); + sourceCounts.set(next.sourceIdentity, (sourceCounts.get(next.sourceIdentity) || 0) + 1); + } + return selected + .sort((left, right) => ( + Number(right.retrieval_score || 0) - Number(left.retrieval_score || 0) + )) + .map(({ retrieval_text: _retrievalText, ...item }) => item); +} + +export function buildQaEnumerationRequirements(question, evidence = []) { + const normalizedQuestion = qaText(question, 1200); + const asksForEnumeration = /哪些|有哪|列出|列举|逐项|所有|全部|包括什么|包含什么|多少(?:项|种|个)/.test(normalizedQuestion); + if (!asksForEnumeration) return []; + const queryLexemes = qaLexemes(qaEnumerationSubject(normalizedQuestion)); + const candidates = (evidence || []) + .map((item) => { + const summary = String(item.summary || ""); + const labels = qaTableRowLabels(summary); + const tableStart = summary.indexOf("|"); + const tableContext = tableStart >= 0 ? summary.slice(0, tableStart) : ""; + return { + evidence_id: String(item.id || ""), + labels, + topic_score: qaLexicalSimilarity(queryLexemes, `${tableContext} ${labels.join(" ")}`), + retrieval_score: Number(item.retrieval_score || 0), + }; + }) + .filter((item) => ( + item.evidence_id + && item.labels.length >= 2 + && item.labels.length <= 12 + && item.topic_score >= 0.015 + )) + .sort((left, right) => ( + right.topic_score - left.topic_score + || right.retrieval_score - left.retrieval_score + || right.labels.length - left.labels.length + )); + const best = candidates[0]; + return best + ? best.labels.map((label) => ({ label, evidence_id: best.evidence_id })) + : []; +} + +export function assessQaAnswerability(question, evidence = [], conversationHistory = []) { + const plan = analyzeQaQuestion(question, conversationHistory); + const ranked = [...(evidence || [])].sort((left, right) => ( + Number(right.retrieval_score || 0) - Number(left.retrieval_score || 0) + )); + const top = ranked[0] || null; + const topScore = Number(top?.retrieval_score || 0); + const groundedSignal = Boolean( + Number(top?.lexical_score || 0) >= 0.01 + || Number(top?.intent_score || 0) >= 0.05 + || top?.exact_subquery_match, + ); + const supported = ranked.length > 0 && topScore >= 0.07 && groundedSignal; + return { + supported, + score: topScore, + evidence_count: ranked.length, + intents: plan.intents, + reason: supported + ? "retrieval_supported" + : ranked.length + ? "low_relevance" + : "missing_evidence", + }; +} + +function isInternalEvidence(item) { + return /内部资料|OpenViking|历史资料|飞书|会议|文档/.test(text(item?.source_kind, 100)); +} + +function isVerifiedDossierEvidence(item) { + return item?.source_quality === "verified_dossier" + || /企业档案/.test(text(item?.source_kind, 100)); +} + +function independentExternalSources(items) { + const keys = new Set(); + for (const item of items.filter((candidate) => !isInternalEvidence(candidate))) { + keys.add(text(item.independence_key || hostname(item.url) || item.source_key || item.label || item.id, 1000)); + } + return [...keys].filter(Boolean); +} + +export function hasHighRiskAssertion(value) { + const input = text(value, 2000); + if (extractCriticalClaims(input).length) return true; + if (/(?:20\d{2}[-/.\u5e74]\d{1,2}(?:[-/.\u6708]\d{1,2}\u65e5?)?)[^\u3002\uff1b\n]{0,24}(?:\u884c\u653f\u5904\u7f5a|\u53f8\u6cd5\u8bc9\u8bbc|\u5931\u4fe1\u88ab\u6267\u884c|\u9650\u5236\u9ad8\u6d88\u8d39|\u7ecf\u8425\u5f02\u5e38|\u76d1\u7ba1\u5904\u7f5a)/u.test(input)) return true; + return /(?:(?:未发现|未涉及|不存在|存在|涉及|新增|发生|受到|列入|被执行|累计|共计).{0,18}(?:行政处罚|诉讼|失信|执行案件|经营异常|重大风险))|(?:(?:行政处罚|诉讼|失信|被执行|经营异常|重大风险).{0,18}(?:未发现|不存在|存在|涉及|新增|\d))/i.test(input); +} + +function highRiskSupportErrors(paragraph, citations, path) { + if (!hasHighRiskAssertion(paragraph.text)) return []; + if (citations.some(isVerifiedDossierEvidence)) return []; + const external = citations.filter((item) => !isInternalEvidence(item)); + const errors = []; + if (independentExternalSources(external).length < 2) { + errors.push(`${path} 的高风险事实缺少两个独立外部来源`); + } + if (!external.some((item) => Number(item.quality_tier || (/专业数据/.test(item.source_kind) ? 1 : 3)) === 1)) { + errors.push(`${path} 的高风险事实缺少专业或官方来源`); + } + for (const claim of extractCriticalClaims(paragraph.text)) { + const supporters = external.filter((item) => (item.critical_claims || extractCriticalClaims(item.summary)).some((sourceClaim) => ( + sourceClaim.field === claim.field && sourceClaim.normalized_value === claim.normalized_value + ))); + if (independentExternalSources(supporters).length < 2) { + errors.push(`${path} 的${claim.field_label}“${claim.value}”未获得双来源一致支持`); + } + } + return errors; +} + +export function validateDossierModelAnswer(parsed = {}, evidence = []) { + const allowed = new Map((evidence || []).map((item) => [String(item.id), item])); + const errors = []; + const body = (Array.isArray(parsed.body) ? parsed.body : []).map((paragraph, index) => { + const paragraphText = text(paragraph.text, 1400) + .replace(/^([^::]{2,18}):/, "$1:"); + const rawSegments = Array.isArray(paragraph.segments) && paragraph.segments.length + ? paragraph.segments + : [{ text: paragraphText, citation_ids: paragraph.citation_ids || [] }]; + const segments = rawSegments.map((segment, segmentIndex) => { + const requested = [...new Set((segment.citation_ids || []).map(String))]; + const citationIds = requested.filter((id) => allowed.has(id)); + const segmentText = text(segment.text, 800); + const path = `body[${index}].segments[${segmentIndex}]`; + if (requested.length !== citationIds.length) errors.push(`${path} 包含无效引用`); + if (!citationIds.length) errors.push(`${path} 缺少有效引用`); + const citations = citationIds.map((id) => allowed.get(id)); + if (citations.some(isInternalEvidence)) { + errors.push(`${path} 使用内部资料支撑外部事实`); + } + errors.push(...highRiskSupportErrors({ text: segmentText }, citations, path)); + return { text: segmentText, citation_ids: citationIds }; + }).filter((segment) => segment.text); + const citationIds = [...new Set(segments.flatMap((segment) => segment.citation_ids))]; + if (!segments.length) errors.push(`body[${index}] 缺少正文段落`); + return { text: paragraphText, citation_ids: citationIds, segments }; + }).filter((paragraph) => paragraph.text); + if (body.length !== DOSSIER_SECTION_TITLES.length) { + errors.push(`档案正文必须包含 ${DOSSIER_SECTION_TITLES.length} 个有引用的固定章节`); + } + DOSSIER_SECTION_TITLES.forEach((title, index) => { + if (!body[index]?.text.startsWith(`${title}:`)) { + errors.push(`body[${index}] 必须以“${title}:”开头`); + } + }); + return { body, errors }; +} + +export function validateQaModelAnswer(parsed = {}, evidence = [], options = {}) { + const allowed = new Map((evidence || []).map((item) => [String(item.id), item])); + const rawParagraphs = Array.isArray(parsed.paragraphs) + ? parsed.paragraphs + : parsed.answer + ? [{ text: parsed.answer, citation_ids: parsed.citation_ids || parsed.citation_source_ids || [] }] + : []; + const insufficient = Boolean(parsed.insufficient); + const asksForGap = QA_GAP_REQUEST_PATTERN.test(qaText(options.question, 1200)); + const sourceParagraphs = insufficient || asksForGap + ? rawParagraphs + : rawParagraphs.filter((paragraph) => ( + !QA_GAP_HEADING_PATTERN.test(qaText(paragraph?.text, 900)) + )); + const errors = []; + const paragraphs = sourceParagraphs.map((paragraph, index) => { + const requested = [...new Set((paragraph.citation_ids || []).map(String))]; + const citationIds = requested.filter((id) => allowed.has(id)); + if (requested.length !== citationIds.length) errors.push(`paragraphs[${index}] 包含无效引用`); + if (!insufficient && !citationIds.length) errors.push(`paragraphs[${index}] 缺少有效引用`); + const normalized = { + text: text(paragraph.text, 900), + citation_ids: citationIds, + }; + if (!insufficient) { + errors.push(...highRiskSupportErrors(normalized, citationIds.map((id) => allowed.get(id)), `paragraphs[${index}]`)); + const citedEvidence = citationIds.map((id) => allowed.get(id)).filter(Boolean); + const dossierEvidence = citedEvidence.filter((item) => item.source_kind === "企业档案"); + if ( + QA_RISK_HEADING_PATTERN.test(normalized.text) + && dossierEvidence.length + && !dossierEvidence.some((item) => /(?:^|·\s*)风险与关注事项/u.test(String(item.label || ""))) + ) { + errors.push(`paragraphs[${index}] 的风险结论未引用档案中的“风险与关注事项”章节`); + } + if (QA_ACTION_HEADING_PATTERN.test(normalized.text) && dossierEvidence.length) { + const bestOverlap = Math.max(0, ...dossierEvidence.map((item) => ( + qaLexicalSimilarity(qaLexemes(normalized.text), item.summary || "") + ))); + if (bestOverlap < 0.03) { + errors.push(`paragraphs[${index}] 的行动建议与所引用档案章节不匹配`); + } + } + } + return normalized; + }).filter((paragraph) => paragraph.text); + if (!paragraphs.length) errors.push("回答正文缺失"); + const answerText = paragraphs.map((paragraph) => paragraph.text).join("\n\n"); + const enumerationRequirements = Array.isArray(options.enumerationRequirements) + ? options.enumerationRequirements + : []; + const normalizedAnswer = qaEnumerationKey(answerText); + const missingEnumerationItems = insufficient + ? [] + : enumerationRequirements.filter((item) => ( + !qaEnumerationAliases(item?.label).some((alias) => normalizedAnswer.includes(alias)) + )); + if (missingEnumerationItems.length) { + errors.push(`回答遗漏枚举项:${missingEnumerationItems.map((item) => item.label).join("、")}`); + } + const usedIds = [...new Set(paragraphs.flatMap((paragraph) => paragraph.citation_ids))]; + return { + paragraphs, + text: answerText, + citation_ids: usedIds, + citations: usedIds.map((id) => allowed.get(id)), + insufficient, + missing_enumeration_items: missingEnumerationItems, + errors, + }; +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/frontend/staticFrontend.js b/demohouse/sales-intelligence-workbench/backend/src/frontend/staticFrontend.js new file mode 100644 index 00000000..e79ce219 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/frontend/staticFrontend.js @@ -0,0 +1,79 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +const CONTENT_TYPES = new Map([ + [".css", "text/css; charset=utf-8"], + [".gif", "image/gif"], + [".html", "text/html; charset=utf-8"], + [".ico", "image/x-icon"], + [".jpeg", "image/jpeg"], + [".jpg", "image/jpeg"], + [".js", "text/javascript; charset=utf-8"], + [".json", "application/json; charset=utf-8"], + [".png", "image/png"], + [".svg", "image/svg+xml; charset=utf-8"], + [".webp", "image/webp"], +]); + +function resolveFrontendFile(rootDir, pathname) { + let decodedPath; + try { + decodedPath = decodeURIComponent(pathname); + } catch { + return null; + } + + const relativePath = decodedPath === "/" + ? "index.html" + : decodedPath.replace(/^\/+/, ""); + if (!relativePath || relativePath.includes("\0") || relativePath.includes("\\")) return null; + if (relativePath.split("/").some((segment) => segment === "..")) return null; + + const resolvedRoot = path.resolve(rootDir); + const resolvedFile = path.resolve(resolvedRoot, relativePath); + if (resolvedFile !== resolvedRoot && !resolvedFile.startsWith(`${resolvedRoot}${path.sep}`)) return null; + return resolvedFile; +} + +async function existingFile(filePath) { + try { + const fileStat = await stat(filePath); + if (fileStat.isFile()) return filePath; + if (!fileStat.isDirectory()) return null; + const indexPath = path.join(filePath, "index.html"); + return (await stat(indexPath)).isFile() ? indexPath : null; + } catch { + return null; + } +} + +function cacheControl(filePath) { + const extension = path.extname(filePath).toLowerCase(); + if ([".html", ".js", ".css"].includes(extension)) return "no-store"; + return "public, max-age=3600"; +} + +export function createStaticFrontend({ rootDir }) { + const resolvedRoot = path.resolve(rootDir); + + return async function serveStaticFrontend(req, res, pathname) { + if (!["GET", "HEAD"].includes(req.method || "GET")) return false; + if (pathname === "/api" || pathname.startsWith("/api/")) return false; + + const candidate = resolveFrontendFile(resolvedRoot, pathname); + const filePath = candidate ? await existingFile(candidate) : null; + if (!filePath) return false; + + const body = await readFile(filePath); + const contentType = CONTENT_TYPES.get(path.extname(filePath).toLowerCase()) || "application/octet-stream"; + res.setHeader("Content-Type", contentType); + res.setHeader("Content-Length", body.byteLength); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Referrer-Policy", "same-origin"); + res.setHeader("Cache-Control", cacheControl(filePath)); + res.writeHead(200); + if (req.method === "HEAD") res.end(); + else res.end(body); + return true; + }; +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/limits/paidWorkflowGuard.js b/demohouse/sales-intelligence-workbench/backend/src/limits/paidWorkflowGuard.js new file mode 100644 index 00000000..bd6ad06e --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/limits/paidWorkflowGuard.js @@ -0,0 +1,228 @@ +import { HttpError } from "../utils/http.js"; +import { makeId } from "../utils/ids.js"; + +const clone = (value) => JSON.parse(JSON.stringify(value)); + +function nonNegativeInteger(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback; +} + +function positiveInteger(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function dateKey(value, timeZone) { + return new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date(value)); +} + +function errorDetails(error) { + const details = error?.details; + if (!details) return {}; + if (typeof details === "object") return details; + try { + return JSON.parse(details); + } catch { + return {}; + } +} + +function limitError(error) { + const message = String(error?.message || ""); + const details = errorDetails(error); + if (message.includes("paid_workflow_concurrency_exceeded")) { + return new HttpError(429, "paid_workflow_concurrency_exceeded", "当前付费任务已达到并发上限,请稍后重试。", { + running: Number(details.running || 0), + limit: Number(details.limit || 0), + retry_after_seconds: Number(details.retry_after_seconds || 30), + }); + } + if (message.includes("paid_workflow_daily_limit_exceeded")) { + return new HttpError(429, "paid_workflow_daily_limit_exceeded", "今日付费任务次数已达到工作区上限。", { + used: Number(details.used || 0), + limit: Number(details.limit || 0), + timezone: String(details.timezone || ""), + }); + } + return null; +} + +export function paidWorkflowLimits(env) { + return Object.freeze({ + max_concurrent: nonNegativeInteger(env.value("PAID_WORKFLOW_MAX_CONCURRENCY", "2"), 2), + daily_limit: nonNegativeInteger(env.value("PAID_WORKFLOW_DAILY_LIMIT", "100"), 100), + timezone: String(env.value("PAID_WORKFLOW_BUDGET_TIMEZONE", "Asia/Shanghai") || "Asia/Shanghai").trim(), + stale_after_seconds: positiveInteger(env.value("PAID_WORKFLOW_STALE_AFTER_SECONDS", "1800"), 1800), + }); +} + +export class PaidWorkflowGuard { + constructor(options = {}) { + this.env = options.env; + this.repository = options.repository || null; + this.failClosed = Boolean(options.failClosed); + this.listLocalJobs = options.listLocalJobs || (() => []); + this.limits = paidWorkflowLimits(this.env); + this.localReservations = new Map(); + this.localQueue = Promise.resolve(); + try { + dateKey(new Date(), this.limits.timezone); + } catch { + throw new Error(`PAID_WORKFLOW_BUDGET_TIMEZONE is invalid: ${this.limits.timezone}`); + } + } + + async reserve(job) { + if (job.is_paid === false) return { job: clone(job), budget: null }; + const reservationId = makeId("usage_reservation"); + const candidate = { ...clone(job), is_paid: true, reservation_id: reservationId }; + + if (typeof this.repository?.reservePaidWorkflow === "function") { + try { + return await this.repository.reservePaidWorkflow(candidate, reservationId, this.limits); + } catch (error) { + const known = limitError(error); + if (known) throw known; + throw new HttpError(503, "usage_guard_unavailable", "付费任务保护暂时不可用,任务未执行。", { + reason: String(error?.code || "reservation_failed"), + }); + } + } + + if (this.failClosed) { + throw new HttpError(503, "usage_guard_unavailable", "生产环境缺少持久化付费任务保护,任务未执行。", { + reason: "repository_reservation_not_supported", + }); + } + return this.withLocalLock(() => this.reserveLocal(candidate)); + } + + async finish(job) { + if (!job?.is_paid || !job?.reservation_id) return clone(job); + if (typeof this.repository?.finishPaidWorkflow === "function") { + try { + return await this.repository.finishPaidWorkflow(job, job.reservation_id); + } catch (error) { + throw new HttpError(503, "usage_guard_unavailable", "付费任务状态未能可靠落库。", { + reason: String(error?.code || "reservation_release_failed"), + }); + } + } + if (this.failClosed) { + throw new HttpError(503, "usage_guard_unavailable", "生产环境缺少持久化付费任务保护。", { + reason: "repository_release_not_supported", + }); + } + const reservation = this.localReservations.get(job.reservation_id); + if (reservation?.status === "running") { + reservation.status = job.status; + reservation.released_at = job.finished_at || new Date().toISOString(); + } + return clone(job); + } + + async snapshot() { + if (typeof this.repository?.getPaidWorkflowUsage === "function") { + try { + const usage = await this.repository.getPaidWorkflowUsage(this.limits.timezone); + return this.publicSnapshot(usage); + } catch (error) { + if (this.failClosed) { + throw new HttpError(503, "usage_guard_unavailable", "无法读取付费任务用量。", { + reason: String(error?.code || "usage_snapshot_failed"), + }); + } + } + } + return this.publicSnapshot(this.localUsage()); + } + + withLocalLock(operation) { + const result = this.localQueue.then(operation, operation); + this.localQueue = result.catch(() => {}); + return result; + } + + reserveLocal(job) { + const now = new Date(); + for (const reservation of this.localReservations.values()) { + if (reservation.status === "running" && new Date(reservation.expires_at) <= now) { + reservation.status = "expired"; + reservation.released_at = now.toISOString(); + } + } + const usage = this.localUsage(now); + if (this.limits.max_concurrent > 0 && usage.running >= this.limits.max_concurrent) { + throw new HttpError(429, "paid_workflow_concurrency_exceeded", "当前付费任务已达到并发上限,请稍后重试。", { + running: usage.running, + limit: this.limits.max_concurrent, + retry_after_seconds: Math.min(this.limits.stale_after_seconds, 60), + }); + } + if (this.limits.daily_limit > 0 && usage.used_today >= this.limits.daily_limit) { + throw new HttpError(429, "paid_workflow_daily_limit_exceeded", "今日付费任务次数已达到工作区上限。", { + used: usage.used_today, + limit: this.limits.daily_limit, + timezone: this.limits.timezone, + }); + } + const reservedAt = now.toISOString(); + this.localReservations.set(job.reservation_id, { + id: job.reservation_id, + job_id: job.id, + job_type: job.job_type, + status: "running", + reserved_at: reservedAt, + expires_at: new Date(now.getTime() + this.limits.stale_after_seconds * 1000).toISOString(), + }); + return { + job: clone(job), + budget: this.publicSnapshot({ + running: usage.running + 1, + used_today: usage.used_today + 1, + by_job_type: { + ...usage.by_job_type, + [job.job_type]: Number(usage.by_job_type[job.job_type] || 0) + 1, + }, + }), + }; + } + + localUsage(now = new Date()) { + const today = dateKey(now, this.limits.timezone); + const usage = { running: 0, used_today: 0, by_job_type: {} }; + for (const reservation of this.localReservations.values()) { + if (reservation.status === "running" && new Date(reservation.expires_at) > now) usage.running += 1; + if (dateKey(reservation.reserved_at, this.limits.timezone) !== today) continue; + usage.used_today += 1; + usage.by_job_type[reservation.job_type] = Number(usage.by_job_type[reservation.job_type] || 0) + 1; + } + if (!this.localReservations.size) { + for (const job of this.listLocalJobs()) { + if (!job?.is_paid || !job.created_at || dateKey(job.created_at, this.limits.timezone) !== today) continue; + usage.used_today += 1; + usage.by_job_type[job.job_type] = Number(usage.by_job_type[job.job_type] || 0) + 1; + if (job.status === "running") usage.running += 1; + } + } + return usage; + } + + publicSnapshot(usage = {}) { + return { + running: Number(usage.running || 0), + max_concurrent: this.limits.max_concurrent, + used_today: Number(usage.used_today || 0), + daily_limit: this.limits.daily_limit, + timezone: this.limits.timezone, + by_job_type: usage.by_job_type && typeof usage.by_job_type === "object" ? usage.by_job_type : {}, + counting_unit: "paid_workflow_attempt", + }; + } +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/limits/providerCircuitBreaker.js b/demohouse/sales-intelligence-workbench/backend/src/limits/providerCircuitBreaker.js new file mode 100644 index 00000000..749a275e --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/limits/providerCircuitBreaker.js @@ -0,0 +1,85 @@ +const RETRYABLE_CATEGORIES = new Set(["network", "timeout", "rate_limit", "upstream"]); + +function positiveInteger(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function shouldCountFailure(error) { + if (!error) return false; + if (error.code === "provider_circuit_open") return false; + return Boolean(error.retryable) || RETRYABLE_CATEGORIES.has(String(error.category || "").toLowerCase()); +} + +function circuitOpenError(provider, retryAfterSeconds) { + const error = new Error(`${provider} is temporarily unavailable after repeated upstream failures.`); + error.code = "provider_circuit_open"; + error.category = "upstream"; + error.retryable = true; + error.retry_after_seconds = Math.max(1, retryAfterSeconds); + return error; +} + +export class ProviderCircuitBreaker { + constructor(options = {}) { + this.enabled = Boolean(options.enabled); + this.failureThreshold = positiveInteger(options.failureThreshold, 5); + this.cooldownMs = positiveInteger(options.cooldownSeconds, 60) * 1000; + this.now = options.now || (() => Date.now()); + this.states = new Map(); + } + + beforeCall(providerName) { + if (!this.enabled) return { provider: String(providerName || "unknown"), halfOpen: false }; + const provider = String(providerName || "unknown"); + const state = this.states.get(provider); + if (!state?.openUntil) return { provider, halfOpen: false }; + + const remainingMs = state.openUntil - this.now(); + if (remainingMs > 0 || state.probeInFlight) { + throw circuitOpenError(provider, Math.ceil(Math.max(remainingMs, 1000) / 1000)); + } + + state.probeInFlight = true; + return { provider, halfOpen: true }; + } + + recordSuccess(token = {}) { + if (!this.enabled) return; + this.states.delete(String(token.provider || "unknown")); + } + + recordFailure(token = {}, error = null) { + if (!this.enabled) return; + const provider = String(token.provider || "unknown"); + const existing = this.states.get(provider) || { + consecutiveFailures: 0, + openUntil: 0, + probeInFlight: false, + }; + existing.probeInFlight = false; + + if (!shouldCountFailure(error)) { + this.states.delete(provider); + return; + } + + existing.consecutiveFailures += 1; + if (token.halfOpen || existing.consecutiveFailures >= this.failureThreshold) { + existing.openUntil = this.now() + this.cooldownMs; + existing.consecutiveFailures = this.failureThreshold; + } + this.states.set(provider, existing); + } + + snapshot() { + const now = this.now(); + return [...this.states.entries()].map(([provider, state]) => ({ + provider, + consecutive_failures: state.consecutiveFailures, + open: Boolean(state.openUntil && state.openUntil > now), + retry_after_seconds: state.openUntil > now ? Math.ceil((state.openUntil - now) / 1000) : 0, + half_open_probe: Boolean(state.probeInFlight), + })); + } +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/observability/providerRunStore.js b/demohouse/sales-intelligence-workbench/backend/src/observability/providerRunStore.js new file mode 100644 index 00000000..952c3696 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/observability/providerRunStore.js @@ -0,0 +1,272 @@ +import { makeId } from "../utils/ids.js"; +import { nowIso } from "../utils/time.js"; + +const clone = (value) => JSON.parse(JSON.stringify(value)); + +function redactSecrets(value, maxLength = 500) { + return String(value || "") + .replace(/Bearer\s+[^\s,;]+/gi, "Bearer [REDACTED]") + .replace(/ark-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}-[0-9a-f]{5}/gi, "[REDACTED]") + .replace(/AKLT[A-Za-z0-9]{20,}/g, "[REDACTED]") + .replace(/((?:api|access|secret)[_-]?key)\s*[:=]\s*[^\s,;]+/gi, "$1=[REDACTED]") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxLength); +} + +function safeError(error) { + if (!error) return null; + const validationErrors = Array.isArray(error.details?.validation_errors) + ? error.details.validation_errors + .map((item) => redactSecrets(item)) + .filter(Boolean) + .slice(0, 16) + : []; + return { + code: redactSecrets(error.code || "provider_error", 80), + message: redactSecrets(error.message || "Provider call failed."), + category: redactSecrets(error.category || "unknown", 80), + retryable: Boolean(error.retryable), + validation_errors: validationErrors, + }; +} + +function safeUsage(usage) { + if (!usage || typeof usage !== "object") return null; + const result = {}; + for (const key of ["prompt_tokens", "completion_tokens", "total_tokens", "reasoning_tokens"]) { + const value = Number(usage[key]); + if (Number.isFinite(value)) result[key] = value; + } + return Object.keys(result).length ? result : null; +} + +function durationMs(startedAt, finishedAt) { + return Math.max(0, new Date(finishedAt).getTime() - new Date(startedAt).getTime()); +} + +export class ProviderRunStore { + constructor(options = {}) { + this.maxRuns = Math.max(20, Number(options.maxRuns || 200)); + this.runs = []; + this.repository = options.repository || null; + this.failOnPersistenceError = Boolean(options.failOnPersistenceError); + this.persistenceError = null; + this.circuitBreaker = options.circuitBreaker || null; + } + + async startRun(input = {}) { + const run = { + id: makeId("provider_run"), + operation: redactSecrets(input.operation || "provider_workflow", 120), + status: "running", + app_mode: "production", + entity_type: redactSecrets(input.entity_type || "", 80), + entity_id: redactSecrets(input.entity_id || "", 160), + job_id: redactSecrets(input.job_id || "", 160) || null, + started_at: nowIso(), + finished_at: null, + duration_ms: null, + result_ref: null, + error: null, + steps: [], + }; + this.runs.unshift(run); + this.runs.splice(this.maxRuns); + try { + await this.persistRun(run, { strict: true }); + } catch (error) { + this.runs = this.runs.filter((item) => item.id !== run.id); + throw error; + } + return clone(run); + } + + async startStep(runId, input = {}) { + const run = this.requireRun(runId); + const step = { + id: makeId("provider_step"), + sequence: run.steps.length + 1, + provider: redactSecrets(input.provider || "unknown", 80), + operation: redactSecrets(input.operation || "provider_call", 120), + status: "running", + input_summary: redactSecrets(input.input_summary || ""), + output_summary: "", + request_id: null, + raw_ref: null, + usage: null, + attempts: Math.max(1, Number(input.attempts || 1)), + started_at: nowIso(), + finished_at: null, + latency_ms: null, + error: null, + }; + run.steps.push(step); + await this.persistRun(run, { strict: true }); + return clone(step); + } + + async finishStep(runId, stepId, result = {}) { + const run = this.requireRun(runId); + const step = run.steps.find((item) => item.id === stepId); + if (!step) throw new Error(`Provider step was not found: ${stepId}`); + const finishedAt = nowIso(); + const explicitlySkipped = result.status === "skipped"; + step.status = explicitlySkipped ? "skipped" : result.ok === false ? "failed" : "succeeded"; + step.output_summary = redactSecrets(result.output_summary || result.summary || ""); + step.request_id = redactSecrets(result.request_id || "", 180) || null; + step.raw_ref = redactSecrets(result.raw_ref || "", 240) || null; + step.usage = safeUsage(result.usage); + step.attempts = Math.max(1, Number(result.attempts || step.attempts || 1)); + step.finished_at = finishedAt; + step.latency_ms = Number.isFinite(Number(result.latency_ms)) + ? Math.max(0, Number(result.latency_ms)) + : durationMs(step.started_at, finishedAt); + step.error = safeError(result.error); + await this.persistRun(run, { strict: true }); + return clone(step); + } + + async skipStep(runId, input = {}) { + const step = await this.startStep(runId, input); + return this.finishStep(runId, step.id, { + status: "skipped", + output_summary: input.output_summary || "Provider step was not enabled for this run.", + error: input.error || null, + }); + } + + async executeStep(runId, input, operation) { + const step = await this.startStep(runId, input); + let circuitToken = null; + try { + circuitToken = this.circuitBreaker?.beforeCall(input?.provider); + const result = await operation(); + const succeeded = result?.ok !== false; + if (succeeded) { + this.circuitBreaker?.recordSuccess(circuitToken); + } else { + this.circuitBreaker?.recordFailure(circuitToken, result?.error); + } + await this.finishStep(runId, step.id, { + ...(result || {}), + ok: succeeded, + output_summary: succeeded ? input.output_summary || result?.summary || "" : "", + }); + return result; + } catch (error) { + if (circuitToken) this.circuitBreaker?.recordFailure(circuitToken, error); + try { + await this.finishStep(runId, step.id, { + ok: false, + error: { + code: error.code || "provider_exception", + message: error.message || "Provider call failed.", + category: error.category || "unknown", + retryable: error.retryable, + }, + }); + } catch (persistenceError) { + if (this.failOnPersistenceError) throw persistenceError; + } + throw error; + } + } + + async completeRun(runId, input = {}) { + const run = this.requireRun(runId); + const finishedAt = nowIso(); + const hasFailedStep = run.steps.some((step) => step.status === "failed"); + run.status = hasFailedStep ? "succeeded_with_issues" : "succeeded"; + run.finished_at = finishedAt; + run.duration_ms = durationMs(run.started_at, finishedAt); + run.result_ref = redactSecrets(input.result_ref || "", 240) || null; + await this.persistRun(run, { strict: true }); + return clone(run); + } + + async failRun(runId, error) { + const run = this.requireRun(runId); + const finishedAt = nowIso(); + run.status = "failed"; + run.finished_at = finishedAt; + run.duration_ms = durationMs(run.started_at, finishedAt); + run.error = safeError(error); + await this.persistRun(run, { strict: true }); + return clone(run); + } + + async cancelRun(runId, input = {}) { + const run = this.requireRun(runId); + if (run.status === "cancelled") return clone(run); + if (run.status !== "running") return clone(run); + const finishedAt = nowIso(); + run.status = "cancelled"; + run.finished_at = finishedAt; + run.duration_ms = durationMs(run.started_at, finishedAt); + run.error = null; + for (const step of run.steps) { + if (step.status !== "running") continue; + step.status = "cancelled"; + step.output_summary = redactSecrets(input.summary || "任务已由用户取消。", 500); + step.finished_at = finishedAt; + step.latency_ms = durationMs(step.started_at, finishedAt); + step.error = null; + } + await this.persistRun(run, { strict: true }); + return clone(run); + } + + async list(filters = {}) { + const operation = String(filters.operation || "").trim(); + const entityId = String(filters.entity_id || "").trim(); + const requestedLimit = Number(filters.limit || 20); + const limit = Math.max(1, Math.min(Number.isFinite(requestedLimit) ? requestedLimit : 20, 100)); + const memoryRuns = this.runs + .filter((run) => !operation || run.operation === operation) + .filter((run) => !entityId || run.entity_id === entityId) + .map((run) => clone(run)); + const persistedRuns = await this.readPersisted("listProviderRuns", [{ operation, entity_id: entityId, limit }], []); + return [...new Map([...memoryRuns, ...persistedRuns].map((run) => [run.id, run])).values()] + .sort((a, b) => String(b.started_at || "").localeCompare(String(a.started_at || ""))) + .slice(0, limit); + } + + async get(runId) { + const run = this.runs.find((item) => item.id === runId); + if (run) return clone(run); + return this.readPersisted("getProviderRun", [runId], null); + } + + requireRun(runId) { + const run = this.runs.find((item) => item.id === runId); + if (!run) throw new Error(`Provider run was not found: ${runId}`); + return run; + } + + async persistRun(run, options = {}) { + if (typeof this.repository?.persistProviderRun !== "function") return null; + try { + const result = await this.repository.persistProviderRun(clone(run)); + this.persistenceError = null; + return result; + } catch (error) { + this.persistenceError = safeError(error); + if (options.strict && this.failOnPersistenceError) throw error; + return null; + } + } + + async readPersisted(method, args, fallback) { + if (typeof this.repository?.[method] !== "function") return fallback; + try { + const result = await this.repository[method](...args); + this.persistenceError = null; + return result ?? fallback; + } catch (error) { + this.persistenceError = safeError(error); + if (this.failOnPersistenceError) throw error; + return fallback; + } + } +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/providers/citationValidator.js b/demohouse/sales-intelligence-workbench/backend/src/providers/citationValidator.js new file mode 100644 index 00000000..5052600b --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/providers/citationValidator.js @@ -0,0 +1,22 @@ +function unique(values) { + return [...new Set((values || []).filter(Boolean))]; +} + +export function collectCitationContext(cards = [], sources = []) { + const cardIds = new Set(cards.map((card) => card.id).filter(Boolean)); + const sourceIds = new Set(sources.map((source) => source.id).filter(Boolean)); + return { cardIds, sourceIds }; +} + +export function filterCitationIds(ids, allowed) { + return unique((ids || []).map((id) => String(id || "").trim()).filter((id) => allowed.has(id))); +} + +export function hasAnyCitation(item) { + return Boolean(item?.citation_card_ids?.length || item?.citation_source_ids?.length); +} + +export function sourceLabelsForIds(sources = [], ids = []) { + const byId = new Map(sources.map((source) => [source.id, source.label || source.url || source.id])); + return ids.map((id) => byId.get(id)).filter(Boolean); +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/providers/dataProProvider.js b/demohouse/sales-intelligence-workbench/backend/src/providers/dataProProvider.js new file mode 100644 index 00000000..28b2da99 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/providers/dataProProvider.js @@ -0,0 +1,352 @@ +import { createEnvReader } from "../config/runtimeEnv.js"; +import { executeProviderCall, providerFailure, providerSuccess } from "./providerResult.js"; + +const DEFAULT_MCP_URL = "https://datapro.hqd.cn-beijing.volces.com/mcp"; +const DEFAULT_TIMEOUT_MS = 45000; + +function enabled(value) { + return ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase()); +} + +function companyContext(object = {}) { + return [ + object.name, + object.industry, + object.location, + object.business_scope, + object.businessScope, + ...(Array.isArray(object.tags) ? object.tags : []), + ].filter(Boolean).join(" "); +} + +function appendUniqueQuery(target, item) { + if (!item?.label || !item?.query) return; + if (target.some((existing) => existing.label === item.label)) return; + target.push(item); +} + +function parseMcpPayload(text) { + if (!text) return {}; + if (text.startsWith("event:")) { + const line = text.split(/\r?\n/).find((item) => item.startsWith("data:")); + return line ? JSON.parse(line.slice(5).trim()) : {}; + } + return JSON.parse(text); +} + +function extractTextContent(result) { + const content = result?.content; + if (!Array.isArray(content)) return ""; + return content + .map((item) => { + if (typeof item?.text === "string") return item.text; + if (item?.type === "json" || item?.json) return JSON.stringify(item.json || item); + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +function firstJsonObject(text) { + const trimmed = String(text || "").trim(); + if (!trimmed) return null; + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start < 0 || end <= start) return null; + try { + return JSON.parse(trimmed.slice(start, end + 1)); + } catch { + return null; + } +} + +function summarizeText(text, maxLength = 4000) { + return String(text || "") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxLength); +} + +function isPrimitiveValue(value) { + return ["string", "number", "boolean"].includes(typeof value); +} + +function cleanValue(value, maxLength = 160) { + if (value === undefined || value === null || value === "") return ""; + if (!isPrimitiveValue(value)) return ""; + return String(value).replace(/\s+/g, " ").trim().slice(0, maxLength); +} + +function summarizeDataItem(item) { + if (!item || typeof item !== "object") return ""; + const preferredKeys = [ + "公司名称", + "企业名称", + "统一社会信用代码", + "注册号", + "法人姓名", + "法定代表人", + "公司组织类型", + "注册资本", + "注册地址", + "成立日期", + "经营状态", + "经营范围", + "业务范围", + "主营业务", + "风险类型", + "案件类型", + "案件名称", + "案号", + "案由", + "涉案金额", + "立案日期", + "开庭日期", + "处罚决定日期", + "处罚事由", + "处罚结果", + "被执行人", + "原告", + "被告", + "标题", + "公告名称", + "发布时间", + "发布日期", + "中标金额", + "招标人", + "中标人", + "项目名称", + ]; + const parts = []; + for (const key of preferredKeys) { + const value = cleanValue( + item[key], + /经营范围|业务范围|主营业务|案由|处罚事由|处罚结果/.test(key) ? 360 : 180, + ); + if (value) parts.push(`${key}:${value}`); + if (parts.length >= 12) break; + } + if (!parts.length) { + for (const [key, value] of Object.entries(item)) { + if (/^(?:id|trace[_-]?id|request[_-]?id|企业ID|关联主键)$/i.test(key)) continue; + const itemText = cleanValue(value, 180); + if (!itemText) continue; + parts.push(`${key}:${itemText}`); + if (parts.length >= 12) break; + } + } + return parts.join(";"); +} + +function summarizeParsedResult(parsed, fallbackText) { + const items = Array.isArray(parsed?.items) ? parsed.items : []; + if (items.length) { + return items + .slice(0, 5) + .map(summarizeDataItem) + .filter(Boolean) + .join("\n") + .slice(0, 4000); + } + const message = cleanValue(parsed?.msg || parsed?.message, 160); + if (message && Number(parsed?.code ?? 0) === 0) return `DataPro 返回成功:${message}`; + return summarizeText(fallbackText); +} + +export class DataProProvider { + constructor(options = {}) { + this.env = options.env || createEnvReader(); + this.fetchImpl = options.fetchImpl || globalThis.fetch; + } + + get apiKey() { + return this.env.value("DATAPRO_API_KEY") || this.env.value("AGENT_PLAN_API_KEY"); + } + + get mcpUrl() { + return this.env.value("DATAPRO_MCP_URL", DEFAULT_MCP_URL); + } + + get runEnabled() { + return enabled(this.env.value("DATAPRO_RUN_ENABLED", "false")); + } + + get maxSources() { + return Math.max(1, Math.min(this.env.number("DATAPRO_MAX_SOURCES", 4), 5)); + } + + get timeoutMs() { + return Math.max(1000, this.env.number("DATAPRO_TIMEOUT_MS", DEFAULT_TIMEOUT_MS)); + } + + get maxRetries() { + return Math.max(0, Math.min(this.env.number("DATAPRO_MAX_RETRIES", 1), 2)); + } + + isConfigured() { + return Boolean(this.apiKey && this.mcpUrl); + } + + isRunEnabled() { + return this.isConfigured() && this.runEnabled; + } + + buildCompanyQuery(object) { + return [ + object.name, + "企业工商信息", + "统一社会信用代码", + "注册资本", + "经营范围", + "知识产权", + "软件著作权", + ].join(" "); + } + + planDossierQueries(object, options = {}) { + const name = cleanValue(object?.name, 200); + if (!name) return []; + const context = companyContext(object); + const maxSources = Math.max( + 1, + Math.min(Number(options.maxSources || this.maxSources) || this.maxSources, 5), + ); + const queries = []; + const businessQuery = { + label: "企业工商数据库", + purpose: "主体、经营与知识产权核验", + query: `${name} 企业工商数据 基本信息 经营状况 经营范围 知识产权 专利`, + }; + + appendUniqueQuery(queries, businessQuery); + + appendUniqueQuery(queries, { + label: "企业风险数据库", + purpose: "风险与关注事项核验", + query: `${name} 企业风险数据 司法诉讼 行政处罚 失信被执行 经营异常 限制高消费`, + }); + + if (/整车|汽车制造|新能源汽车|乘用车|商用车|车企/.test(context)) { + appendUniqueQuery(queries, { + label: "汽车销量数据库", + purpose: "汽车市场与销量变化核验", + query: `${name} 汽车销量数据库 最新月度销量 品牌 车系 厂商 同比 环比`, + }); + } + + if (/股份有限公司|上市|证券|银行|金融|保险|基金|期货|信托/.test(context)) { + appendUniqueQuery(queries, { + label: "金融数据库", + purpose: "上市与财务信息核验", + query: `${name} 金融数据库 证券代码 最新财务指标 营业收入 净利润 市值 公告`, + }); + } + + if (/科研|研究院|高校|生物医药|制药|医疗器械|半导体|人工智能/.test(context)) { + appendUniqueQuery(queries, { + label: "科研学术数据搜索服务", + purpose: "技术与科研能力核验", + query: `${name} 科研学术数据 论文 专利 技术方向 研发成果`, + }); + } + + return queries.slice(0, maxSources); + } + + async callTool(query) { + if (!this.isConfigured()) { + return providerFailure("datapro", { code: "missing_config", message: "AGENT_PLAN_API_KEY or DATAPRO_MCP_URL is not configured." }); + } + + return executeProviderCall( + () => this.callToolOnce(query), + { max_retries: this.maxRetries }, + ); + } + + async callToolOnce(query) { + const startedAt = Date.now(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + let response; + let payload; + try { + response = await this.fetchImpl(this.mcpUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "X-Agent-Plan-Key": this.apiKey, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: `datapro-${Date.now()}`, + method: "tools/call", + params: { + name: "dataPro_search", + arguments: { query }, + }, + }), + signal: controller.signal, + }); + payload = parseMcpPayload(await response.text()); + } catch (error) { + return providerFailure("datapro", { + code: error.name === "AbortError" ? "timeout" : "network_error", + message: error.name === "AbortError" ? "DataPro request timed out." : error.message, + }, { latency_ms: Date.now() - startedAt }); + } finally { + clearTimeout(timeout); + } + + if (!response.ok || payload?.error) { + return providerFailure("datapro", { + code: payload?.error?.code || "provider_error", + message: payload?.error?.message || `HTTP ${response.status}`, + }, { + http_status: response.status, + latency_ms: Date.now() - startedAt, + }); + } + + const result = payload.result || {}; + const text = extractTextContent(result); + const parsed = firstJsonObject(text); + const traceId = parsed?.trace_id || parsed?.traceId || parsed?.data?.trace_id || null; + const parsedCode = parsed?.code ?? parsed?.Code ?? parsed?.data?.code; + const isError = Boolean(result.isError || parsed?.isError || (parsedCode !== undefined && Number(parsedCode) !== 0)); + if (isError) { + return providerFailure("datapro", { + code: parsedCode !== undefined ? String(parsedCode) : "tool_error", + message: parsed?.msg || parsed?.message || summarizeText(text, 240) || "DataPro tool returned an error.", + }, { + request_id: traceId, + raw_ref: traceId ? `datapro:${traceId}` : null, + latency_ms: Date.now() - startedAt, + }); + } + + return providerSuccess("datapro", { + query, + request_id: traceId, + raw_ref: traceId ? `datapro:${traceId}` : null, + latency_ms: Date.now() - startedAt, + text, + parsed, + item_summaries: (Array.isArray(parsed?.items) ? parsed.items : []) + .slice(0, 5) + .map(summarizeDataItem) + .filter(Boolean), + summary: summarizeParsedResult(parsed, text), + }); + } + + async queryCompanyFacts(object) { + const query = this.buildCompanyQuery(object); + return this.callTool(query); + } +} + +export function createDataProProvider(options = {}) { + return new DataProProvider(options); +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/providers/modelProvider.js b/demohouse/sales-intelligence-workbench/backend/src/providers/modelProvider.js new file mode 100644 index 00000000..b275f7c9 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/providers/modelProvider.js @@ -0,0 +1,699 @@ +import { createEnvReader } from "../config/runtimeEnv.js"; +import { collectCitationContext, filterCitationIds, hasAnyCitation, sourceLabelsForIds } from "./citationValidator.js"; +import { + executeProviderCall, + providerFailure, + providerSuccess, +} from "./providerResult.js"; + +const DEFAULT_BASE_URL = "https://ark.cn-beijing.volces.com/api/plan/v3"; +const DEFAULT_MODEL_NAME = "ark-code-latest"; +const DEFAULT_TIMEOUT_MS = 90000; +const MAX_INVALID_JSON_CONTENT_LENGTH = 30000; + +function enabled(value) { + return ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase()); +} + +function stripJsonFence(content) { + const text = String(content || "").trim(); + const unfenced = text.startsWith("```") + ? text + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```$/i, "") + .trim() + : text; + + for (let start = 0; start < unfenced.length; start += 1) { + const opening = unfenced[start]; + if (opening !== "{" && opening !== "[") continue; + const stack = [opening]; + let inString = false; + let escaped = false; + for (let index = start + 1; index < unfenced.length; index += 1) { + const character = unfenced[index]; + if (inString) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === "\"") inString = false; + continue; + } + if (character === "\"") { + inString = true; + continue; + } + if (character === "{" || character === "[") { + stack.push(character); + continue; + } + if (character !== "}" && character !== "]") continue; + const expected = character === "}" ? "{" : "["; + if (stack.at(-1) !== expected) break; + stack.pop(); + if (!stack.length) return unfenced.slice(start, index + 1); + } + } + + if (unfenced.startsWith("{") || unfenced.startsWith("[")) return unfenced; + return unfenced; +} + +function normalizeError(payload) { + const error = payload?.error || payload?.ResponseMetadata?.Error || payload?.Error || null; + if (!error) return null; + return { + code: error.code || error.Code || "provider_error", + message: error.message || error.Message || "Model provider returned an error.", + }; +} + +function conciseSource(source) { + return { + id: source.id, + type: source.type, + label: source.label, + url: source.url, + snippet: source.snippet || "", + summary: source.summary || "", + provider: source.provider, + provider_mode: source.provider_mode, + }; +} + +function baselineForPrompt(baseline) { + return { + id: baseline.id, + dimension: baseline.dimension || baseline.title, + title: baseline.title || baseline.dimension, + value: baseline.value, + source_ids: baseline.source_ids || [], + }; +} + +function asString(value, fallback = "") { + return String(value ?? fallback).trim(); +} + +function stripAndParseJson(content) { + return JSON.parse(stripJsonFence(content)); +} + +function invalidJsonContent(content) { + return String(content || "") + .trim() + .slice(0, MAX_INVALID_JSON_CONTENT_LENGTH); +} + +function extractResponseText(payload) { + if (typeof payload?.output_text === "string") return payload.output_text; + const texts = []; + for (const output of payload?.output || []) { + if (typeof output?.text === "string") texts.push(output.text); + for (const content of output?.content || []) { + if (typeof content?.text === "string") texts.push(content.text); + else if (typeof content?.text?.value === "string") texts.push(content.text.value); + } + } + return texts.join(""); +} + +function normalizeUsage(usage) { + if (!usage || typeof usage !== "object") return null; + const promptTokens = Number(usage.prompt_tokens ?? usage.input_tokens); + const completionTokens = Number(usage.completion_tokens ?? usage.output_tokens); + const explicitTotal = Number(usage.total_tokens); + const totalTokens = Number.isFinite(explicitTotal) + ? explicitTotal + : (Number.isFinite(promptTokens) && Number.isFinite(completionTokens) + ? promptTokens + completionTokens + : NaN); + const reasoningTokens = Number( + usage.reasoning_tokens + ?? usage.output_tokens_details?.reasoning_tokens + ?? usage.completion_tokens_details?.reasoning_tokens, + ); + const normalized = {}; + if (Number.isFinite(promptTokens)) normalized.prompt_tokens = promptTokens; + if (Number.isFinite(completionTokens)) normalized.completion_tokens = completionTokens; + if (Number.isFinite(totalTokens)) normalized.total_tokens = totalTokens; + if (Number.isFinite(reasoningTokens)) normalized.reasoning_tokens = reasoningTokens; + return Object.keys(normalized).length ? normalized : null; +} + +function responseStatusFailure(payload) { + const status = String(payload?.status || "").trim().toLowerCase(); + if (!status || status === "completed") return null; + if (status === "incomplete") { + const reason = String(payload?.incomplete_details?.reason || "unknown").trim(); + return { + code: "incomplete_response", + message: `Model response was incomplete (${reason}).`, + retryable: reason === "max_output_tokens", + }; + } + if (status === "failed") { + return { + code: "response_failed", + message: String(payload?.error?.message || "Model response failed."), + retryable: false, + }; + } + return { + code: "unexpected_response_status", + message: `Model response ended with unexpected status: ${status}.`, + retryable: false, + }; +} + +function matchingFunctionCalls(payload, functionName) { + return (Array.isArray(payload?.output) ? payload.output : []) + .filter((item) => ["function_call", "function_tool_call"].includes(String(item?.type || ""))) + .filter((item) => String(item?.name || "") === functionName); +} + +export class ModelProvider { + constructor(options = {}) { + this.env = options.env || createEnvReader(); + this.fetchImpl = options.fetchImpl || globalThis.fetch; + this.sleep = options.sleep; + } + + get apiKey() { + return this.env.value("MODEL_API_KEY") + || this.env.value("AGENT_PLAN_API_KEY") + || this.env.value("ARK_API_KEY") + || this.env.value("VOLCENGINE_ARK_API_KEY"); + } + + get baseUrl() { + return this.env.value("MODEL_BASE_URL", DEFAULT_BASE_URL).replace(/\/$/, ""); + } + + get modelName() { + return this.env.value("MODEL_NAME", DEFAULT_MODEL_NAME); + } + + get runEnabled() { + return enabled(this.env.value("MODEL_RUN_ENABLED", "false")); + } + + get maxCards() { + return Math.max(1, Math.min(this.env.number("MODEL_MAX_CARDS", 2), 5)); + } + + get maxTokens() { + return Math.max(200, Math.min(this.env.number("MODEL_MAX_TOKENS", 700), 2000)); + } + + get timeoutMs() { + return Math.max(5000, Math.min(this.env.number("MODEL_TIMEOUT_MS", DEFAULT_TIMEOUT_MS), 300000)); + } + + get maxRetries() { + return Math.max(0, Math.min(this.env.number("MODEL_MAX_RETRIES", 1), 2)); + } + + isConfigured() { + return Boolean(this.apiKey && this.baseUrl && this.modelName); + } + + isRunEnabled() { + return this.isConfigured() && this.runEnabled; + } + + async generateChangeCards(input) { + if (!this.isConfigured()) { + return providerFailure("model", { code: "missing_config", message: "AGENT_PLAN_API_KEY, MODEL_BASE_URL or MODEL_NAME is not configured." }); + } + + const allowedSourceIds = new Set((input.sources || []).map((source) => source.id).filter(Boolean)); + if (!allowedSourceIds.size) { + return providerFailure("model", { code: "missing_sources", message: "At least one source is required for model generation." }); + } + + const result = await this.callJson({ + operation: "change_cards", + maxTokens: this.maxTokens, + system: [ + "你是竞争变化卡生成器。只输出 JSON,不要输出 Markdown。", + "只能根据用户提供的 baseline 和 sources 判断,不能补充外部事实。", + "如果证据足以和 baseline 对比,输出候选变化卡。", + "如果证据相关但不足以确定变化,也输出低置信度候选卡,并在 after 中写明需要人工核验。", + "只有 sources 明显与对象无关时,才返回空 cards,并写 note。", + "每张 card 必须引用至少一个给定 source id。", + ].join("\n"), + payload: { + task: "基于真实来源生成候选变化卡", + output_schema: { + cards: [ + { + dimension: "变化维度,例如 价格页 / 官网新闻 / 文档站 / 企业主体 / 知识产权", + title: "一句话标题", + before: "历史基线或未知状态", + after: "基于 sources 可支持的候选变化描述", + confidence: "高/中/低", + source_ids: ["必须来自 sources[].id"], + }, + ], + note: "证据不足或补充说明", + }, + rules: [ + `最多输出 ${this.maxCards} 张 card`, + "不要使用未提供的 source_id", + "不要把搜索结果标题直接当作确定事实,无法确认时写成候选变化或待核验", + "如果 sources 与 baseline 无法比较,但来源与对象相关,可以输出低置信度候选卡", + ], + object: { + id: input.object.id, + name: input.object.name, + object_type: input.object.object_type, + summary: input.object.summary, + }, + baseline: (input.object.baseline || []).map(baselineForPrompt), + sources: (input.sources || []).map(conciseSource), + }, + }); + if (!result.ok) return result; + + const validation = this.validateCards(result.parsed, allowedSourceIds); + return providerSuccess("model", { + request_id: result.request_id, + model: this.modelName, + latency_ms: result.latency_ms, + raw_ref: result.raw_ref, + cards: validation.cards, + note: asString(result.parsed.note), + validation_errors: validation.errors, + usage: result.usage, + }); + } + + async callJson({ system, payload, maxTokens, operation = "model" }) { + if (!this.isConfigured()) { + return providerFailure("model", { code: "missing_config", message: "AGENT_PLAN_API_KEY, MODEL_BASE_URL or MODEL_NAME is not configured." }); + } + + const body = { + model: this.modelName, + instructions: system, + input: JSON.stringify(payload), + max_output_tokens: maxTokens || this.maxTokens, + thinking: { type: "disabled" }, + text: { format: { type: "json_object" } }, + }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + const startedAt = Date.now(); + let response; + let providerPayload; + try { + response = await this.fetchImpl(`${this.baseUrl}/responses`, { + method: "POST", + headers: { + "Authorization": `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + const text = await response.text(); + providerPayload = text ? JSON.parse(text) : {}; + } catch (error) { + return providerFailure("model", { + code: error.name === "AbortError" ? "timeout" : "network_error", + message: error.name === "AbortError" ? `${operation} request timed out.` : error.message, + }, { latency_ms: Date.now() - startedAt }); + } finally { + clearTimeout(timeout); + } + + const providerError = normalizeError(providerPayload); + const requestId = providerPayload?.id || providerPayload?.ResponseMetadata?.RequestId || null; + if (!response.ok || providerError) { + return providerFailure("model", providerError || { code: "http_error", message: `HTTP ${response.status}` }, { + http_status: response.status, + request_id: requestId, + latency_ms: Date.now() - startedAt, + }); + } + + const content = extractResponseText(providerPayload); + try { + return providerSuccess("model", { + request_id: requestId, + model: this.modelName, + latency_ms: Date.now() - startedAt, + raw_ref: requestId ? `model:${requestId}` : null, + parsed: stripAndParseJson(content), + usage: normalizeUsage(providerPayload?.usage), + }); + } catch (error) { + return providerFailure("model", { code: "invalid_json", message: `Model returned invalid JSON: ${error.message}` }, { + request_id: requestId, + raw_ref: requestId ? `model:${requestId}` : null, + latency_ms: Date.now() - startedAt, + invalid_content: invalidJsonContent(content), + }); + } + } + + async callRequiredFunction(request = {}) { + return executeProviderCall( + () => this.callRequiredFunctionOnce(request), + { + max_retries: this.maxRetries, + base_delay_ms: 1200, + sleep: this.sleep, + }, + ); + } + + async callRequiredFunctionOnce({ + system, + payload, + functionName, + functionDescription, + parameters, + maxTokens, + operation = "model_function", + }) { + if (!this.isConfigured()) { + return providerFailure("model", { + code: "missing_config", + message: "AGENT_PLAN_API_KEY, MODEL_BASE_URL or MODEL_NAME is not configured.", + }); + } + + const body = { + model: this.modelName, + instructions: system, + input: JSON.stringify(payload), + max_output_tokens: maxTokens || this.maxTokens, + thinking: { type: "disabled" }, + store: false, + tools: [{ + type: "function", + name: functionName, + description: functionDescription, + strict: true, + parameters, + }], + tool_choice: "required", + }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + const startedAt = Date.now(); + let response; + let providerPayload; + try { + response = await this.fetchImpl(`${this.baseUrl}/responses`, { + method: "POST", + headers: { + "Authorization": `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + const text = await response.text(); + providerPayload = text ? JSON.parse(text) : {}; + } catch (error) { + return providerFailure("model", { + code: error.name === "AbortError" ? "timeout" : "network_error", + message: error.name === "AbortError" ? `${operation} request timed out.` : error.message, + }, { latency_ms: Date.now() - startedAt }); + } finally { + clearTimeout(timeout); + } + + const providerError = normalizeError(providerPayload); + const requestId = providerPayload?.id || providerPayload?.ResponseMetadata?.RequestId || null; + if (!response.ok || providerError) { + return providerFailure("model", providerError || { + code: "http_error", + message: `HTTP ${response.status}`, + }, { + http_status: response.status, + request_id: requestId, + latency_ms: Date.now() - startedAt, + }); + } + + const statusFailure = responseStatusFailure(providerPayload); + if (statusFailure) { + return providerFailure("model", statusFailure, { + request_id: requestId, + raw_ref: requestId ? `model:${requestId}` : null, + latency_ms: Date.now() - startedAt, + usage: normalizeUsage(providerPayload?.usage), + }); + } + + const calls = matchingFunctionCalls(providerPayload, functionName); + if (!calls.length) { + const anyFunctionCall = (Array.isArray(providerPayload?.output) ? providerPayload.output : []) + .some((item) => ["function_call", "function_tool_call"].includes(String(item?.type || ""))); + return providerFailure("model", { + code: anyFunctionCall ? "unexpected_function_call" : "missing_function_call", + message: anyFunctionCall + ? `Model called a function other than ${functionName}.` + : `Model did not call required function ${functionName}.`, + }, { + request_id: requestId, + raw_ref: requestId ? `model:${requestId}` : null, + latency_ms: Date.now() - startedAt, + usage: normalizeUsage(providerPayload?.usage), + }); + } + if (calls.length !== 1) { + return providerFailure("model", { + code: "unexpected_function_call", + message: `Model called required function ${functionName} ${calls.length} times.`, + }, { + request_id: requestId, + raw_ref: requestId ? `model:${requestId}` : null, + latency_ms: Date.now() - startedAt, + usage: normalizeUsage(providerPayload?.usage), + }); + } + + try { + return providerSuccess("model", { + request_id: requestId, + model: providerPayload?.model || this.modelName, + latency_ms: Date.now() - startedAt, + raw_ref: requestId ? `model:${requestId}` : null, + parsed: JSON.parse(String(calls[0].arguments || "")), + function_call_id: calls[0].call_id || calls[0].id || null, + usage: normalizeUsage(providerPayload?.usage), + }); + } catch (error) { + return providerFailure("model", { + code: "invalid_function_arguments", + message: `Model returned invalid function arguments: ${error.message}`, + }, { + request_id: requestId, + raw_ref: requestId ? `model:${requestId}` : null, + latency_ms: Date.now() - startedAt, + usage: normalizeUsage(providerPayload?.usage), + }); + } + } + + async generateReport({ scope, object, cards, sources, visualAsset = null }) { + const result = await this.callJson({ + operation: "report", + maxTokens: Math.max(this.maxTokens, 1000), + system: [ + "你是竞争变化报告生成器。只输出 JSON,不要输出 Markdown。", + "你只能基于 confirmed_cards、sources 和 visual_asset 生成报告。", + "每个实质性结论都必须引用 citation_card_ids 或 citation_source_ids。", + "证据不足时写入 risks 或 uncertainty,不能补编事实。", + ].join("\n"), + payload: { + task: "生成竞争变化追踪报告", + output_schema: { + summary: "一句话到三句话摘要", + sections: [ + { + title: "章节标题", + items: [ + { + text: "结论或说明", + citation_card_ids: ["必须来自 confirmed_cards[].id"], + citation_source_ids: ["必须来自 sources[].id"], + }, + ], + }, + ], + risks: [ + { + text: "风险或不确定性", + citation_card_ids: [], + citation_source_ids: [], + }, + ], + next_steps: ["后续建议"], + }, + scope, + object, + confirmed_cards: cards, + sources, + visual_asset: visualAsset ? { + id: visualAsset.id, + type: visualAsset.type, + title: visualAsset.title, + provider: visualAsset.provider, + provider_mode: visualAsset.provider_mode, + } : null, + }, + }); + if (!result.ok) return result; + const validation = this.validateReport(result.parsed, cards, sources); + return { + ...result, + content_json: validation.content_json, + validation_errors: validation.errors, + }; + } + + validateReport(parsed, cards, sources) { + const { cardIds, sourceIds } = collectCitationContext(cards, sources); + const errors = []; + const sections = []; + for (const [sectionIndex, section] of (Array.isArray(parsed?.sections) ? parsed.sections : []).entries()) { + const items = []; + for (const [itemIndex, item] of (Array.isArray(section?.items) ? section.items : []).entries()) { + const normalized = { + text: asString(item.text).slice(0, 420), + citation_card_ids: filterCitationIds(item.citation_card_ids, cardIds), + citation_source_ids: filterCitationIds(item.citation_source_ids, sourceIds), + }; + if (!normalized.text) { + errors.push(`sections[${sectionIndex}].items[${itemIndex}].text 缺失`); + continue; + } + if (!hasAnyCitation(normalized)) { + errors.push(`sections[${sectionIndex}].items[${itemIndex}] 缺少有效引用`); + continue; + } + items.push(normalized); + } + if (items.length) { + sections.push({ + title: asString(section.title, "报告章节").slice(0, 48), + items, + }); + } + } + + const risks = (Array.isArray(parsed?.risks) ? parsed.risks : []) + .map((risk) => ({ + text: asString(risk.text || risk).slice(0, 240), + citation_card_ids: filterCitationIds(risk.citation_card_ids, cardIds), + citation_source_ids: filterCitationIds(risk.citation_source_ids, sourceIds), + })) + .filter((risk) => risk.text); + + return { + errors, + content_json: { + summary: asString(parsed?.summary, "基于已确认变化生成报告。").slice(0, 600), + sections, + risks, + next_steps: (Array.isArray(parsed?.next_steps) ? parsed.next_steps : []).map((item) => asString(item).slice(0, 180)).filter(Boolean).slice(0, 5), + }, + }; + } + + async generateQaAnswer({ scope, question, cards, sources, assets = [], excerpts = [] }) { + const result = await this.callJson({ + operation: "qa", + maxTokens: Math.max(this.maxTokens, 700), + system: [ + "你是资料问答助手。只输出 JSON,不要输出 Markdown。", + "你只能基于 confirmed_cards、sources、reports 和 excerpts 回答。", + "如果资料不足,answer 里明确说当前资料不足。", + "回答必须引用有效 citation_card_ids 或 citation_source_ids,资料不足回答也要引用相关资料或留空并说明原因。", + ].join("\n"), + payload: { + task: "基于当前范围已确认资料回答问题", + output_schema: { + answer: "回答文本", + citation_card_ids: ["必须来自 confirmed_cards[].id"], + citation_source_ids: ["必须来自 sources[].id"], + insufficient: false, + }, + question, + scope, + confirmed_cards: cards, + sources, + reports: assets.filter((asset) => asset.type === "report").map((asset) => ({ + id: asset.id, + title: asset.title, + summary: asset.content_json?.summary || "", + })), + excerpts, + }, + }); + if (!result.ok) return result; + const validation = this.validateQaAnswer(result.parsed, cards, sources); + return { + ...result, + answer: validation.answer, + validation_errors: validation.errors, + }; + } + + validateQaAnswer(parsed, cards, sources) { + const { cardIds, sourceIds } = collectCitationContext(cards, sources); + const answer = { + text: asString(parsed?.answer).slice(0, 900), + citation_card_ids: filterCitationIds(parsed?.citation_card_ids, cardIds), + citation_source_ids: filterCitationIds(parsed?.citation_source_ids, sourceIds), + insufficient: Boolean(parsed?.insufficient), + }; + answer.citations = sourceLabelsForIds(sources, answer.citation_source_ids); + const errors = []; + if (!answer.text) errors.push("answer 缺失"); + if (!answer.insufficient && !hasAnyCitation(answer)) errors.push("answer 缺少有效引用"); + return { answer, errors }; + } + + validateCards(parsed, allowedSourceIds) { + const cards = Array.isArray(parsed?.cards) ? parsed.cards : Array.isArray(parsed) ? parsed : []; + const errors = []; + const normalized = []; + for (const [index, card] of cards.slice(0, this.maxCards).entries()) { + const sourceIds = Array.isArray(card?.source_ids) + ? card.source_ids.map((id) => String(id).trim()).filter((id) => allowedSourceIds.has(id)) + : []; + if (!sourceIds.length) { + errors.push(`cards[${index}].source_ids 缺失或不在允许来源内`); + continue; + } + const title = asString(card.title); + const after = asString(card.after); + if (!title || !after) { + errors.push(`cards[${index}].title/after 缺失`); + continue; + } + const confidence = ["高", "中", "低"].includes(asString(card.confidence)) ? asString(card.confidence) : "中"; + normalized.push({ + dimension: asString(card.dimension, "公开来源"), + title: title.slice(0, 80), + before: asString(card.before, "历史基线未记录该候选变化。").slice(0, 240), + after: after.slice(0, 320), + confidence, + source_ids: [...new Set(sourceIds)], + }); + } + return { cards: normalized, errors }; + } +} + +export function createModelProvider(options = {}) { + return new ModelProvider(options); +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/providers/openVikingProvider.js b/demohouse/sales-intelligence-workbench/backend/src/providers/openVikingProvider.js new file mode 100644 index 00000000..6409dd13 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/providers/openVikingProvider.js @@ -0,0 +1,755 @@ +import { execFile } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { promisify } from "node:util"; +import { createEnvReader } from "../config/runtimeEnv.js"; +import { providerFailure, providerSuccess } from "./providerResult.js"; + +const execFileAsync = promisify(execFile); + +function truthy(value) { + return ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase()); +} + +function truncate(text, maxLength = 12000) { + const value = String(text || ""); + return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value; +} + +function parseJsonOutput(stdout) { + const output = String(stdout || "").trim(); + if (!output) return null; + try { + return JSON.parse(output); + } catch { + const objectStart = output.indexOf("{"); + const arrayStart = output.indexOf("["); + const candidates = [objectStart, arrayStart].filter((index) => index >= 0); + if (!candidates.length) return null; + const start = Math.min(...candidates); + try { + return JSON.parse(output.slice(start)); + } catch { + return null; + } + } +} + +function defaultCliPath() { + const homeCli = process.env.HOME ? join(process.env.HOME, "bin", "ov") : ""; + if (homeCli && existsSync(homeCli)) return homeCli; + return "ov"; +} + +function defaultCliConfigPath() { + return process.env.HOME ? join(process.env.HOME, ".openviking", "ovcli.conf") : ""; +} + +function commandExists(command) { + const value = String(command || "").trim(); + if (!value) return false; + if (value.includes("/")) return existsSync(value); + return String(process.env.PATH || "") + .split(delimiter) + .filter(Boolean) + .some((directory) => existsSync(join(directory, value))); +} + +function readCliConfig(configPath) { + if (!configPath || !existsSync(configPath)) return {}; + try { + const parsed = JSON.parse(readFileSync(configPath, "utf8")); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +function sessionIdFromResult(result, fallback = "") { + const candidates = [ + result?.session_id, + result?.id, + result?.result?.session_id, + result?.result?.id, + ]; + return String(candidates.find((value) => value) || fallback || "").trim(); +} + +function sessionMessageText(message) { + const parts = Array.isArray(message?.parts) ? message.parts : []; + const partText = parts + .filter((part) => part?.type === "text" || typeof part?.text === "string") + .map((part) => String(part?.text || "")) + .join("\n") + .trim(); + return partText || String(message?.content || message?.text || "").trim(); +} + +function normalizeSessionContext(result) { + const context = result?.result && typeof result.result === "object" ? result.result : result || {}; + const messages = (Array.isArray(context?.messages) ? context.messages : []) + .map((message, index) => ({ + id: String(message?.id || `openviking-message-${index + 1}`), + role: ["assistant", "user"].includes(message?.role) ? message.role : "user", + text: sessionMessageText(message), + created_at: message?.created_at || message?.timestamp || null, + })) + .filter((message) => message.text); + return { + ...context, + latest_archive_overview: String( + context?.latest_archive_overview + || context?.archive_overview + || context?.overview + || "", + ).trim(), + messages, + }; +} + +function textResourceContent(result) { + const value = result?.result ?? result; + if (typeof value === "string") return value; + return String(value?.content || value?.text || value?.raw_content || "").trim(); +} + +function isSessionNotFound(result) { + const code = String(result?.error?.code || "").toLowerCase(); + const message = `${result?.error?.message || ""} ${result?.stderr || ""} ${result?.stdout || ""}`.toLowerCase(); + return Number(result?.http_status || 0) === 404 + || ["404", "not_found", "session_not_found"].includes(code) + || /not found|does not exist|不存在|未找到/.test(message); +} + +function uriSegment(value, fallback = "default") { + const normalized = String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized || fallback; +} + +export class OpenVikingProvider { + constructor(options = {}) { + this.env = options.env || createEnvReader(); + this.execFile = options.execFile || execFileAsync; + this.fetchImpl = options.fetchImpl || globalThis.fetch; + this.cliPath = this.env.value("OPENVIKING_CLI") || defaultCliPath(); + this.cliConfigPath = this.env.value("OPENVIKING_CLI_CONFIG") || defaultCliConfigPath(); + this.cliConfig = options.cliConfig || readCliConfig(this.cliConfigPath); + this.agentId = this.env.value("OPENVIKING_AGENT_ID") || this.cliConfig.agent_id || "default"; + this.timeoutMs = Math.min(300000, Math.max(5000, this.env.number("OPENVIKING_TIMEOUT_MS", 120000))); + this.findLimit = this.env.number("OPENVIKING_FIND_LIMIT", 3); + this.qaKeepRecentMessages = Math.max(0, Math.min( + 40, + this.env.number("OPENVIKING_QA_KEEP_RECENT_MESSAGES", 6), + )); + this.memoryUri = this.env.value("OPENVIKING_MEMORY_URI", ""); + this.salesRootUri = String(this.env.value("OPENVIKING_SALES_ROOT_URI", "viking://resources/sales-workbench") || "viking://resources/sales-workbench").replace(/\/$/, ""); + } + + get apiKey() { + return this.env.value("OPENVIKING_API_KEY") + || this.env.value("OPENVIKING_BEARER_TOKEN") + || this.cliConfig.api_key + || ""; + } + + get baseUrl() { + const raw = this.env.value("OPENVIKING_BASE_URL") + || this.env.value("OPENVIKING_URL") + || this.cliConfig.url + || ""; + return String(raw || "").replace(/\/mcp\/?$/, "").replace(/\/api\/v1\/?$/, "").replace(/\/$/, ""); + } + + isConfigured() { + return Boolean((this.baseUrl && this.apiKey) || commandExists(this.cliPath)); + } + + isRunEnabled() { + return truthy(this.env.value("OPENVIKING_RUN_ENABLED", "false")); + } + + salesWorkspaceUri({ workspaceId } = {}) { + return `${this.salesRootUri}/${uriSegment(workspaceId, "local-workspace")}`; + } + + salesCompanyUri({ workspaceId, companyId } = {}) { + return `${this.salesWorkspaceUri({ workspaceId })}/companies/${uriSegment(companyId, "unknown-company")}`; + } + + salesMaterialUri({ workspaceId, companyId, sourceId } = {}) { + return `${this.salesCompanyUri({ workspaceId, companyId })}/materials/${uriSegment(sourceId, "unknown-source")}.md`; + } + + salesDossierUri({ workspaceId, companyId, dossierId } = {}) { + return `${this.salesCompanyUri({ workspaceId, companyId })}/dossiers/${uriSegment(dossierId, "unknown-dossier")}.md`; + } + + salesSessionId({ workspaceId, companyId } = {}) { + return `sales-${uriSegment(workspaceId, "local-workspace")}-${uriSegment(companyId, "unknown-company")}`; + } + + async runCli(args) { + const startedAt = Date.now(); + const cliArgs = this.agentId && !args.includes("--agent-id") + ? ["--agent-id", this.agentId, ...args] + : args; + try { + const { stdout, stderr } = await this.execFile(this.cliPath, cliArgs, { + timeout: this.timeoutMs, + maxBuffer: 1024 * 1024, + env: { + ...process.env, + NO_COLOR: "1", + PYTHONIOENCODING: "utf-8", + }, + }); + return providerSuccess("openviking", { + stdout: truncate(stdout), + stderr: truncate(stderr, 2000), + latency_ms: Date.now() - startedAt, + }); + } catch (error) { + const timedOut = error.code === "ETIMEDOUT" || (error.killed && error.signal === "SIGTERM"); + return providerFailure("openviking", { + code: error.code === "ENOENT" ? "missing_cli" : timedOut ? "timeout" : "cli_error", + message: timedOut ? "OpenViking CLI request timed out." : truncate(error.message, 2000), + }, { + stdout: truncate(error.stdout, 2000), + stderr: truncate(error.stderr, 2000), + latency_ms: Date.now() - startedAt, + }); + } + } + + async callRest(path, body = {}, options = {}) { + if (!this.baseUrl || !this.apiKey) { + return providerFailure("openviking", { code: "missing_http_config", message: "OPENVIKING_BASE_URL and an OpenViking API Key are not configured." }); + } + const startedAt = Date.now(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + let response; + let payload; + try { + const method = String(options.method || "POST").toUpperCase(); + response = await this.fetchImpl(`${this.baseUrl}/api/v1${path}`, { + method, + headers: { + "Authorization": `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + "X-OpenViking-Agent": this.agentId, + }, + body: ["GET", "HEAD", "DELETE"].includes(method) ? undefined : JSON.stringify(body), + signal: controller.signal, + }); + const text = await response.text(); + payload = text ? JSON.parse(text) : {}; + } catch (error) { + return providerFailure("openviking", { + code: error.name === "AbortError" ? "timeout" : "network_error", + message: error.name === "AbortError" ? "OpenViking HTTP request timed out." : error.message, + }, { + latency_ms: Date.now() - startedAt, + }); + } finally { + clearTimeout(timeout); + } + + if (!response.ok || payload?.status === "error") { + return providerFailure("openviking", { + code: payload?.error?.code || "provider_error", + message: payload?.error?.message || `HTTP ${response.status}`, + }, { + http_status: response.status, + latency_ms: Date.now() - startedAt, + }); + } + + return providerSuccess("openviking", { + result: payload?.result ?? payload, + raw_ref: `openviking:http:${path}`, + latency_ms: Date.now() - startedAt, + }); + } + + async health() { + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking CLI is not configured." }); + } + const result = await this.runCli(["health", "-o", "json"]); + if (!result.ok) return result; + return providerSuccess("openviking", { + result: parseJsonOutput(result.stdout), + raw_ref: "openviking:health", + latency_ms: result.latency_ms, + }); + } + + buildConfirmedCardMemory({ scope, object, card, sources }) { + const sourceLines = (sources || []) + .slice(0, 5) + .map((source) => `- ${source.label || source.id}${source.url ? ` (${source.url})` : ""}`) + .join("\n"); + return [ + "竞争变化卡已被用户确认,需要作为长期记忆保存。", + `范围:${scope?.name || card.scope_id}`, + `对象:${object?.name || card.object_id}`, + `维度:${card.dimension}`, + `标题:${card.title}`, + `确认后的变化:${card.after}`, + `置信度:${card.confidence}`, + sourceLines ? `证据来源:\n${sourceLines}` : "", + `内部追踪:scope=${card.scope_id}; object=${card.object_id}; card=${card.id}; run=${card.run_id}`, + ].filter(Boolean).join("\n"); + } + + async rememberConfirmedCard(payload) { + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking CLI is not configured." }); + } + if (!this.isRunEnabled()) { + return providerFailure("openviking", { code: "disabled", message: "OPENVIKING_RUN_ENABLED is false." }, { skipped: true }); + } + + const content = this.buildConfirmedCardMemory(payload); + const message = JSON.stringify({ role: "user", content }); + const result = await this.runCli(["add-memory", message, "-o", "json"]); + if (!result.ok) return result; + + const parsed = parseJsonOutput(result.stdout); + return providerSuccess("openviking", { + raw_ref: `openviking:add-memory:${payload.card.id}`, + result: parsed, + summary: parsed?.result?.message || parsed?.message || "OpenViking memory write completed.", + latency_ms: result.latency_ms, + }); + } + + async storeMemory(messages) { + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking is not configured." }); + } + if (!this.isRunEnabled()) { + return providerFailure("openviking", { code: "disabled", message: "OPENVIKING_RUN_ENABLED is false." }, { skipped: true }); + } + const normalized = Array.isArray(messages) ? messages : [{ role: "user", content: String(messages || "") }]; + const result = await this.runCli(["add-memory", JSON.stringify(normalized), "-o", "json"]); + if (!result.ok) return result; + const parsed = parseJsonOutput(result.stdout); + return providerSuccess("openviking", { + raw_ref: `openviking:add-memory:${Date.now()}`, + result: parsed, + summary: parsed?.result?.message || parsed?.message || "OpenViking memory write completed.", + latency_ms: result.latency_ms, + }); + } + + async addResource(path, options = {}) { + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking is not configured." }); + } + if (!this.isRunEnabled()) { + return providerFailure("openviking", { code: "disabled", message: "OPENVIKING_RUN_ENABLED is false." }, { skipped: true }); + } + const args = ["add-resource", String(path), "-o", "json"]; + if (options.to) args.push("--to", String(options.to)); + if (options.parent) args.push("--parent", String(options.parent)); + if (options.reason) args.push("--reason", String(options.reason)); + if (options.instruction) args.push("--instruction", String(options.instruction)); + if (options.wait) args.push("--wait"); + const result = await this.runCli(args); + if (!result.ok) return result; + return providerSuccess("openviking", { + raw_ref: `openviking:add-resource:${path}`, + result: parseJsonOutput(result.stdout), + latency_ms: result.latency_ms, + }); + } + + async upsertTextResource({ uri, content, mode = "replace" } = {}) { + const targetUri = String(uri || "").trim(); + const text = String(content || "").trim(); + if (!targetUri || !text) { + return providerFailure("openviking", { code: "bad_request", message: "uri and content are required." }); + } + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking is not configured." }); + } + if (!this.isRunEnabled()) { + return providerFailure("openviking", { code: "disabled", message: "OPENVIKING_RUN_ENABLED is false." }, { skipped: true }); + } + const writeMode = mode === "create" ? "create" : "replace"; + const result = await this.runCli([ + "write", + targetUri, + "--content", + text, + "--mode", + writeMode, + "-o", + "json", + ]); + if (!result.ok) return result; + const parsed = parseJsonOutput(result.stdout); + const semanticStatus = String(parsed?.result?.semantic_status || "").trim(); + const vectorStatus = String(parsed?.result?.vector_status || "").trim(); + const processingStatus = [semanticStatus, vectorStatus].includes("queued") ? "queued" : "ready"; + return providerSuccess("openviking", { + uri: targetUri, + raw_ref: targetUri, + result: parsed, + processing_status: processingStatus, + summary: processingStatus === "queued" + ? "OpenViking resource accepted and queued for indexing." + : writeMode === "create" ? "OpenViking resource created." : "OpenViking resource updated.", + latency_ms: result.latency_ms, + }); + } + + async readTextResource(uri) { + const targetUri = String(uri || "").trim(); + if (!targetUri) { + return providerFailure("openviking", { code: "bad_request", message: "uri is required." }); + } + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking is not configured." }); + } + + if (this.baseUrl && this.apiKey) { + const result = await this.callRest( + `/content/read?uri=${encodeURIComponent(targetUri)}&raw=true`, + {}, + { method: "GET" }, + ); + if (!result.ok) return result; + return providerSuccess("openviking", { + uri: targetUri, + content: textResourceContent(result.result), + result: result.result, + raw_ref: targetUri, + latency_ms: result.latency_ms, + }); + } + + const result = await this.runCli(["read", targetUri, "-o", "json"]); + if (!result.ok) return result; + const parsed = parseJsonOutput(result.stdout); + return providerSuccess("openviking", { + uri: targetUri, + content: textResourceContent(parsed), + result: parsed?.result ?? parsed, + raw_ref: targetUri, + latency_ms: result.latency_ms, + }); + } + + async removeResource(uri) { + const targetUri = String(uri || "").trim(); + if (!targetUri) { + return providerFailure("openviking", { code: "bad_request", message: "uri is required." }); + } + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking is not configured." }); + } + if (!this.isRunEnabled()) { + return providerFailure("openviking", { code: "disabled", message: "OPENVIKING_RUN_ENABLED is false." }, { skipped: true }); + } + const result = await this.runCli(["rm", targetUri, "-o", "json"]); + if (!result.ok) return result; + return providerSuccess("openviking", { + uri: targetUri, + raw_ref: targetUri, + result: parseJsonOutput(result.stdout), + summary: "OpenViking resource removed.", + latency_ms: result.latency_ms, + }); + } + + async getSession(sessionId) { + const targetSessionId = String(sessionId || "").trim(); + if (!targetSessionId) { + return providerFailure("openviking", { code: "bad_request", message: "sessionId is required." }); + } + if (this.baseUrl && this.apiKey) { + const result = await this.callRest(`/sessions/${encodeURIComponent(targetSessionId)}`, {}, { method: "GET" }); + if (!result.ok) return result; + return providerSuccess("openviking", { + session_id: sessionIdFromResult(result.result, targetSessionId), + result: result.result, + raw_ref: `openviking:session:${targetSessionId}`, + latency_ms: result.latency_ms, + }); + } + + const result = await this.runCli(["session", "get", targetSessionId, "-o", "json"]); + if (!result.ok) return result; + const parsed = parseJsonOutput(result.stdout); + return providerSuccess("openviking", { + session_id: sessionIdFromResult(parsed, targetSessionId), + result: parsed?.result ?? parsed, + raw_ref: `openviking:session:${targetSessionId}`, + latency_ms: result.latency_ms, + }); + } + + async getSessionContext(sessionId, options = {}) { + const targetSessionId = String(sessionId || "").trim(); + if (!targetSessionId) { + return providerFailure("openviking", { code: "bad_request", message: "sessionId is required." }); + } + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking is not configured." }); + } + + if (this.baseUrl && this.apiKey) { + const tokenBudget = Math.max(0, Number(options.tokenBudget || 0)); + const query = tokenBudget ? `?token_budget=${Math.floor(tokenBudget)}` : ""; + const result = await this.callRest( + `/sessions/${encodeURIComponent(targetSessionId)}/context${query}`, + {}, + { method: "GET" }, + ); + if (!result.ok) return result; + const context = normalizeSessionContext(result.result); + return providerSuccess("openviking", { + session_id: targetSessionId, + context, + messages: context.messages, + latest_archive_overview: context.latest_archive_overview, + result: result.result, + raw_ref: `openviking:session:${targetSessionId}:context`, + latency_ms: result.latency_ms, + }); + } + + const result = await this.runCli([ + "session", + "get-session-context", + targetSessionId, + "-o", + "json", + ]); + if (!result.ok) return result; + const parsed = parseJsonOutput(result.stdout); + const context = normalizeSessionContext(parsed); + return providerSuccess("openviking", { + session_id: targetSessionId, + context, + messages: context.messages, + latest_archive_overview: context.latest_archive_overview, + result: parsed?.result ?? parsed, + raw_ref: `openviking:session:${targetSessionId}:context`, + latency_ms: result.latency_ms, + }); + } + + async createSession(preferredSessionId = "") { + const requestedSessionId = String(preferredSessionId || "").trim(); + if (this.baseUrl && this.apiKey) { + const body = requestedSessionId ? { session_id: requestedSessionId } : {}; + const result = await this.callRest("/sessions", body); + if (!result.ok) return result; + const sessionId = sessionIdFromResult(result.result, requestedSessionId); + if (!sessionId) { + return providerFailure("openviking", { + code: "invalid_response", + message: "OpenViking did not return a session_id.", + }); + } + return providerSuccess("openviking", { + session_id: sessionId, + created: true, + result: result.result, + raw_ref: `openviking:session:${sessionId}`, + latency_ms: result.latency_ms, + }); + } + + const result = await this.runCli(["session", "new", "-o", "json"]); + if (!result.ok) return result; + const parsed = parseJsonOutput(result.stdout); + const sessionId = sessionIdFromResult(parsed); + if (!sessionId) { + return providerFailure("openviking", { + code: "invalid_response", + message: "OpenViking CLI did not return a session_id.", + }); + } + return providerSuccess("openviking", { + session_id: sessionId, + created: true, + result: parsed?.result ?? parsed, + raw_ref: `openviking:session:${sessionId}`, + latency_ms: result.latency_ms, + }); + } + + async ensureSession(sessionId) { + const preferredSessionId = String(sessionId || "").trim(); + if (!preferredSessionId) return this.createSession(); + const existing = await this.getSession(preferredSessionId); + if (existing.ok) return { ...existing, created: false }; + if (!isSessionNotFound(existing)) return existing; + return this.createSession(preferredSessionId); + } + + async addSessionMessages(sessionId, messages) { + const normalized = (Array.isArray(messages) ? messages : []) + .map((message) => ({ + role: ["assistant", "user"].includes(message.role) ? message.role : "user", + content: String(message.content || message.text || "").trim(), + })) + .filter((message) => message.content); + if (!normalized.length) { + return providerFailure("openviking", { code: "bad_request", message: "messages are required." }); + } + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking is not configured." }); + } + if (!this.isRunEnabled()) { + return providerFailure("openviking", { code: "disabled", message: "OPENVIKING_RUN_ENABLED is false." }, { skipped: true }); + } + + const ensured = await this.ensureSession(sessionId); + if (!ensured.ok) return ensured; + const actualSessionId = ensured.session_id; + const results = []; + + if (this.baseUrl && this.apiKey) { + for (const message of normalized) { + const result = await this.callRest( + `/sessions/${encodeURIComponent(actualSessionId)}/messages`, + { + role: message.role, + parts: [{ type: "text", text: message.content }], + }, + ); + if (!result.ok) return result; + results.push(result.result); + } + } else { + for (const message of normalized) { + const result = await this.runCli([ + "session", + "add-message", + actualSessionId, + "--role", + message.role, + "--content", + message.content, + "-o", + "json", + ]); + if (!result.ok) return result; + results.push(parseJsonOutput(result.stdout)); + } + } + + return providerSuccess("openviking", { + session_id: actualSessionId, + created: Boolean(ensured.created), + raw_ref: `openviking:session:${actualSessionId}:messages`, + result: results, + }); + } + + async recordSessionUsed(sessionId, contexts = []) { + const uris = (contexts || []).map((item) => String(item || "").trim()).filter(Boolean); + if (!uris.length) return providerSuccess("openviking", { skipped: true, summary: "No OpenViking contexts used." }); + if (!this.isRunEnabled()) { + return providerFailure("openviking", { code: "disabled", message: "OPENVIKING_RUN_ENABLED is false." }, { skipped: true }); + } + const result = await this.callRest(`/sessions/${encodeURIComponent(sessionId)}/used`, { contexts: uris }); + return { + ...result, + raw_ref: result.ok ? `openviking:session:${sessionId}:used` : result.raw_ref, + }; + } + + async commitSession(sessionId, options = {}) { + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking is not configured." }); + } + if (!this.isRunEnabled()) { + return providerFailure("openviking", { code: "disabled", message: "OPENVIKING_RUN_ENABLED is false." }, { skipped: true }); + } + + const keepRecentCount = Math.max(0, Math.min( + 40, + Number.isFinite(Number(options.keepRecentCount)) + ? Math.floor(Number(options.keepRecentCount)) + : this.qaKeepRecentMessages, + )); + const rest = await this.callRest(`/sessions/${encodeURIComponent(sessionId)}/commit`, { + telemetry: false, + keep_recent_count: keepRecentCount, + }); + if (rest.ok || rest.error?.code !== "missing_http_config") { + return { + ...rest, + raw_ref: rest.ok ? `openviking:session:${sessionId}:commit` : rest.raw_ref, + }; + } + + const result = await this.runCli(["session", "commit", String(sessionId), "-o", "json"]); + if (!result.ok) return result; + return providerSuccess("openviking", { + raw_ref: `openviking:session:${sessionId}:commit`, + result: parseJsonOutput(result.stdout), + latency_ms: result.latency_ms, + }); + } + + async deleteSession(sessionId) { + const targetSessionId = String(sessionId || "").trim(); + if (!targetSessionId) { + return providerFailure("openviking", { code: "bad_request", message: "sessionId is required." }); + } + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking is not configured." }); + } + if (!this.isRunEnabled()) { + return providerFailure("openviking", { code: "disabled", message: "OPENVIKING_RUN_ENABLED is false." }, { skipped: true }); + } + if (!this.baseUrl || !this.apiKey) { + return providerFailure("openviking", { + code: "missing_http_config", + message: "Deleting an OpenViking session requires HTTP configuration or ~/.openviking/ovcli.conf.", + }); + } + const result = await this.callRest(`/sessions/${encodeURIComponent(targetSessionId)}`, {}, { method: "DELETE" }); + if (!result.ok) return result; + return providerSuccess("openviking", { + session_id: targetSessionId, + raw_ref: `openviking:session:${targetSessionId}:deleted`, + result: result.result, + latency_ms: result.latency_ms, + }); + } + + async findMemories(query, options = {}) { + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking CLI is not configured." }); + } + const args = ["find", String(query || ""), "--node-limit", String(options.limit || this.findLimit), "-o", "json"]; + const uri = options.uri || this.memoryUri; + if (uri) args.splice(2, 0, "--uri", uri); + const result = await this.runCli(args); + if (!result.ok) return result; + const parsed = parseJsonOutput(result.stdout); + + return providerSuccess("openviking", { + result: parsed?.result ?? parsed, + raw_ref: "openviking:find", + latency_ms: result.latency_ms, + }); + } +} + +export function createOpenVikingProvider(options = {}) { + return new OpenVikingProvider(options); +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/providers/providerResult.js b/demohouse/sales-intelligence-workbench/backend/src/providers/providerResult.js new file mode 100644 index 00000000..313e871e --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/providers/providerResult.js @@ -0,0 +1,98 @@ +const AUTH_CODES = new Set(["401", "403", "unauthorized", "forbidden", "invalid_api_key", "authentication_error"]); +const VALIDATION_CODES = new Set([ + "4003", + "bad_request", + "invalid_query", + "missing_sources", + "invalid_json", + "invalid_function_arguments", + "missing_function_call", + "unexpected_function_call", + "validation_error", +]); +const CONFIG_CODES = new Set(["missing_config", "missing_http_config", "missing_cli", "disabled", "provider_disabled"]); +const NETWORK_CODES = new Set(["network_error", "econnreset", "econnrefused", "enotfound"]); + +function normalizedCode(value) { + return String(value || "provider_error").trim().toLowerCase(); +} + +export function classifyProviderError(input = {}) { + const code = normalizedCode(input.code); + const httpStatus = Number(input.http_status || input.httpStatus || 0); + const message = String(input.message || "").toLowerCase(); + + if (CONFIG_CODES.has(code)) return { category: "configuration", retryable: false }; + if (AUTH_CODES.has(code) || httpStatus === 401 || httpStatus === 403 || /auth|api.?key|鉴权/.test(message)) { + return { category: "authentication", retryable: false }; + } + if (VALIDATION_CODES.has(code) || (httpStatus >= 400 && httpStatus < 422)) { + return { category: "validation", retryable: false }; + } + if (code === "timeout" || /timed? out|超时/.test(message)) return { category: "timeout", retryable: true }; + if (NETWORK_CODES.has(code)) return { category: "network", retryable: true }; + if (code === "429" || httpStatus === 429 || /rate.?limit|too many requests|限流/.test(message)) { + return { category: "rate_limit", retryable: true }; + } + if ( + httpStatus >= 500 + || /temporar|unavailable|service busy|internal (?:server )?error|暂时不可用|内部错误/.test(message) + ) { + return { category: "upstream", retryable: true }; + } + return { category: "unknown", retryable: false }; +} + +export function providerFailure(provider, error = {}, metadata = {}) { + const httpStatus = Number(metadata.http_status || error.http_status || 0) || undefined; + const classified = classifyProviderError({ + code: error.code, + message: error.message, + http_status: httpStatus, + }); + return { + ok: false, + provider, + provider_mode: "real", + ...metadata, + ...(httpStatus ? { http_status: httpStatus } : {}), + error: { + code: String(error.code || "provider_error"), + message: String(error.message || "Provider call failed."), + category: error.category || classified.category, + retryable: error.retryable ?? classified.retryable, + }, + }; +} + +export function providerSuccess(provider, data = {}) { + return { + ok: true, + provider, + provider_mode: "real", + ...data, + }; +} + +function defaultSleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +export async function executeProviderCall(operation, options = {}) { + const maxRetries = Math.max(0, Math.min(Number(options.max_retries || 0), 3)); + const baseDelayMs = Math.max(0, Number(options.base_delay_ms || 150)); + const sleep = options.sleep || defaultSleep; + let attempts = 0; + let result; + + while (attempts <= maxRetries) { + attempts += 1; + result = await operation(attempts); + if (result?.ok || !result?.error?.retryable || attempts > maxRetries) { + return { ...(result || {}), attempts }; + } + await sleep(baseDelayMs * attempts); + } + + return { ...(result || {}), attempts }; +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/providers/supabaseDataProvider.js b/demohouse/sales-intelligence-workbench/backend/src/providers/supabaseDataProvider.js new file mode 100644 index 00000000..6cf7de4a --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/providers/supabaseDataProvider.js @@ -0,0 +1,146 @@ +import { createEnvReader } from "../config/runtimeEnv.js"; + +function truthy(value) { + return ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase()); +} + +function normalizedBaseUrl(value) { + const base = String(value || "").trim().replace(/\/$/, ""); + if (!base) return ""; + return base.endsWith("/rest/v1") ? base : `${base}/rest/v1`; +} + +function apiError(response, body) { + const error = new Error(body?.message || body?.hint || `Supabase Data API returned HTTP ${response.status}.`); + error.code = body?.code || `http_${response.status}`; + error.details = body?.details || null; + error.http_status = response.status; + return error; +} + +export class SupabaseDataProvider { + constructor(options = {}) { + this.env = options.env || createEnvReader(); + this.fetch = options.fetchImpl || fetch; + this.baseUrl = normalizedBaseUrl(this.env.value("SUPABASE_API_URL")); + this.serviceRoleKey = this.env.value("SUPABASE_SERVICE_ROLE_KEY"); + this.timeoutMs = this.env.number("SUPABASE_DATA_API_TIMEOUT_MS", 15000); + this.runEnabled = truthy(this.env.value("SUPABASE_RUN_ENABLED", "false")); + } + + isConfigured() { + return Boolean(this.baseUrl && this.serviceRoleKey); + } + + isRunEnabled() { + return this.runEnabled; + } + + async request(path, options = {}) { + if (!this.isConfigured()) throw new Error("Supabase Data API is not configured."); + const url = new URL(`${this.baseUrl}/${String(path).replace(/^\//, "")}`); + for (const [name, value] of Object.entries(options.query || {})) { + if (value !== undefined && value !== null && value !== "") url.searchParams.set(name, String(value)); + } + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await this.fetch(url, { + method: options.method || "GET", + headers: { + Accept: "application/json", + apikey: this.serviceRoleKey, + Authorization: `Bearer ${this.serviceRoleKey}`, + ...(options.body !== undefined ? { "Content-Type": "application/json" } : {}), + ...(options.prefer ? { Prefer: options.prefer } : {}), + ...(options.headers || {}), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller.signal, + }); + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = { message: text.slice(0, 1000) }; + } + } + if (!response.ok) throw apiError(response, body); + return body; + } catch (error) { + if (error.name === "AbortError") { + const timeoutError = new Error(`Supabase Data API timed out after ${this.timeoutMs}ms.`); + timeoutError.code = "timeout"; + throw timeoutError; + } + throw error; + } finally { + clearTimeout(timeout); + } + } + + select(table, options = {}) { + return this.request(table, { + query: { + select: options.select || "*", + ...(options.filters || {}), + order: options.order || undefined, + limit: options.limit || undefined, + offset: options.offset || undefined, + }, + }); + } + + insert(table, rows, options = {}) { + return this.request(table, { + method: "POST", + body: rows, + prefer: options.returning === false ? "return=minimal" : "return=representation", + }); + } + + upsert(table, rows, options = {}) { + return this.request(table, { + method: "POST", + query: { on_conflict: options.onConflict || "id" }, + body: rows, + prefer: `resolution=merge-duplicates,${options.returning === false ? "return=minimal" : "return=representation"}`, + }); + } + + update(table, values, filters = {}, options = {}) { + return this.request(table, { + method: "PATCH", + query: filters, + body: values, + prefer: options.returning === false ? "return=minimal" : "return=representation", + }); + } + + delete(table, filters = {}, options = {}) { + return this.request(table, { + method: "DELETE", + query: filters, + prefer: options.returning === false ? "return=minimal" : "return=representation", + }); + } + + rpc(functionName, body) { + return this.request(`rpc/${functionName}`, { + method: "POST", + body, + prefer: "return=representation", + }); + } + + async probe() { + const rows = await this.select("app_workspaces", { select: "id", limit: 1 }); + return { ok: true, row_count: Array.isArray(rows) ? rows.length : 0 }; + } +} + +export function createSupabaseDataProvider(options = {}) { + return new SupabaseDataProvider(options); +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/providers/supabaseProvider.js b/demohouse/sales-intelligence-workbench/backend/src/providers/supabaseProvider.js new file mode 100644 index 00000000..a229b8d6 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/providers/supabaseProvider.js @@ -0,0 +1,204 @@ +import { execFile, execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { createEnvReader } from "../config/runtimeEnv.js"; +import { providerFailure, providerSuccess } from "./providerResult.js"; + +const execFileAsync = promisify(execFile); + +function truthy(value) { + return ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase()); +} + +function truncate(text, maxLength = 12000) { + const value = String(text || ""); + return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value; +} + +function parseJsonOutput(stdout) { + const output = String(stdout || "").trim(); + if (!output) return null; + try { + return JSON.parse(output); + } catch { + return null; + } +} + +function resultRows(parsed) { + if (Array.isArray(parsed)) return parsed; + if (Array.isArray(parsed?.rows)) return parsed.rows; + return parsed; +} + +function isReadOnlySql(query) { + const normalized = String(query || "") + .replace(/^\s*(?:--[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*/g, "") + .trim() + .toLowerCase(); + return /^(select|with|show|explain)\b/.test(normalized); +} + +export class SupabaseProvider { + constructor(options = {}) { + this.env = options.env || createEnvReader(); + this.execFile = options.execFile || execFileAsync; + this.command = this.env.value("SUPABASE_CLI_BIN", "byted-supabase-cli"); + this.timeoutMs = this.env.number("SUPABASE_TIMEOUT_MS", 30000); + this.workspaceId = this.env.value("SUPABASE_WORKSPACE_ID") || this.env.value("DEFAULT_WORKSPACE_ID"); + this.branchId = this.env.value("SUPABASE_BRANCH_ID"); + this.readOnly = truthy(this.env.value("SUPABASE_READ_ONLY", "true")); + } + + isConfigured() { + return Boolean( + this.workspaceId + && this.env.value("VOLCENGINE_ACCESS_KEY") + && this.env.value("VOLCENGINE_SECRET_KEY") + && this.command + ); + } + + isRunEnabled() { + return truthy(this.env.value("SUPABASE_RUN_ENABLED", "false")); + } + + async executeSql(query) { + if (!this.isConfigured()) { + return providerFailure("supabase", { code: "missing_config", message: "Supabase control-plane SQL is not configured." }); + } + if (this.readOnly && !isReadOnlySql(query)) { + return providerFailure("supabase", { code: "read_only", message: "Supabase writes are disabled by SUPABASE_READ_ONLY." }); + } + + const tempDir = await mkdtemp(join(tmpdir(), "ccc-supabase-")); + const queryFile = join(tempDir, "query.sql"); + await writeFile(queryFile, query, "utf8"); + const startedAt = Date.now(); + try { + const args = [ + "db", + "query", + "--file", + queryFile, + "--workspace-id", + this.workspaceId, + ]; + if (this.branchId) args.push("--branch-id", this.branchId); + const { stdout, stderr } = await this.execFile(this.command, args, { + timeout: this.timeoutMs, + maxBuffer: 1024 * 1024, + env: { + ...process.env, + VOLCENGINE_ACCESS_KEY: this.env.value("VOLCENGINE_ACCESS_KEY"), + VOLCENGINE_SECRET_KEY: this.env.value("VOLCENGINE_SECRET_KEY"), + VOLCENGINE_REGION: this.env.value("VOLCENGINE_REGION", "cn-beijing"), + }, + }); + const parsed = parseJsonOutput(stdout); + const providerError = parsed && !Array.isArray(parsed) && parsed.error; + if (providerError) { + return providerFailure("supabase", { code: "provider_error", message: truncate(providerError, 2000) }, { + stdout: truncate(stdout, 2000), + stderr: truncate(stderr, 2000), + latency_ms: Date.now() - startedAt, + }); + } + return providerSuccess("supabase", { + rows: resultRows(parsed), + stdout: truncate(stdout, 2000), + stderr: truncate(stderr, 2000), + latency_ms: Date.now() - startedAt, + }); + } catch (error) { + return providerFailure("supabase", { + code: error.code === "ENOENT" ? "missing_cli" : "cli_error", + message: truncate(error.message, 2000), + }, { + stdout: truncate(error.stdout, 2000), + stderr: truncate(error.stderr, 2000), + latency_ms: Date.now() - startedAt, + }); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + } + + executeSqlSync(query) { + if (!this.isConfigured()) { + return providerFailure("supabase", { code: "missing_config", message: "Supabase control-plane SQL is not configured." }); + } + if (this.readOnly && !isReadOnlySql(query)) { + return providerFailure("supabase", { code: "read_only", message: "Supabase writes are disabled by SUPABASE_READ_ONLY." }); + } + + const tempDir = mkdtempSync(join(tmpdir(), "ccc-supabase-")); + const queryFile = join(tempDir, "query.sql"); + writeFileSync(queryFile, query, "utf8"); + const startedAt = Date.now(); + try { + const args = [ + "db", + "query", + "--file", + queryFile, + "--workspace-id", + this.workspaceId, + ]; + if (this.branchId) args.push("--branch-id", this.branchId); + const stdout = execFileSync(this.command, args, { + timeout: this.timeoutMs, + maxBuffer: 1024 * 1024, + encoding: "utf8", + env: { + ...process.env, + VOLCENGINE_ACCESS_KEY: this.env.value("VOLCENGINE_ACCESS_KEY"), + VOLCENGINE_SECRET_KEY: this.env.value("VOLCENGINE_SECRET_KEY"), + VOLCENGINE_REGION: this.env.value("VOLCENGINE_REGION", "cn-beijing"), + }, + }); + const parsed = parseJsonOutput(stdout); + const providerError = parsed && !Array.isArray(parsed) && parsed.error; + if (providerError) { + return providerFailure("supabase", { code: "provider_error", message: truncate(providerError, 2000) }, { + stdout: truncate(stdout, 2000), + latency_ms: Date.now() - startedAt, + }); + } + return providerSuccess("supabase", { + rows: resultRows(parsed), + stdout: truncate(stdout, 2000), + latency_ms: Date.now() - startedAt, + }); + } catch (error) { + return providerFailure("supabase", { + code: error.code === "ENOENT" ? "missing_cli" : "cli_error", + message: truncate(error.message, 2000), + }, { + stdout: truncate(error.stdout, 2000), + stderr: truncate(error.stderr, 2000), + latency_ms: Date.now() - startedAt, + }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + } + + async probe() { + const result = await this.executeSql("select 1 as supabase_probe;"); + if (!result.ok) return result; + return providerSuccess("supabase", { + rows: result.rows, + raw_ref: "supabase:execute-sql:probe", + latency_ms: result.latency_ms, + }); + } + +} + +export function createSupabaseProvider(options = {}) { + return new SupabaseProvider(options); +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/providers/webSearchProvider.js b/demohouse/sales-intelligence-workbench/backend/src/providers/webSearchProvider.js new file mode 100644 index 00000000..5507dfe7 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/providers/webSearchProvider.js @@ -0,0 +1,221 @@ +import { createEnvReader } from "../config/runtimeEnv.js"; +import { executeProviderCall, providerFailure, providerSuccess } from "./providerResult.js"; + +const DEFAULT_BASE_URL = "https://open.feedcoopapi.com/search_api/web_search"; +const DEFAULT_TRAFFIC_TAG = "skill_web_search_common"; +const DEFAULT_TIMEOUT_MS = 20000; + +function clampCount(value, maxCount) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return maxCount; + return Math.max(1, Math.min(Math.trunc(parsed), maxCount)); +} + +function normalizeError(payload) { + const error = payload?.ResponseMetadata?.Error || payload?.Error || null; + if (!error) return null; + return { + code: error.Code || payload?.Code || "provider_error", + code_n: error.CodeN || payload?.CodeN || null, + message: error.Message || payload?.Message || "Provider returned an error.", + }; +} + +function cleanResultText(value, maxLength = 2000) { + return String(value || "") + .normalize("NFKC") + .replace(/[\u0000-\u001F\u007F]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxLength); +} + +function normalizeTitle(value) { + const title = cleanResultText(value, 500); + const structured = title.match( + /(?:^|---\s*)title\s*[::]\s*(.*?)(?=\s+(?:source|datetime|publish(?:ed)?_?time|url|summary)\s*[::]|$)/i, + )?.[1]; + return cleanResultText(structured || title, 300) + .replace(/^["'“”‘’]+|["'“”‘’]+$/g, "") + .trim(); +} + +function validPublishDate(value) { + if (value === null || value === undefined || value === "") return null; + const numeric = Number(value); + const input = Number.isFinite(numeric) + ? numeric < 10_000_000_000 ? numeric * 1000 : numeric + : value; + const date = new Date(input); + if (!Number.isFinite(date.getTime())) return null; + const year = date.getUTCFullYear(); + if (year < 2000 || year > new Date().getUTCFullYear() + 1) return null; + return date.toISOString(); +} + +function normalizePublishTime(value, metadataText = "") { + const direct = validPublishDate(value); + if (direct) return direct; + const embedded = cleanResultText(metadataText, 800).match( + /(?:datetime|publish(?:ed)?_?time|发布日期|发布时间)\s*[::]\s*(\d{4}[-/]\d{1,2}[-/]\d{1,2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?)/i, + )?.[1]; + return validPublishDate(embedded?.replaceAll("/", "-")); +} + +function normalizeResult(result) { + return { + id: result.Id || null, + sort_id: result.SortId ?? null, + title: normalizeTitle(result.Title), + site_name: cleanResultText(result.SiteName, 160), + url: cleanResultText(result.Url, 1000), + snippet: cleanResultText(result.Snippet, 2000), + summary: cleanResultText(result.Summary, 4000), + publish_time: normalizePublishTime(result.PublishTime, result.Title), + logo_url: result.LogoUrl || null, + rank_score: result.RankScore ?? null, + auth_description: result.AuthInfoDes || null, + auth_level: result.AuthInfoLevel ?? null, + content_formats: result.ContentFormats || null, + }; +} + +export class WebSearchProvider { + constructor(options = {}) { + this.env = options.env || createEnvReader(); + this.fetchImpl = options.fetchImpl || globalThis.fetch; + this.sleep = options.sleep; + } + + get apiKey() { + return this.env.value("WEB_SEARCH_API_KEY") + || this.env.value("AGENT_PLAN_API_KEY") + || this.env.value("ASK_ECHO_SEARCH_INFINITY_API_KEY"); + } + + get baseUrl() { + return this.env.value("WEB_SEARCH_BASE_URL", DEFAULT_BASE_URL); + } + + get maxCount() { + return Math.max(1, Math.min(this.env.number("WEB_SEARCH_MAX_COUNT", 3), 50)); + } + + get trafficTag() { + return this.env.value("WEB_SEARCH_TRAFFIC_TAG", DEFAULT_TRAFFIC_TAG); + } + + get runEnabled() { + return ["1", "true", "yes", "on"].includes(String(this.env.value("WEB_SEARCH_RUN_ENABLED", "false")).toLowerCase()); + } + + get timeoutMs() { + return Math.max(1000, this.env.number("WEB_SEARCH_TIMEOUT_MS", DEFAULT_TIMEOUT_MS)); + } + + get maxRetries() { + return Math.max(0, Math.min(this.env.number("WEB_SEARCH_MAX_RETRIES", 1), 2)); + } + + isConfigured() { + return Boolean(this.apiKey); + } + + isRunEnabled() { + return this.isConfigured() && this.runEnabled; + } + + async search(input) { + const query = String(input.query || input.Query || "").trim(); + if (!query) { + return providerFailure("web_search", { code: "bad_request", message: "query is required." }); + } + if (query.length > 100) { + return providerFailure("web_search", { code: "bad_request", message: "query must be 100 characters or fewer." }); + } + if (!this.isConfigured()) { + return providerFailure("web_search", { code: "missing_config", message: "AGENT_PLAN_API_KEY is not configured." }); + } + + const searchType = input.search_type || input.SearchType || "web"; + const count = clampCount(input.count ?? input.Count, this.maxCount); + const body = { + Query: query, + SearchType: searchType, + Count: count, + NeedSummary: input.need_summary ?? input.NeedSummary ?? true, + }; + const timeRange = input.time_range ?? input.TimeRange; + const authLevel = input.auth_level ?? input.AuthLevel; + const queryRewrite = input.query_rewrite ?? input.QueryRewrite; + if (timeRange) body.TimeRange = timeRange; + if (authLevel !== undefined && authLevel !== null && authLevel !== "") { + body.Filter = { AuthInfoLevel: Number(authLevel) }; + } + if (queryRewrite) body.QueryControl = { QueryRewrite: true }; + + return executeProviderCall( + () => this.searchOnce({ body, query, searchType }), + { + max_retries: this.maxRetries, + base_delay_ms: 2500, + sleep: this.sleep, + }, + ); + } + + async searchOnce({ body, query, searchType }) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + let response; + let payload; + const startedAt = Date.now(); + try { + response = await this.fetchImpl(this.baseUrl, { + method: "POST", + headers: { + "Authorization": `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + "X-Traffic-Tag": this.trafficTag, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + payload = await response.json(); + } catch (error) { + return providerFailure("web_search", { + code: error.name === "AbortError" ? "timeout" : "network_error", + message: error.name === "AbortError" ? "web search request timed out." : error.message, + }, { latency_ms: Date.now() - startedAt }); + } finally { + clearTimeout(timeout); + } + + const providerError = normalizeError(payload); + if (!response.ok || providerError) { + return providerFailure("web_search", providerError || { code: "http_error", message: `HTTP ${response.status}` }, { + http_status: response.status, + request_id: payload?.ResponseMetadata?.RequestId || payload?.Result?.LogId || null, + latency_ms: Date.now() - startedAt, + }); + } + + const result = payload.Result || {}; + const webResults = Array.isArray(result.WebResults) ? result.WebResults : []; + const requestId = payload.ResponseMetadata?.RequestId || result.LogId || null; + return providerSuccess("web_search", { + request_id: requestId, + log_id: result.LogId || requestId, + query, + search_type: result.SearchContext?.SearchType || searchType, + result_count: result.ResultCount ?? webResults.length, + latency_ms: Date.now() - startedAt, + raw_ref: requestId ? `web_search:${requestId}` : null, + results: webResults.map(normalizeResult), + }); + } +} + +export function createWebSearchProvider(options = {}) { + return new WebSearchProvider(options); +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/repositories/supabaseDataRepository.js b/demohouse/sales-intelligence-workbench/backend/src/repositories/supabaseDataRepository.js new file mode 100644 index 00000000..acc303f5 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/repositories/supabaseDataRepository.js @@ -0,0 +1,806 @@ +import { createSupabaseDataProvider } from "../providers/supabaseDataProvider.js"; +import { makeId } from "../utils/ids.js"; +import { nowIso } from "../utils/time.js"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +const clone = (value) => JSON.parse(JSON.stringify(value)); + +function payload(row) { + const value = row?.payload_json; + if (!value) return {}; + if (typeof value === "string") { + try { + return JSON.parse(value); + } catch { + return {}; + } + } + return value; +} + +function groupBy(rows, key) { + const groups = new Map(); + for (const row of rows || []) { + const value = row[key] || ""; + if (!groups.has(value)) groups.set(value, []); + groups.get(value).push(row); + } + return groups; +} + +function updateBody(row) { + const body = { ...row }; + delete body.id; + delete body.workspace_id; + delete body.created_at; + return body; +} + +function boundedLimit(value, fallback = 20) { + const parsed = Number(value || fallback); + return Math.max(1, Math.min(Number.isFinite(parsed) ? parsed : fallback, 100)); +} + +function salesMaterialMetadata(material = {}) { + return { + id: material.id, + company_id: material.company_id, + title: material.title || "", + source_type: material.source_type || "", + source_url: material.source_url || "", + source_id: material.source_id || null, + source_external_id: material.source_external_id || "", + source_version: material.source_version || "", + content_hash: material.content_hash || null, + occurred_at: material.occurred_at || null, + last_synced_at: material.last_synced_at || null, + openviking_uri: material.openviking_uri || material.openviking_ref || "", + openviking_status: material.openviking_status || (material.openviking_uri ? "indexed" : "pending"), + created_at: material.created_at || null, + updated_at: material.updated_at || null, + }; +} + +export class SupabaseDataRepository { + constructor(options = {}) { + this.provider = options.supabaseDataProvider || createSupabaseDataProvider({ env: options.env }); + this.workspaceId = String(options.workspaceId || this.provider.env?.value?.("APP_WORKSPACE_ID") || "").trim(); + if (!UUID_PATTERN.test(this.workspaceId)) { + throw new Error("APP_WORKSPACE_ID must be a valid UUID."); + } + this.readyPromise = null; + } + + async ensureSalesReady() { + if (this.readyPromise) return this.readyPromise; + this.readyPromise = (async () => { + if (!this.provider.isConfigured()) throw new Error("Supabase Data API is not configured."); + const [migrations, workspaces] = await Promise.all([ + this.provider.select("schema_migrations", { + select: "version", + filters: { version: "eq.202607300001" }, + limit: 1, + }), + this.provider.select("app_workspaces", { + select: "id", + filters: { id: `eq.${this.workspaceId}` }, + limit: 1, + }), + ]); + if (!migrations.length) throw new Error("Supabase security boundary migration is not applied."); + if (!workspaces.length) throw new Error(`Application workspace is not initialized: ${this.workspaceId}`); + return true; + })().catch((error) => { + this.readyPromise = null; + throw error; + }); + return this.readyPromise; + } + + async upsertScoped(table, id, row) { + await this.ensureSalesReady(); + const existing = await this.provider.update(table, updateBody(row), { + workspace_id: `eq.${this.workspaceId}`, + id: `eq.${id}`, + }); + if (Array.isArray(existing) && existing.length) return existing[0]; + const inserted = await this.provider.insert(table, row); + return Array.isArray(inserted) ? inserted[0] : inserted; + } + + async getSalesState(seed = {}) { + await this.ensureSalesReady(); + const workspaceFilter = { workspace_id: `eq.${this.workspaceId}` }; + const activeFilter = { ...workspaceFilter, deleted_at: "is.null" }; + const [ + goalRows, + companyRows, + targetRows, + progressRows, + dossierRows, + citationRows, + materialRows, + refRows, + syncSourceRows, + syncCheckpointRows, + jobRows, + ] = await Promise.all([ + this.provider.select("sales_goals", { filters: activeFilter, order: "created_at.asc" }), + this.provider.select("sales_companies", { filters: activeFilter, order: "created_at.asc" }), + this.provider.select("sales_target_enterprises", { filters: activeFilter, order: "created_at.asc" }), + this.provider.select("sales_progress_snapshots", { filters: workspaceFilter, order: "created_at.desc" }), + this.provider.select("sales_dossier_records", { filters: activeFilter, order: "created_at.desc" }), + this.provider.select("sales_dossier_citations", { filters: workspaceFilter, order: "created_at.asc" }), + this.provider.select("sales_materials", { filters: activeFilter, order: "updated_at.desc" }), + this.provider.select("sales_openviking_refs", { filters: workspaceFilter, order: "created_at.asc" }), + this.provider.select("sync_sources", { filters: workspaceFilter, order: "updated_at.desc" }), + this.provider.select("sync_checkpoints", { filters: workspaceFilter, order: "updated_at.desc" }), + this.provider.select("jobs", { filters: workspaceFilter, order: "created_at.desc" }), + ]); + + const progressByCompany = new Map(); + for (const row of progressRows) { + if (!progressByCompany.has(row.company_id)) { + progressByCompany.set(row.company_id, { + label: row.label, + summary: row.summary, + evidence: row.evidence, + updated_at: row.created_at, + }); + } + } + + const companies = {}; + for (const row of companyRows) { + const saved = payload(row); + companies[row.id] = { + ...saved, + id: row.id, + name: row.name, + initial: row.initial, + industry: row.industry, + location: row.location, + tags: Array.isArray(row.tags) ? row.tags : saved.tags || [], + progress: progressByCompany.get(row.id) || saved.progress || null, + dossier_ids: [], + material_ids: [], + qa_session_id: saved.qa_session_id || `sales-${row.id}`, + created_at: row.created_at, + updated_at: row.updated_at, + }; + } + + const targetCompanyIdsByGoal = groupBy(targetRows, "goal_id"); + const seedGoalOrder = new Map((seed?.goals || []).map((goal, index) => [goal.id, index])); + const goals = goalRows.map((row) => { + const saved = payload(row); + return { + ...saved, + id: row.id, + name: row.name, + description: row.description, + keywords: Array.isArray(row.keywords) ? row.keywords : saved.keywords || [], + company_ids: (targetCompanyIdsByGoal.get(row.id) || []).map((target) => target.company_id).filter((id) => companies[id]), + candidate_ids: saved.candidate_ids || [], + created_at: row.created_at, + updated_at: row.updated_at, + }; + }).sort((a, b) => { + const aOrder = seedGoalOrder.has(a.id) ? seedGoalOrder.get(a.id) : Number.MAX_SAFE_INTEGER; + const bOrder = seedGoalOrder.has(b.id) ? seedGoalOrder.get(b.id) : Number.MAX_SAFE_INTEGER; + if (aOrder !== bOrder) return aOrder - bOrder; + return String(b.created_at || "").localeCompare(String(a.created_at || "")); + }); + + const citationsByDossier = groupBy(citationRows, "dossier_id"); + const dossiers = {}; + for (const row of dossierRows) { + const saved = payload(row); + const citations = (citationsByDossier.get(row.id) || []).map((citationRow) => ({ + ...payload(citationRow), + id: citationRow.citation_no, + label: citationRow.label, + source_kind: citationRow.source_kind, + url: citationRow.url || "", + })).sort((a, b) => Number(a.id) - Number(b.id)); + dossiers[row.id] = { + ...saved, + id: row.id, + company_id: row.company_id, + title: row.title, + summary: row.summary, + memory_summary: row.memory_summary, + provider_run_id: row.provider_run_id || saved.provider_run_id || null, + version_no: Number(row.version_no || saved.version_no || 1), + previous_dossier_id: row.previous_dossier_id || saved.previous_dossier_id || null, + evidence_hash: row.evidence_hash || saved.evidence_hash || null, + dossier_fingerprint: row.dossier_fingerprint || saved.dossier_fingerprint || null, + change_status: row.change_status || saved.change_status || "initial", + data_as_of: row.data_as_of || saved.data_as_of || row.created_at, + generated_at: row.generated_at || saved.generated_at || row.created_at, + evidence_pack: Array.isArray(row.evidence_pack_json) + ? row.evidence_pack_json + : saved.evidence_pack || [], + created_at: row.created_at, + body: saved.body || [], + citations: citations.length ? citations : saved.citations || [], + }; + if (companies[row.company_id]) companies[row.company_id].dossier_ids.push(row.id); + } + + const materials = {}; + for (const row of materialRows) { + const saved = payload(row); + materials[row.id] = { + id: row.id, + company_id: row.company_id, + title: row.title, + source_type: row.source_type || saved.source_type || "", + source_url: row.source_url || saved.source_url || "", + source_id: row.source_id || saved.source_id || null, + source_external_id: saved.source_external_id || "", + source_version: row.source_version || saved.source_version || "", + content_hash: row.content_hash || saved.content_hash || null, + summary: "", + text: "", + source_items: [], + occurred_at: row.occurred_at || saved.occurred_at || null, + last_synced_at: row.last_synced_at || saved.last_synced_at || null, + updated_at: row.updated_at, + created_at: row.created_at, + openviking_uri: row.openviking_uri || saved.openviking_uri || "", + openviking_status: row.openviking_status || saved.openviking_status || (row.openviking_uri ? "indexed" : "pending"), + }; + if (companies[row.company_id] && !companies[row.company_id].material_ids.includes(row.id)) { + companies[row.company_id].material_ids.push(row.id); + } + } + + for (const row of refRows.filter((item) => item.related_type === "material")) { + const saved = payload(row); + const id = row.related_id || saved.id || row.id; + const seedMaterial = seed?.materials?.[id] || {}; + const existing = materials[id] || {}; + const memoryImported = row.ref_kind === "memory_import"; + materials[id] = { + ...existing, + id, + company_id: row.company_id, + title: saved.title || existing.title || seedMaterial.title || row.summary, + source_type: saved.source_type || existing.source_type || seedMaterial.source_type || "", + source_url: saved.source_url || existing.source_url || seedMaterial.source_url || "", + source_id: saved.source_id || existing.source_id || seedMaterial.source_id || null, + source_external_id: saved.source_external_id || existing.source_external_id || "", + source_version: saved.source_version || existing.source_version || "", + content_hash: saved.content_hash || existing.content_hash || null, + summary: "", + text: "", + source_items: [], + updated_at: saved.updated_at || existing.updated_at || seedMaterial.updated_at || row.created_at, + openviking_uri: memoryImported ? row.uri : existing.openviking_uri || row.uri, + openviking_status: memoryImported ? "indexed" : existing.openviking_status || (row.uri ? "indexed" : "pending"), + }; + if (companies[row.company_id] && !companies[row.company_id].material_ids.includes(id)) { + companies[row.company_id].material_ids.push(id); + } + } + + const qa_messages = {}; + + const sync_sources = Object.fromEntries(syncSourceRows.map((row) => [row.id, { + ...payload(row), + id: row.id, + source_type: row.source_type, + external_id: row.external_id, + display_name: row.display_name || "", + status: row.status, + config: row.config_json || {}, + last_synced_at: row.last_synced_at || null, + created_at: row.created_at, + updated_at: row.updated_at, + }])); + const sync_checkpoints = Object.fromEntries(syncCheckpointRows.map((row) => [row.id, { + ...payload(row), + id: row.id, + source_id: row.source_id, + checkpoint_key: row.checkpoint_key, + checkpoint_value: row.checkpoint_value || "", + content_hash: row.content_hash || null, + last_success_at: row.last_success_at || null, + error: row.error_json || null, + created_at: row.created_at, + updated_at: row.updated_at, + }])); + + const jobs = Object.fromEntries(jobRows.map((row) => [row.id, this.jobView(row)])); + + return { goals, companies, dossiers, materials, qa_messages, sync_sources, sync_checkpoints, jobs }; + } + + async persistSalesGoal(goal) { + const row = { + id: goal.id, + workspace_id: this.workspaceId, + name: goal.name, + description: goal.description || "", + keywords: goal.keywords || [], + deleted_at: null, + created_at: goal.created_at || nowIso(), + updated_at: goal.updated_at || nowIso(), + payload_json: goal, + }; + return this.upsertScoped("sales_goals", goal.id, row); + } + + async persistSalesCompany(company) { + const row = { + id: company.id, + workspace_id: this.workspaceId, + name: company.name, + initial: company.initial || "", + industry: company.industry || "", + location: company.location || "", + tags: company.tags || [], + deleted_at: null, + created_at: company.created_at || nowIso(), + updated_at: company.updated_at || nowIso(), + payload_json: company, + }; + const saved = await this.upsertScoped("sales_companies", company.id, row); + if (company.progress) await this.persistSalesProgress(company.id, company.progress); + return saved; + } + + async persistSalesProgress(companyId, progress) { + const createdAt = progress.updated_at || nowIso(); + const id = `${companyId}:${createdAt}`; + return this.upsertScoped("sales_progress_snapshots", id, { + id, + workspace_id: this.workspaceId, + company_id: companyId, + label: progress.label || "", + summary: progress.summary || "", + evidence: progress.evidence || "", + created_at: createdAt, + payload_json: progress, + }); + } + + async persistSalesTargetEnterprise(goalId, company) { + await this.persistSalesCompany(company); + const now = nowIso(); + const filters = { + workspace_id: `eq.${this.workspaceId}`, + goal_id: `eq.${goalId}`, + company_id: `eq.${company.id}`, + }; + const status = company.progress?.label || "新商机"; + const payload_json = { goal_id: goalId, company_id: company.id, status }; + const existing = await this.provider.update("sales_target_enterprises", { + status, + deleted_at: null, + updated_at: now, + payload_json, + }, filters); + if (Array.isArray(existing) && existing.length) return existing[0]; + const inserted = await this.provider.insert("sales_target_enterprises", { + id: `${goalId}:${company.id}`, + workspace_id: this.workspaceId, + goal_id: goalId, + company_id: company.id, + status, + created_at: now, + updated_at: now, + payload_json, + }); + return Array.isArray(inserted) ? inserted[0] : inserted; + } + + async persistSalesSearchResults(goalId, query, companies) { + await this.ensureSalesReady(); + const rows = (companies || []).map((company) => ({ + id: makeId("sales_search"), + workspace_id: this.workspaceId, + goal_id: goalId, + company_id: company.id || null, + query, + reason: company.reason || "", + created_at: nowIso(), + payload_json: company, + })); + if (!rows.length) return []; + return this.provider.insert("sales_company_search_results", rows); + } + + async persistSalesDossier(dossier) { + await this.ensureSalesReady(); + await this.provider.rpc("persist_sales_dossier", { + p_workspace_id: this.workspaceId, + p_dossier: dossier, + }); + return clone(dossier); + } + + async persistSalesMaterial(material) { + const metadata = salesMaterialMetadata(material); + const row = { + id: material.id, + workspace_id: this.workspaceId, + company_id: material.company_id, + title: material.title, + source_type: material.source_type || "", + source_url: material.source_url || "", + source_id: material.source_id || null, + source_version: material.source_version || "", + content_hash: material.content_hash || null, + summary: "", + occurred_at: material.occurred_at || null, + openviking_uri: material.openviking_uri || material.openviking_ref || "", + openviking_status: material.openviking_status || (material.openviking_uri ? "indexed" : "pending"), + last_synced_at: material.last_synced_at || null, + deleted_at: null, + created_at: material.created_at || material.updated_at || nowIso(), + updated_at: material.updated_at || nowIso(), + payload_json: metadata, + }; + return this.upsertScoped("sales_materials", material.id, row); + } + + async softDeleteSalesMaterial(materialId, deletedAt = nowIso()) { + await this.ensureSalesReady(); + return this.provider.update("sales_materials", { + deleted_at: deletedAt, + updated_at: deletedAt, + }, { + workspace_id: `eq.${this.workspaceId}`, + id: `eq.${materialId}`, + }); + } + + async persistSyncSource(source) { + const row = { + id: source.id, + workspace_id: this.workspaceId, + source_type: source.source_type, + external_id: source.external_id, + display_name: source.display_name || "", + status: source.status || "active", + config_json: source.config || source.config_json || {}, + last_synced_at: source.last_synced_at || null, + created_at: source.created_at || nowIso(), + updated_at: source.updated_at || nowIso(), + }; + return this.upsertScoped("sync_sources", source.id, row); + } + + async persistSyncCheckpoint(checkpoint) { + const id = checkpoint.id || `${checkpoint.source_id}:${checkpoint.checkpoint_key || "latest"}`; + const row = { + id, + workspace_id: this.workspaceId, + source_id: checkpoint.source_id, + checkpoint_key: checkpoint.checkpoint_key || "latest", + checkpoint_value: checkpoint.checkpoint_value || "", + content_hash: checkpoint.content_hash || null, + last_success_at: checkpoint.last_success_at || null, + error_json: checkpoint.error || checkpoint.error_json || null, + created_at: checkpoint.created_at || nowIso(), + updated_at: checkpoint.updated_at || nowIso(), + }; + return this.upsertScoped("sync_checkpoints", id, row); + } + + async persistSalesOpenVikingRef(record) { + const id = record.id || (record.related_id + ? `${record.company_id || "global"}:${record.related_type || "ref"}:${record.related_id}:${record.ref_kind || "ref"}` + : makeId("sales_ov")); + const row = { + id, + workspace_id: this.workspaceId, + company_id: record.company_id || null, + related_type: record.related_type, + related_id: record.related_id || null, + ref_kind: record.ref_kind, + uri: record.uri || "", + summary: record.summary || "", + created_at: record.created_at || nowIso(), + payload_json: record.payload_json || record, + }; + return this.upsertScoped("sales_openviking_refs", id, row); + } + + async persistProviderRun(run) { + await this.ensureSalesReady(); + await this.provider.rpc("persist_provider_run", { + p_workspace_id: this.workspaceId, + p_run: run, + }); + return clone(run); + } + + async persistJob(job) { + const row = { + id: job.id, + workspace_id: this.workspaceId, + job_type: job.job_type, + status: job.status || "queued", + entity_type: job.entity_type || null, + entity_id: job.entity_id || null, + idempotency_key: job.idempotency_key || null, + attempt_count: Number(job.attempt_count || 0), + max_attempts: Number(job.max_attempts || 3), + scheduled_at: job.scheduled_at || null, + started_at: job.started_at || null, + finished_at: job.finished_at || null, + error_json: job.error || job.error_json || null, + payload_json: job, + is_paid: Boolean(job.is_paid), + stage: job.stage || job.status || "queued", + progress: Math.max(0, Math.min(Number(job.progress || 0), 100)), + worker_id: job.worker_id || null, + lease_expires_at: job.lease_expires_at || null, + heartbeat_at: job.heartbeat_at || null, + cancel_requested_at: job.cancel_requested_at || null, + checkpoint_json: job.checkpoint || job.checkpoint_json || {}, + progress_detail_json: job.progress_detail || job.progress_detail_json || {}, + created_by: job.created_by || null, + created_at: job.created_at || nowIso(), + updated_at: job.updated_at || nowIso(), + }; + await this.upsertScoped("jobs", job.id, row); + return clone(job); + } + + async enqueueJob(job) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("enqueue_sales_job", { + p_workspace_id: this.workspaceId, + p_job: job, + }); + return this.jobView(result); + } + + async claimNextJob(workerId, jobTypes, leaseSeconds) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("claim_sales_job", { + p_workspace_id: this.workspaceId, + p_worker_id: workerId, + p_job_types: Array.isArray(jobTypes) ? jobTypes : [], + p_lease_seconds: Number(leaseSeconds || 600), + }); + return result ? this.jobView(result) : null; + } + + async heartbeatJob(jobId, workerId, stage, progress, leaseSeconds) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("heartbeat_sales_job", { + p_workspace_id: this.workspaceId, + p_job_id: jobId, + p_worker_id: workerId, + p_stage: stage, + p_progress: Number(progress || 1), + p_lease_seconds: Number(leaseSeconds || 600), + }); + return this.jobView(result); + } + + async saveJobCheckpoint(jobId, workerId, checkpointPatch = {}, options = {}) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("checkpoint_sales_job", { + p_workspace_id: this.workspaceId, + p_job_id: jobId, + p_worker_id: workerId, + p_stage: options.stage || "running", + p_progress: Number(options.progress || 1), + p_progress_detail: options.detail || {}, + p_checkpoint_patch: checkpointPatch || {}, + p_lease_seconds: Number(options.lease_seconds || 600), + }); + return this.jobView(result); + } + + async releaseJobClaim(jobId, workerId, error, options = {}) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("release_sales_job_claim", { + p_workspace_id: this.workspaceId, + p_job_id: jobId, + p_worker_id: workerId, + p_error: error || null, + p_retry: Boolean(options.retry), + p_delay_seconds: Number(options.delay_seconds || 0), + }); + return result ? this.jobView(result) : null; + } + + async requestJobCancellation(jobId) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("request_cancel_sales_job", { + p_workspace_id: this.workspaceId, + p_job_id: jobId, + }); + return this.jobView(result); + } + + async acknowledgeJobCancellation(jobId, workerId) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("acknowledge_cancel_sales_job", { + p_workspace_id: this.workspaceId, + p_job_id: jobId, + p_worker_id: workerId, + }); + return this.jobView(result); + } + + async retryQueuedJob(jobId) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("retry_sales_job", { + p_workspace_id: this.workspaceId, + p_job_id: jobId, + }); + return this.jobView(result); + } + + async reservePaidWorkflow(job, reservationId, limits) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("reserve_paid_workflow", { + p_workspace_id: this.workspaceId, + p_job: job, + p_reservation_id: reservationId, + p_max_concurrent: limits.max_concurrent, + p_daily_limit: limits.daily_limit, + p_budget_timezone: limits.timezone, + p_stale_after_seconds: limits.stale_after_seconds, + }); + return { + job: result?.job || job, + budget: result?.budget || null, + }; + } + + async finishPaidWorkflow(job, reservationId) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("finish_paid_workflow", { + p_workspace_id: this.workspaceId, + p_job: job, + p_reservation_id: reservationId, + }); + return result || clone(job); + } + + async getPaidWorkflowUsage(timezone) { + await this.ensureSalesReady(); + return this.provider.rpc("get_paid_workflow_usage", { + p_workspace_id: this.workspaceId, + p_budget_timezone: timezone, + }); + } + + async listJobs(filters = {}) { + await this.ensureSalesReady(); + const queryFilters = { workspace_id: `eq.${this.workspaceId}` }; + if (filters.job_type) queryFilters.job_type = `eq.${filters.job_type}`; + if (filters.status) queryFilters.status = `eq.${filters.status}`; + if (filters.entity_id) queryFilters.entity_id = `eq.${filters.entity_id}`; + const rows = await this.provider.select("jobs", { + filters: queryFilters, + order: "created_at.desc", + limit: boundedLimit(filters.limit), + }); + return rows.map((row) => this.jobView(row)); + } + + async getJob(jobId) { + await this.ensureSalesReady(); + const rows = await this.provider.select("jobs", { + filters: { workspace_id: `eq.${this.workspaceId}`, id: `eq.${jobId}` }, + limit: 1, + }); + return rows.length ? this.jobView(rows[0]) : null; + } + + jobView(row) { + const saved = payload(row); + return { + ...saved, + id: row.id, + job_type: row.job_type, + status: row.status, + entity_type: row.entity_type || "", + entity_id: row.entity_id || "", + idempotency_key: row.idempotency_key || null, + attempt_count: Number(row.attempt_count || 0), + max_attempts: Number(row.max_attempts || 3), + scheduled_at: row.scheduled_at || null, + started_at: row.started_at || null, + finished_at: row.finished_at || null, + error: row.error_json || saved.error || null, + is_paid: Boolean(row.is_paid || saved.is_paid), + stage: row.stage || saved.stage || row.status, + progress: Number(row.progress ?? saved.progress ?? (row.status === "succeeded" ? 100 : 0)), + worker_id: row.worker_id || saved.worker_id || null, + lease_expires_at: row.lease_expires_at || saved.lease_expires_at || null, + heartbeat_at: row.heartbeat_at || saved.heartbeat_at || null, + cancel_requested_at: row.cancel_requested_at || saved.cancel_requested_at || null, + checkpoint: row.checkpoint_json || saved.checkpoint || {}, + progress_detail: row.progress_detail_json || saved.progress_detail || {}, + created_by: row.created_by || saved.created_by || null, + created_at: row.created_at, + updated_at: row.updated_at, + }; + } + + async listProviderRuns(filters = {}) { + await this.ensureSalesReady(); + const limit = boundedLimit(filters.limit); + const queryFilters = { workspace_id: `eq.${this.workspaceId}` }; + if (filters.operation) queryFilters.operation = `eq.${filters.operation}`; + if (filters.entity_id) queryFilters.entity_id = `eq.${filters.entity_id}`; + const runs = await this.provider.select("provider_runs", { + filters: queryFilters, + order: "started_at.desc", + limit, + }); + if (!runs.length) return []; + const runIds = runs.map((run) => run.id); + const steps = await this.provider.select("provider_run_steps", { + filters: { + workspace_id: `eq.${this.workspaceId}`, + provider_run_id: `in.(${runIds.join(",")})`, + }, + order: "sequence.asc", + }); + const stepsByRun = groupBy(steps, "provider_run_id"); + return runs.map((row) => this.providerRunView(row, stepsByRun.get(row.id) || [])); + } + + async getProviderRun(runId) { + await this.ensureSalesReady(); + const rows = await this.provider.select("provider_runs", { + filters: { workspace_id: `eq.${this.workspaceId}`, id: `eq.${runId}` }, + limit: 1, + }); + if (!rows.length) return null; + const steps = await this.provider.select("provider_run_steps", { + filters: { workspace_id: `eq.${this.workspaceId}`, provider_run_id: `eq.${runId}` }, + order: "sequence.asc", + }); + return this.providerRunView(rows[0], steps); + } + + providerRunView(row, stepRows = []) { + const saved = payload(row); + return { + ...saved, + id: row.id, + operation: row.operation, + status: row.status, + app_mode: row.app_mode, + entity_type: row.entity_type || "", + entity_id: row.entity_id || "", + job_id: row.job_id || saved.job_id || null, + started_at: row.started_at, + finished_at: row.finished_at, + duration_ms: row.duration_ms, + result_ref: row.result_ref, + error: row.error_json || saved.error || null, + steps: stepRows.map((step) => ({ + id: step.id, + sequence: step.sequence, + provider: step.provider, + operation: step.operation, + status: step.status, + input_summary: step.input_summary || "", + output_summary: step.output_summary || "", + request_id: step.request_id, + raw_ref: step.raw_ref, + usage: step.usage_json, + attempts: step.attempts, + started_at: step.started_at, + finished_at: step.finished_at, + latency_ms: step.latency_ms, + error: step.error_json, + })), + }; + } +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/routes/index.js b/demohouse/sales-intelligence-workbench/backend/src/routes/index.js new file mode 100644 index 00000000..a9d23924 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/routes/index.js @@ -0,0 +1,473 @@ +import { + HttpError, + accepted, + created, + fail, + isOriginAllowed, + ok, + parseAllowedOrigins, + readJson, + withCors, + withSecurityHeaders, +} from "../utils/http.js"; +import { makeRequestId } from "../utils/ids.js"; +import { enforceRateLimit } from "../security/rateLimiter.js"; + +function route(method, pattern, names, handler, access = method === "GET" ? "viewer" : "member", audit = null) { + return { method, pattern, names, handler, access, audit }; +} + +function paramsFrom(match, names) { + return Object.fromEntries(names.map((name, index) => [name, decodeURIComponent(match[index + 1])])); +} + +function listMeta(data, providerMode = "real") { + return { + count: Array.isArray(data) ? data.length : undefined, + provider_mode: providerMode, + }; +} + +function isSalesBusinessPath(pathname) { + return /^\/api\/(sales-goals|target-enterprises|dossiers|provider-runs|jobs)(?:\/|$)/.test(pathname); +} + +function isProviderProbePath(pathname) { + return /^\/api\/providers\/[^/]+\/probe$/.test(pathname); +} + +function isPaidOperation(method, pathname) { + if (method !== "POST") return false; + return isProviderProbePath(pathname) + || /\/company-search$/.test(pathname) + || /\/dossiers$/.test(pathname) + || /\/qa(?:\/commit-memory)?$/.test(pathname) + || /\/materials\/(?:import|sync-openviking|feishu-import)$/.test(pathname); +} + +function requestClientKey(req, trustProxy = false) { + if (trustProxy) { + const forwarded = String(req.headers?.["x-forwarded-for"] || "").split(",")[0].trim(); + if (forwarded) return forwarded.slice(0, 120); + } + return String(req.socket?.remoteAddress || "unknown").slice(0, 120); +} + +export function createRouter(providerService, options = {}) { + const salesService = options.salesService || null; + const feishuImportTaskService = options.feishuImportTaskService || null; + const adminStatusService = options.adminStatusService || null; + const staticFrontend = options.staticFrontend || null; + const authService = options.authService || null; + const rateLimiters = options.rateLimiters || null; + const env = options.env || null; + const allowedOrigins = parseAllowedOrigins(env?.value?.("ALLOWED_ORIGINS", "") || ""); + const maxBodyBytes = Math.max(1024, env?.number?.("API_MAX_BODY_BYTES", 1024 * 1024) || 1024 * 1024); + const trustProxy = ["1", "true", "yes", "on"].includes(String(env?.value?.("TRUST_PROXY", "false") || "").toLowerCase()); + const runtimePolicy = options.runtimePolicy || { + ready: true, + fail_closed: true, + blockers: [], + }; + const providerMode = "real"; + const freshSalesData = async (read, options = {}) => { + await salesService?.refreshPersistedState?.(options); + return read(); + }; + const routes = [ + route("GET", /^\/api\/health$/, [], async () => ({ + data: { + status: runtimePolicy.ready ? "ok" : "degraded", + service: "sales-intelligence-workbench-api", + version: "0.10.0", + provider_mode: providerMode, + runtime_ready: runtimePolicy.ready, + }, + meta: { provider_mode: providerMode }, + }), "public"), + route("GET", /^\/api\/auth\/status$/, [], async ({ req, res }) => ({ + data: authService + ? await authService.sessionStatus(req, res) + : { enabled: false, authenticated: true, bootstrap_required: false, user: null }, + meta: { provider_mode: "supabase_auth" }, + }), "public"), + route("POST", /^\/api\/auth\/bootstrap$/, [], async ({ body, res }) => ({ + data: await authService.bootstrap(body, res), + status: 201, + meta: { provider_mode: "supabase_auth" }, + }), "public"), + route("POST", /^\/api\/auth\/login$/, [], async ({ body, res }) => ({ + data: await authService.login(body, res), + meta: { provider_mode: "supabase_auth" }, + }), "public"), + route("POST", /^\/api\/auth\/cli-login$/, [], async ({ body }) => ({ + data: await authService.cliLogin(body), + meta: { provider_mode: "supabase_auth" }, + }), "public"), + route("POST", /^\/api\/auth\/cli-refresh$/, [], async ({ body }) => ({ + data: await authService.cliRefresh(body), + meta: { provider_mode: "supabase_auth" }, + }), "public"), + route("POST", /^\/api\/auth\/refresh$/, [], async ({ req, res }) => ({ + data: await authService.refresh(req, res), + meta: { provider_mode: "supabase_auth" }, + }), "public"), + route("POST", /^\/api\/auth\/logout$/, [], async ({ req, res }) => ({ + data: await authService.logout(req, res), + meta: { provider_mode: "supabase_auth" }, + }), "public"), + route("GET", /^\/api\/providers\/status$/, [], async () => ({ + data: providerService.getProviderStatus(), + meta: { provider_mode: "real" }, + }), "admin"), + route("GET", /^\/api\/admin\/status$/, [], async () => ({ + data: adminStatusService + ? await adminStatusService.getStatus() + : { read_only: true, unavailable: true }, + meta: { provider_mode: providerMode }, + }), "admin"), + route("GET", /^\/api\/admin\/audit-events$/, [], async ({ auth, query }) => { + const data = await authService.listAuditEvents(auth, { + action: query.get("action") || "", + entity_type: query.get("entity_type") || "", + entity_id: query.get("entity_id") || "", + limit: query.get("limit") || 50, + }); + return { + data, + meta: { ...listMeta(data, "local") }, + }; + }, "admin"), + route("GET", /^\/api\/admin\/workspace-export$/, [], async () => { + await salesService?.assertRuntimeReady?.(); + return { + data: await freshSalesData(() => salesService.exportWorkspaceData(), { force: true }), + meta: { provider_mode: "local" }, + }; + }, "owner", ({ auth }) => ({ + action: "workspace.exported", + entity_type: "workspace", + entity_id: auth?.principal?.workspace_id || "", + })), + route("POST", /^\/api\/providers\/web-search\/probe$/, [], async ({ body }) => ({ + data: await providerService.probeWebSearch(body), + meta: { provider_mode: "real" }, + }), "admin", () => ({ action: "provider.probed", entity_type: "provider", entity_id: "web_search" })), + route("POST", /^\/api\/providers\/datapro\/probe$/, [], async ({ body }) => ({ + data: await providerService.probeDataPro(body), + meta: { provider_mode: "real" }, + }), "admin", () => ({ action: "provider.probed", entity_type: "provider", entity_id: "datapro" })), + route("POST", /^\/api\/providers\/model\/probe$/, [], async () => ({ + data: await providerService.probeModel(), + meta: { provider_mode: "real" }, + }), "admin", () => ({ action: "provider.probed", entity_type: "provider", entity_id: "model" })), + route("POST", /^\/api\/providers\/openviking\/probe$/, [], async ({ body }) => ({ + data: await providerService.probeOpenViking(body), + meta: { provider_mode: "real" }, + }), "admin", () => ({ action: "provider.probed", entity_type: "provider", entity_id: "openviking" })), + route("POST", /^\/api\/providers\/supabase\/probe$/, [], async () => ({ + data: await providerService.probeSupabase(), + meta: { provider_mode: "real" }, + }), "admin", () => ({ action: "provider.probed", entity_type: "provider", entity_id: "supabase" })), + + route("GET", /^\/api\/provider-runs$/, [], async ({ query }) => { + const data = await salesService.listProviderRuns({ + operation: query.get("operation") || "", + entity_id: query.get("entity_id") || "", + limit: query.get("limit") || 20, + }); + return { data, meta: { ...listMeta(data, providerMode) } }; + }, "admin"), + route("GET", /^\/api\/provider-runs\/([^/]+)$/, ["provider_run_id"], async ({ params }) => ({ + data: await salesService.getProviderRun(params.provider_run_id), + meta: { provider_mode: providerMode }, + }), "admin"), + route("GET", /^\/api\/jobs$/, [], async ({ query }) => { + const data = await salesService.listPublicJobs({ + job_type: query.get("job_type") || "", + status: query.get("status") || "", + entity_id: query.get("entity_id") || "", + limit: query.get("limit") || 20, + }); + return { data, meta: { ...listMeta(data, providerMode) } }; + }), + route("GET", /^\/api\/jobs\/([^/]+)$/, ["job_id"], async ({ params }) => ({ + data: await salesService.getPublicJob(params.job_id), + meta: { provider_mode: providerMode }, + })), + route("POST", /^\/api\/jobs\/([^/]+)\/cancel$/, ["job_id"], async ({ params }) => ({ + data: salesService.publicJob(await salesService.cancelJob(params.job_id)), + meta: { provider_mode: providerMode }, + }), "member", ({ params }) => ({ + action: "job.cancelled", + entity_type: "job", + entity_id: params.job_id, + })), + route("POST", /^\/api\/jobs\/([^/]+)\/retry$/, ["job_id"], async ({ params }) => ({ + data: await salesService.retryJob(params.job_id), + meta: { provider_mode: providerMode }, + }), "member", ({ params }) => ({ + action: "job.retried", + entity_type: "job", + entity_id: params.job_id, + })), + route("GET", /^\/api\/admin\/usage-budget$/, [], async () => ({ + data: await salesService.getPaidWorkflowUsage(), + meta: {}, + }), "admin"), + + route("GET", /^\/api\/sales-goals$/, [], async () => ({ + data: await freshSalesData(() => salesService.listGoals()), + meta: { provider_mode: "mixed" }, + })), + route("POST", /^\/api\/sales-goals$/, [], async ({ body }) => ({ + data: await salesService.createGoal(body), + status: 201, + meta: { provider_mode: "mixed" }, + }), "member", ({ result }) => ({ + action: "sales_goal.created", + entity_type: "sales_goal", + entity_id: result?.data?.id || "", + })), + route("GET", /^\/api\/sales-goals\/([^/]+)\/target-enterprises$/, ["goal_id"], async ({ params }) => ({ + data: await freshSalesData(() => salesService.listTargetEnterprises(params.goal_id)), + meta: { provider_mode: "mixed" }, + })), + route("POST", /^\/api\/sales-goals\/([^/]+)\/company-search$/, ["goal_id"], async ({ params, body }) => ({ + data: await salesService.searchCompanies(params.goal_id, body), + meta: { provider_mode: "mixed" }, + }), "member", ({ params }) => ({ + action: "company_search.executed", + entity_type: "sales_goal", + entity_id: params.goal_id, + })), + route("POST", /^\/api\/sales-goals\/([^/]+)\/target-enterprises$/, ["goal_id"], async ({ params, body }) => ({ + data: await salesService.addTargetEnterprise(params.goal_id, body), + status: 201, + meta: { provider_mode: "mixed" }, + }), "member", ({ params, result }) => ({ + action: "target_enterprise.added", + entity_type: "target_enterprise", + entity_id: result?.data?.id || params.goal_id, + })), + route("GET", /^\/api\/target-enterprises\/([^/]+)$/, ["enterprise_id"], async ({ params, query }) => ({ + data: await freshSalesData(() => salesService.enterpriseDetail(params.enterprise_id, { + goal_id: query.get("goal_id") || "", + })), + meta: { provider_mode: "mixed" }, + })), + route("GET", /^\/api\/target-enterprises\/([^/]+)\/progress$/, ["enterprise_id"], async ({ params }) => ({ + data: await freshSalesData(() => salesService.progressView(params.enterprise_id)), + meta: { provider_mode: "mixed" }, + })), + route("GET", /^\/api\/target-enterprises\/([^/]+)\/dossiers$/, ["enterprise_id"], async ({ params }) => ({ + data: await freshSalesData(() => salesService.listDossiers(params.enterprise_id)), + meta: { provider_mode: "mixed" }, + })), + route("POST", /^\/api\/target-enterprises\/([^/]+)\/dossiers$/, ["enterprise_id"], async ({ params, body, auth }) => { + if (salesService.asyncJobsEnabled) { + return { + data: await salesService.enqueueDossier(params.enterprise_id, body, { + created_by: auth?.principal?.id || null, + }), + status: 202, + meta: { provider_mode: "mixed", execution_mode: "asynchronous" }, + }; + } + return { + data: await salesService.createDossier(params.enterprise_id, body), + status: 201, + meta: { provider_mode: "mixed", execution_mode: "synchronous" }, + }; + }, "member", ({ params }) => ({ + action: "dossier.generation_requested", + entity_type: "target_enterprise", + entity_id: params.enterprise_id, + })), + route("GET", /^\/api\/dossiers\/([^/]+)$/, ["dossier_id"], async ({ params }) => ({ + data: await freshSalesData(() => salesService.dossierDetail(params.dossier_id), { minIntervalMs: 5_000 }), + meta: { provider_mode: "mixed" }, + })), + route("GET", /^\/api\/target-enterprises\/([^/]+)\/materials$/, ["enterprise_id"], async ({ params }) => ({ + data: await freshSalesData(() => salesService.listMaterials(params.enterprise_id)), + meta: { provider_mode: "mixed" }, + })), + route("GET", /^\/api\/target-enterprises\/([^/]+)\/materials\/sources$/, ["enterprise_id"], async ({ params }) => ({ + data: await freshSalesData(() => salesService.listMaterialSyncSources(params.enterprise_id)), + meta: { provider_mode: "mixed" }, + })), + route("GET", /^\/api\/target-enterprises\/([^/]+)\/materials\/sync-state$/, ["enterprise_id"], async ({ params, query }) => ({ + data: await freshSalesData(() => salesService.getMaterialSyncState(params.enterprise_id, { + source_id: query.get("source_id") || "", + title: query.get("display_name") || query.get("external_id") || "资料同步源", + source: { + type: query.get("source_type") || "manual", + external_id: query.get("external_id") || "", + checkpoint_key: query.get("checkpoint_key") || "latest", + }, + })), + meta: { provider_mode: "mixed" }, + })), + route("GET", /^\/api\/feishu-import\/status$/, [], async () => ({ + data: feishuImportTaskService?.status?.() || { + available: false, + supported_sources: [], + }, + meta: { provider_mode: "local_cli" }, + }), "viewer"), + route("POST", /^\/api\/target-enterprises\/([^/]+)\/materials\/feishu-import$/, ["enterprise_id"], async ({ params, body }) => ({ + data: await feishuImportTaskService.start(params.enterprise_id, body), + status: 202, + meta: { provider_mode: "local_cli", execution_mode: "asynchronous" }, + }), "member", ({ params, result }) => ({ + action: "feishu_material.import_requested", + entity_type: "target_enterprise", + entity_id: params.enterprise_id, + metadata: { task_id: result?.data?.id || null }, + })), + route("GET", /^\/api\/target-enterprises\/([^/]+)\/materials\/feishu-import\/([^/]+)$/, ["enterprise_id", "task_id"], async ({ params }) => ({ + data: feishuImportTaskService.get(params.enterprise_id, params.task_id), + meta: { provider_mode: "local_cli" }, + }), "member"), + route("POST", /^\/api\/target-enterprises\/([^/]+)\/materials\/import$/, ["enterprise_id"], async ({ params, body }) => ({ + data: await salesService.importMaterial(params.enterprise_id, body), + status: 201, + meta: { provider_mode: "mixed" }, + }), "member", ({ params, result }) => ({ + action: "material.imported", + entity_type: "sales_material", + entity_id: result?.data?.material?.id || params.enterprise_id, + })), + route("POST", /^\/api\/target-enterprises\/([^/]+)\/materials\/source-action$/, ["enterprise_id"], async ({ params, body }) => ({ + data: await salesService.updateMaterialSyncSource(params.enterprise_id, body), + meta: { provider_mode: "mixed" }, + }), "member", ({ params, result }) => ({ + action: "material_source.updated", + entity_type: "material_source", + entity_id: result?.data?.source?.id || params.enterprise_id, + })), + route("POST", /^\/api\/target-enterprises\/([^/]+)\/materials\/sync-openviking$/, ["enterprise_id"], async ({ params, body, auth }) => { + if (salesService.asyncJobsEnabled) { + const data = await salesService.enqueueMaterialsToOpenViking(params.enterprise_id, { + idempotency_key: body.idempotency_key || null, + created_by: auth?.principal?.id || null, + }); + return { + data, + status: data.id ? 202 : 200, + meta: { provider_mode: "mixed", execution_mode: data.id ? "asynchronous" : "skipped" }, + }; + } + return { + data: await salesService.syncMaterialsToOpenViking(params.enterprise_id), + meta: { provider_mode: "mixed", execution_mode: "synchronous" }, + }; + }, "member", ({ params }) => ({ + action: "openviking.sync_requested", + entity_type: "target_enterprise", + entity_id: params.enterprise_id, + })), + route("GET", /^\/api\/target-enterprises\/([^/]+)\/qa$/, ["enterprise_id"], async ({ params }) => ({ + data: await freshSalesData(() => salesService.getQa(params.enterprise_id)), + meta: { provider_mode: "mixed" }, + })), + route("POST", /^\/api\/target-enterprises\/([^/]+)\/qa$/, ["enterprise_id"], async ({ params, body }) => ({ + data: await salesService.askQuestion(params.enterprise_id, body), + meta: { provider_mode: "mixed" }, + }), "member", ({ params }) => ({ + action: "qa.answered", + entity_type: "target_enterprise", + entity_id: params.enterprise_id, + })), + route("POST", /^\/api\/target-enterprises\/([^/]+)\/qa\/commit-memory$/, ["enterprise_id"], async ({ params }) => ({ + data: await salesService.commitQaMemory(params.enterprise_id), + meta: { provider_mode: "mixed" }, + }), "member", ({ params }) => ({ + action: "qa.memory_committed", + entity_type: "target_enterprise", + entity_id: params.enterprise_id, + })), + ]; + + return async function handle(req, res) { + const requestId = makeRequestId(); + try { + const url = new URL(req.url, "http://localhost"); + const isApi = url.pathname.startsWith("/api"); + withSecurityHeaders(res, { api: isApi }); + if (isApi && !isOriginAllowed(req, allowedOrigins)) { + throw new HttpError(403, "origin_not_allowed", "当前请求来源不在允许列表中。"); + } + withCors(req, res, allowedOrigins); + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + if (staticFrontend && !url.pathname.startsWith("/api")) { + const served = await staticFrontend(req, res, url.pathname); + if (served) return; + } + const found = routes.find((item) => item.method === req.method && item.pattern.test(url.pathname)); + if (!found) throw new HttpError(404, "not_found", "API route was not found.", { method: req.method, path: url.pathname }); + + const clientKey = requestClientKey(req, trustProxy); + if (rateLimiters?.general) enforceRateLimit(res, rateLimiters.general, clientKey); + if (/^\/api\/auth\/(?:bootstrap|login|cli-login|cli-refresh)$/.test(url.pathname) && rateLimiters?.auth) { + enforceRateLimit(res, rateLimiters.auth, clientKey, "auth_rate_limit_exceeded"); + } + let auth = null; + if (found.access !== "public") { + if (!authService) throw new HttpError(503, "auth_not_configured", "身份认证尚未完成配置。"); + auth = await authService.authenticateRequest(req, res); + authService.requireRole(auth, found.access); + } + if ( + runtimePolicy.fail_closed + && !runtimePolicy.ready + && (isSalesBusinessPath(url.pathname) || isPaidOperation(req.method, url.pathname)) + ) { + throw new HttpError(503, "runtime_not_ready", "Runtime configuration is not ready."); + } + if (isSalesBusinessPath(url.pathname)) { + await salesService?.assertRuntimeReady?.(); + } + if (req.method !== "GET" && req.method !== "HEAD" && found.access !== "public") { + authService?.assertCsrf(req, auth); + if (rateLimiters?.write) enforceRateLimit(res, rateLimiters.write, auth?.principal?.id || clientKey); + } + if (isPaidOperation(req.method, url.pathname) && rateLimiters?.paid) { + enforceRateLimit(res, rateLimiters.paid, auth?.principal?.id || clientKey, "paid_operation_rate_limit_exceeded"); + } + + const match = url.pathname.match(found.pattern); + const params = paramsFrom(match, found.names); + const body = req.method === "POST" ? await readJson(req, { maxBytes: maxBodyBytes }) : {}; + const result = await found.handler({ params, body, query: url.searchParams, request_id: requestId, req, res, auth }); + if (found.audit && auth?.principal && authService?.recordAudit) { + const descriptor = typeof found.audit === "function" + ? found.audit({ params, body, result, auth }) + : found.audit; + if (descriptor?.action) { + await authService.recordAudit(auth, { + ...descriptor, + request_id: requestId, + after: { + status: result.status || 200, + ...(descriptor.after || {}), + }, + }); + } + } + const meta = { + request_id: requestId, + ...(result.meta || {}), + }; + if (result.status === 201) created(res, result.data, meta); + else if (result.status === 202) accepted(res, result.data, meta); + else ok(res, result.data, meta); + } catch (error) { + fail(res, error, requestId); + } + }; +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/security/authService.js b/demohouse/sales-intelligence-workbench/backend/src/security/authService.js new file mode 100644 index 00000000..450eae4b --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/security/authService.js @@ -0,0 +1,632 @@ +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; +import { HttpError } from "../utils/http.js"; + +const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); +const ROLE_LEVEL = Object.freeze({ viewer: 0, member: 1, admin: 2, owner: 3 }); +const AUDIT_FILTER_PATTERN = /^[a-z0-9_.:-]+$/i; +const AUDIT_SECRET_KEY_PATTERN = /(?:authorization|cookie|password|secret|token|api[_-]?key|raw[_-]?ref|openviking[_-]?(?:uri|ref))/i; + +function enabled(value) { + return TRUE_VALUES.has(String(value || "").trim().toLowerCase()); +} + +function authBaseUrl(value) { + return String(value || "").trim().replace(/\/$/, "").replace(/\/rest\/v1$/, ""); +} + +function normalizeEmail(value) { + return String(value || "").trim().toLowerCase(); +} + +function normalizeUsername(value) { + return String(value || "").trim().replace(/\s+/g, " "); +} + +function validateEmail(value) { + const email = normalizeEmail(value); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw new HttpError(400, "invalid_email", "请输入有效的邮箱地址。"); + } + return email; +} + +function validateUsername(value) { + const username = normalizeUsername(value); + if ( + username.length < 2 + || username.length > 40 + || /[@\u0000-\u001f\u007f]/u.test(username) + ) { + throw new HttpError(400, "invalid_username", "用户名需要为 2 至 40 个字符,不能包含 @ 或控制字符。"); + } + return username; +} + +function validatePassword(value) { + const password = String(value || ""); + if (password.length < 10 || password.length > 256) { + throw new HttpError(400, "weak_password", "密码长度需要为 10 至 256 个字符。"); + } + return password; +} + +function internalOwnerEmail(workspaceId) { + const suffix = createHash("sha256").update(String(workspaceId || "")).digest("hex").slice(0, 24); + return `owner-${suffix}@sales-workbench.invalid`; +} + +function parseCookies(header = "") { + const cookies = {}; + for (const item of String(header || "").split(";")) { + const separator = item.indexOf("="); + if (separator < 1) continue; + const name = item.slice(0, separator).trim(); + const value = item.slice(separator + 1).trim(); + try { + cookies[name] = decodeURIComponent(value); + } catch { + cookies[name] = value; + } + } + return cookies; +} + +function serializeCookie(name, value, options = {}) { + const parts = [`${name}=${encodeURIComponent(value)}`, `Path=${options.path || "/"}`]; + if (options.maxAge !== undefined) parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAge))}`); + if (options.httpOnly) parts.push("HttpOnly"); + if (options.secure) parts.push("Secure"); + parts.push(`SameSite=${options.sameSite || "Strict"}`); + return parts.join("; "); +} + +function safeEqual(left, right) { + const a = Buffer.from(String(left || "")); + const b = Buffer.from(String(right || "")); + return a.length > 0 && a.length === b.length && timingSafeEqual(a, b); +} + +function tokenHash(token) { + return createHash("sha256").update(String(token || "")).digest("hex"); +} + +function sanitizeAuditValue(value, depth = 0) { + if (value === null || value === undefined) return null; + if (depth > 4) return "[depth-limited]"; + if (typeof value === "string") return value.slice(0, 1000); + if (typeof value === "number" || typeof value === "boolean") return value; + if (Array.isArray(value)) return value.slice(0, 50).map((item) => sanitizeAuditValue(item, depth + 1)); + if (typeof value !== "object") return String(value).slice(0, 1000); + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => !AUDIT_SECRET_KEY_PATTERN.test(String(key))) + .slice(0, 50) + .map(([key, item]) => [String(key).slice(0, 120), sanitizeAuditValue(item, depth + 1)]), + ); +} + +function auditFilter(value, name, maxLength) { + const text = String(value || "").trim(); + if (!text) return ""; + if (text.length > maxLength || !AUDIT_FILTER_PATTERN.test(text)) { + throw new HttpError(400, "invalid_audit_filter", `审计筛选条件 ${name} 无效。`); + } + return text; +} + +function safeAuthError(status, body, context = "session") { + const code = String(body?.error_code || body?.code || body?.error || `auth_http_${status}`); + const message = String(body?.msg || body?.message || ""); + const expiredJwt = status === 403 && /(?:bad_jwt|invalid jwt|jwt.{0,40}expired|token.{0,20}expired)/i.test(`${code} ${message}`); + if (status === 400 || status === 401 || expiredJwt) { + return new HttpError(401, "invalid_credentials", "用户名或密码不正确,或登录会话已经过期。"); + } + if (status === 422 || /already|registered|exists/i.test(message)) { + return new HttpError(409, "account_exists", "管理员账号已经创建,请直接登录。"); + } + return new HttpError(502, "auth_provider_error", "身份服务暂时不可用,请稍后重试。", { provider_code: code }); +} + +function validateLoginCredentials(body) { + const identifier = String(body?.username || body?.account || body?.email || "").trim(); + if (!identifier) throw new HttpError(400, "username_required", "请输入用户名。"); + const password = validatePassword(body?.password); + return { identifier, password }; +} + +function publicUser(principal) { + if (!principal) return null; + return { + id: principal.id, + username: principal.username, + display_name: principal.display_name, + }; +} + +export class AuthService { + constructor(options = {}) { + this.env = options.env; + this.fetch = options.fetchImpl || fetch; + this.dataProvider = options.dataProvider; + this.baseUrl = authBaseUrl(this.env?.value?.("SUPABASE_API_URL", "")); + this.serviceRoleKey = this.env?.value?.("SUPABASE_SERVICE_ROLE_KEY", "") || ""; + this.workspaceId = this.env?.value?.("APP_WORKSPACE_ID", "") || ""; + this.authEnabled = enabled(this.env?.value?.("HTTP_AUTH_ENABLED", "false")); + this.bootstrapEnabled = enabled(this.env?.value?.("AUTH_BOOTSTRAP_ENABLED", "true")); + this.cookieSecure = enabled(this.env?.value?.("AUTH_COOKIE_SECURE", "false")); + this.timeoutMs = this.env?.number?.("AUTH_PROVIDER_TIMEOUT_MS", 12000) || 12000; + this.cacheTtlMs = this.env?.number?.("AUTH_SESSION_CACHE_TTL_MS", 15000) || 15000; + this.refreshMaxAge = this.env?.number?.("AUTH_REFRESH_COOKIE_MAX_AGE", 31536000) || 31536000; + this.cache = new Map(); + this.bootstrapPromise = null; + this.cookieNames = Object.freeze({ + access: "siw_access", + refresh: "siw_refresh", + csrf: "siw_csrf", + }); + } + + isEnabled() { + return this.authEnabled; + } + + isConfigured() { + return Boolean(this.baseUrl && this.serviceRoleKey && this.workspaceId && this.dataProvider?.isConfigured?.()); + } + + assertConfigured() { + if (!this.isConfigured()) { + throw new HttpError(503, "auth_not_configured", "身份认证尚未完成配置。"); + } + } + + async authRequest(path, options = {}) { + this.assertConfigured(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await this.fetch(`${this.baseUrl}/auth/v1/${String(path).replace(/^\//, "")}`, { + method: options.method || "GET", + headers: { + Accept: "application/json", + apikey: this.serviceRoleKey, + Authorization: `Bearer ${options.accessToken || this.serviceRoleKey}`, + ...(options.body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller.signal, + }); + const text = await response.text(); + let body = {}; + try { + body = text ? JSON.parse(text) : {}; + } catch { + body = {}; + } + if (!response.ok) throw safeAuthError(response.status, body, options.context); + return body; + } catch (error) { + if (error?.name === "AbortError") { + throw new HttpError(504, "auth_timeout", "身份服务响应超时,请稍后重试。"); + } + if (error instanceof HttpError) throw error; + throw new HttpError(502, "auth_unreachable", "无法连接身份服务,请稍后重试。"); + } finally { + clearTimeout(timeout); + } + } + + async isBootstrapRequired() { + if (!this.authEnabled || !this.bootstrapEnabled || !this.isConfigured()) return false; + const bindings = await this.dataProvider.select("app_workspace_members", { + select: "user_id", + filters: { workspace_id: `eq.${this.workspaceId}` }, + limit: 1, + }); + return !Array.isArray(bindings) || bindings.length === 0; + } + + async singleLoginAccount() { + const bindings = await this.dataProvider.select("app_workspace_members", { + select: "workspace_id,user_id,role", + filters: { + workspace_id: `eq.${this.workspaceId}`, + }, + limit: 2, + }); + if (!Array.isArray(bindings) || bindings.length !== 1) { + throw new HttpError(503, "single_user_account_invalid", "本机管理员账号状态异常,请检查安装配置。"); + } + const binding = bindings[0]; + const profiles = await this.dataProvider.select("app_users", { + select: "id,display_name", + filters: { id: `eq.${binding.user_id}` }, + limit: 1, + }); + const username = normalizeUsername(profiles?.[0]?.display_name || ""); + if (!username) { + throw new HttpError(503, "single_user_account_invalid", "本机管理员用户名缺失,请检查安装配置。"); + } + return { id: binding.user_id, username }; + } + + async resolveLoginEmail(identifier) { + if (String(identifier).includes("@")) return validateEmail(identifier); + const username = validateUsername(identifier); + const account = await this.singleLoginAccount(); + if (normalizeUsername(account.username).toLowerCase() !== username.toLowerCase()) { + throw new HttpError(401, "invalid_credentials", "用户名或密码不正确,或登录会话已经过期。"); + } + const result = await this.authRequest(`admin/users/${encodeURIComponent(account.id)}`); + const user = result?.user || result; + if (!user?.email) { + throw new HttpError(503, "single_user_account_invalid", "本机管理员账号无法登录,请检查安装配置。"); + } + return validateEmail(user.email); + } + + async principalForUser(user) { + const memberships = await this.dataProvider.select("app_workspace_members", { + select: "workspace_id,user_id,role", + filters: { + workspace_id: `eq.${this.workspaceId}`, + user_id: `eq.${user.id}`, + }, + limit: 1, + }); + const membership = memberships?.[0]; + if (!membership || !Object.hasOwn(ROLE_LEVEL, membership.role)) { + throw new HttpError(403, "workspace_access_denied", "当前账号没有访问此工作区的权限。"); + } + const profiles = await this.dataProvider.select("app_users", { + select: "id,display_name", + filters: { id: `eq.${user.id}` }, + limit: 1, + }); + return Object.freeze({ + id: user.id, + email: normalizeEmail(user.email), + username: normalizeUsername(profiles?.[0]?.display_name || user.user_metadata?.username || user.user_metadata?.display_name || "管理员"), + display_name: normalizeUsername(profiles?.[0]?.display_name || user.user_metadata?.username || user.user_metadata?.display_name || "管理员"), + workspace_id: membership.workspace_id, + role: membership.role, + }); + } + + async verifyAccessToken(accessToken) { + if (!accessToken) throw new HttpError(401, "authentication_required", "请先登录。"); + const key = tokenHash(accessToken); + const cached = this.cache.get(key); + if (cached && cached.expiresAt > Date.now()) return cached.principal; + const user = await this.authRequest("user", { accessToken }); + const principal = await this.principalForUser(user); + this.cache.set(key, { principal, expiresAt: Date.now() + this.cacheTtlMs }); + return principal; + } + + async passwordSessionByEmail(email, password) { + const session = await this.authRequest("token?grant_type=password", { + method: "POST", + body: { email, password }, + }); + const principal = await this.verifyAccessToken(session.access_token); + return { ...session, principal }; + } + + async passwordSession(identifier, password) { + return this.passwordSessionByEmail(await this.resolveLoginEmail(identifier), password); + } + + async refreshSession(refreshToken) { + if (!refreshToken) throw new HttpError(401, "authentication_required", "请先登录。"); + const session = await this.authRequest("token?grant_type=refresh_token", { + method: "POST", + body: { refresh_token: refreshToken }, + }); + const principal = await this.verifyAccessToken(session.access_token); + return { ...session, principal }; + } + + setSessionCookies(res, session, csrfToken = randomBytes(24).toString("base64url")) { + const accessMaxAge = Math.max(60, Number(session.expires_in) || 3600); + res.setHeader("Set-Cookie", [ + serializeCookie(this.cookieNames.access, session.access_token, { + httpOnly: true, + secure: this.cookieSecure, + maxAge: accessMaxAge, + }), + serializeCookie(this.cookieNames.refresh, session.refresh_token, { + httpOnly: true, + secure: this.cookieSecure, + maxAge: this.refreshMaxAge, + }), + serializeCookie(this.cookieNames.csrf, csrfToken, { + httpOnly: false, + secure: this.cookieSecure, + maxAge: this.refreshMaxAge, + }), + ]); + return csrfToken; + } + + clearSessionCookies(res) { + res.setHeader("Set-Cookie", Object.values(this.cookieNames).map((name) => serializeCookie(name, "", { + httpOnly: name !== this.cookieNames.csrf, + secure: this.cookieSecure, + maxAge: 0, + }))); + } + + async authenticateRequest(req, res) { + if (!this.authEnabled) { + return { + principal: Object.freeze({ + id: "auth-disabled-diagnostic", + email: "", + display_name: "本地开发者", + workspace_id: this.workspaceId, + role: "owner", + }), + source: "disabled", + }; + } + this.assertConfigured(); + const authorization = String(req.headers?.authorization || ""); + const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1]?.trim() || ""; + const cookies = parseCookies(req.headers?.cookie); + const accessToken = bearer || cookies[this.cookieNames.access] || ""; + if (!accessToken) { + if (!cookies[this.cookieNames.refresh]) return null; + try { + const session = await this.refreshSession(cookies[this.cookieNames.refresh]); + this.setSessionCookies(res, session, cookies[this.cookieNames.csrf] || undefined); + return { principal: session.principal, source: "cookie" }; + } catch (refreshError) { + this.clearSessionCookies(res); + if (refreshError?.status === 403) throw refreshError; + return null; + } + } + try { + return { + principal: await this.verifyAccessToken(accessToken), + source: bearer ? "bearer" : "cookie", + }; + } catch (error) { + if (bearer || error?.status === 403 || !cookies[this.cookieNames.refresh]) throw error; + try { + const session = await this.refreshSession(cookies[this.cookieNames.refresh]); + this.setSessionCookies(res, session, cookies[this.cookieNames.csrf] || undefined); + return { principal: session.principal, source: "cookie" }; + } catch (refreshError) { + this.clearSessionCookies(res); + if (refreshError?.status === 403) throw refreshError; + return null; + } + } + } + + requireRole(auth, minimumRole) { + if (!auth?.principal) throw new HttpError(401, "authentication_required", "请先登录。"); + const actual = ROLE_LEVEL[auth.principal.role]; + const required = ROLE_LEVEL[minimumRole]; + if (!Number.isInteger(actual) || !Number.isInteger(required) || actual < required) { + throw new HttpError(403, "insufficient_role", "当前账号没有执行此操作的权限。", { + required_role: minimumRole, + }); + } + } + + async recordAudit(auth, event = {}) { + const action = String(event.action || "").trim().slice(0, 120); + if (!action || !AUDIT_FILTER_PATTERN.test(action) || !this.workspaceId || !this.dataProvider?.insert) return false; + try { + await this.dataProvider.insert("audit_events", [{ + id: `audit_${randomUUID()}`, + workspace_id: this.workspaceId, + actor_user_id: /^[0-9a-f-]{36}$/i.test(String(auth?.principal?.id || "")) ? auth.principal.id : null, + action, + entity_type: String(event.entity_type || "").trim().slice(0, 80) || null, + entity_id: String(event.entity_id || "").trim().slice(0, 240) || null, + request_id: String(event.request_id || "").trim().slice(0, 120) || null, + before_json: sanitizeAuditValue(event.before), + after_json: sanitizeAuditValue(event.after), + }], { returning: false }); + return true; + } catch (error) { + console.error("Audit write failed.", { action, code: String(error?.code || "audit_write_failed") }); + return false; + } + } + + async listAuditEvents(auth, options = {}) { + this.requireRole(auth, "admin"); + this.assertConfigured(); + const action = auditFilter(options.action, "action", 120); + const entityType = auditFilter(options.entity_type, "entity_type", 80); + const entityId = auditFilter(options.entity_id, "entity_id", 240); + const limit = Math.min(200, Math.max(1, Number.parseInt(options.limit, 10) || 50)); + const filters = { workspace_id: `eq.${this.workspaceId}` }; + if (action) filters.action = `eq.${action}`; + if (entityType) filters.entity_type = `eq.${entityType}`; + if (entityId) filters.entity_id = `eq.${entityId}`; + const rows = await this.dataProvider.select("audit_events", { + select: "id,actor_user_id,action,entity_type,entity_id,request_id,before_json,after_json,created_at", + filters, + order: "created_at.desc", + limit, + }); + return (rows || []).map((row) => ({ + id: row.id, + actor_user_id: row.actor_user_id || null, + action: row.action, + entity_type: row.entity_type || null, + entity_id: row.entity_id || null, + request_id: row.request_id || null, + before: sanitizeAuditValue(row.before_json), + after: sanitizeAuditValue(row.after_json), + created_at: row.created_at || null, + })); + } + + assertCsrf(req, auth) { + if (!this.authEnabled || auth?.source !== "cookie") return; + const cookies = parseCookies(req.headers?.cookie); + const cookieToken = cookies[this.cookieNames.csrf] || ""; + const headerToken = req.headers?.["x-csrf-token"] || ""; + if (!safeEqual(cookieToken, headerToken)) { + throw new HttpError(403, "csrf_failed", "请求校验失败,请刷新页面后重试。"); + } + } + + async sessionStatus(req, res) { + if (!this.authEnabled) { + return { + enabled: false, + authenticated: true, + bootstrap_required: false, + user: { username: "本机管理员", display_name: "本机管理员" }, + }; + } + this.assertConfigured(); + const bootstrapRequired = await this.isBootstrapRequired(); + let auth = null; + try { + auth = await this.authenticateRequest(req, res); + } catch (error) { + if (error?.status === 403) throw error; + this.clearSessionCookies(res); + } + const cookies = parseCookies(req.headers?.cookie); + return { + enabled: true, + authenticated: Boolean(auth?.principal), + bootstrap_required: bootstrapRequired, + csrf_token: auth?.source === "cookie" ? cookies[this.cookieNames.csrf] || "" : "", + user: publicUser(auth?.principal), + }; + } + + async login(body, res) { + if (!this.authEnabled) throw new HttpError(409, "auth_disabled", "当前配置未启用登录。"); + const { identifier, password } = validateLoginCredentials(body); + const session = await this.passwordSession(identifier, password); + const csrfToken = this.setSessionCookies(res, session); + return { + authenticated: true, + csrf_token: csrfToken, + user: publicUser(session.principal), + }; + } + + cliSessionPayload(session) { + return { + token_type: "bearer", + access_token: session.access_token, + refresh_token: session.refresh_token, + expires_in: Math.max(60, Number(session.expires_in) || 3600), + user: publicUser(session.principal), + }; + } + + async cliLogin(body) { + if (!this.authEnabled) throw new HttpError(409, "auth_disabled", "当前配置未启用登录。"); + const { identifier, password } = validateLoginCredentials(body); + return this.cliSessionPayload(await this.passwordSession(identifier, password)); + } + + async cliRefresh(body) { + if (!this.authEnabled) throw new HttpError(409, "auth_disabled", "当前配置未启用登录。"); + const refreshToken = String(body?.refresh_token || "").trim(); + if (!refreshToken || refreshToken.length > 4096) { + throw new HttpError(401, "authentication_required", "CLI 登录会话已过期,请重新登录。"); + } + return this.cliSessionPayload(await this.refreshSession(refreshToken)); + } + + async bootstrap(body, res) { + if (!this.authEnabled || !this.bootstrapEnabled) { + throw new HttpError(404, "not_found", "API route was not found."); + } + if (this.bootstrapPromise) { + await this.bootstrapPromise.catch(() => {}); + throw new HttpError(409, "bootstrap_completed", "本机管理员已经创建,请直接登录。"); + } + this.bootstrapPromise = this.bootstrapAccount(body, res); + try { + return await this.bootstrapPromise; + } finally { + this.bootstrapPromise = null; + } + } + + async bootstrapAccount(body, res) { + this.assertConfigured(); + if (!(await this.isBootstrapRequired())) { + throw new HttpError(409, "bootstrap_completed", "本机管理员已经创建,请直接登录。"); + } + const username = validateUsername(body?.username || body?.display_name); + const password = validatePassword(body?.password); + const email = internalOwnerEmail(this.workspaceId); + const created = await this.authRequest("admin/users", { + method: "POST", + body: { + email, + password, + email_confirm: true, + user_metadata: { display_name: username, username }, + }, + }); + const user = created.user || created; + if (!user?.id) throw new HttpError(502, "auth_provider_error", "身份服务没有返回有效账号。"); + try { + await this.dataProvider.upsert("app_users", [{ id: user.id, display_name: username }], { onConflict: "id" }); + await this.dataProvider.upsert("app_workspace_members", [{ + workspace_id: this.workspaceId, + user_id: user.id, + role: "owner", + }], { onConflict: "workspace_id,user_id" }); + await this.dataProvider.update("app_workspaces", { created_by: user.id }, { + id: `eq.${this.workspaceId}`, + created_by: "is.null", + }, { returning: false }); + } catch (error) { + await this.authRequest(`admin/users/${encodeURIComponent(user.id)}`, { method: "DELETE" }).catch(() => {}); + throw new HttpError(502, "bootstrap_persistence_failed", "个人账号未能写入工作区,已撤销本次创建。", { + provider_code: String(error?.code || "persistence_failed").slice(0, 80), + }); + } + const session = await this.passwordSessionByEmail(email, password); + const csrfToken = this.setSessionCookies(res, session); + return { + authenticated: true, + csrf_token: csrfToken, + user: publicUser(session.principal), + }; + } + + async refresh(req, res) { + if (!this.authEnabled) throw new HttpError(409, "auth_disabled", "当前配置未启用登录。"); + const cookies = parseCookies(req.headers?.cookie); + const session = await this.refreshSession(cookies[this.cookieNames.refresh]); + const csrfToken = this.setSessionCookies(res, session, cookies[this.cookieNames.csrf] || undefined); + return { authenticated: true, csrf_token: csrfToken, user: publicUser(session.principal) }; + } + + async logout(req, res) { + const cookies = parseCookies(req.headers?.cookie); + const accessToken = cookies[this.cookieNames.access] || ""; + if (this.authEnabled && accessToken) { + await this.authRequest("logout?scope=local", { method: "POST", accessToken }).catch(() => {}); + this.cache.delete(tokenHash(accessToken)); + } + this.clearSessionCookies(res); + return { authenticated: false }; + } +} + +export function createAuthService(options = {}) { + return new AuthService(options); +} + +export { ROLE_LEVEL, parseCookies, serializeCookie }; diff --git a/demohouse/sales-intelligence-workbench/backend/src/security/rateLimiter.js b/demohouse/sales-intelligence-workbench/backend/src/security/rateLimiter.js new file mode 100644 index 00000000..3d352604 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/security/rateLimiter.js @@ -0,0 +1,76 @@ +import { HttpError } from "../utils/http.js"; + +function positiveInteger(value, fallback) { + const number = Number(value); + return Number.isInteger(number) && number > 0 ? number : fallback; +} + +export class MemoryRateLimiter { + constructor(options = {}) { + this.limit = positiveInteger(options.limit, 60); + this.windowMs = positiveInteger(options.windowMs, 60_000); + this.buckets = new Map(); + this.operations = 0; + } + + consume(key, now = Date.now()) { + const normalizedKey = String(key || "unknown").slice(0, 240); + let bucket = this.buckets.get(normalizedKey); + if (!bucket || bucket.resetAt <= now) { + bucket = { count: 0, resetAt: now + this.windowMs }; + this.buckets.set(normalizedKey, bucket); + } + bucket.count += 1; + this.operations += 1; + if (this.operations % 500 === 0) this.cleanup(now); + const remaining = Math.max(0, this.limit - bucket.count); + const retryAfter = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)); + return { + allowed: bucket.count <= this.limit, + limit: this.limit, + remaining, + retryAfter, + resetAt: bucket.resetAt, + }; + } + + cleanup(now = Date.now()) { + for (const [key, bucket] of this.buckets) { + if (bucket.resetAt <= now) this.buckets.delete(key); + } + } +} + +export function enforceRateLimit(res, limiter, key, code = "rate_limit_exceeded") { + const result = limiter.consume(key); + res.setHeader("X-RateLimit-Limit", String(result.limit)); + res.setHeader("X-RateLimit-Remaining", String(result.remaining)); + if (!result.allowed) { + res.setHeader("Retry-After", String(result.retryAfter)); + throw new HttpError(429, code, "请求过于频繁,请稍后重试。", { + retry_after_seconds: result.retryAfter, + }); + } + return result; +} + +export function createRateLimiters(env) { + return Object.freeze({ + general: new MemoryRateLimiter({ + limit: env?.number?.("API_RATE_LIMIT_PER_MIN", 240) || 240, + windowMs: 60_000, + }), + write: new MemoryRateLimiter({ + limit: env?.number?.("API_WRITE_RATE_LIMIT_PER_MIN", 90) || 90, + windowMs: 60_000, + }), + paid: new MemoryRateLimiter({ + limit: env?.number?.("API_PAID_RATE_LIMIT_PER_MIN", 30) || 30, + windowMs: 60_000, + }), + auth: new MemoryRateLimiter({ + limit: env?.number?.("AUTH_RATE_LIMIT_PER_15_MIN", 20) || 20, + windowMs: 15 * 60_000, + }), + }); +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/server.js b/demohouse/sales-intelligence-workbench/backend/src/server.js new file mode 100644 index 00000000..3953d561 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/server.js @@ -0,0 +1,17 @@ +import { createApp } from "./app.js"; + +const port = Number(process.env.PORT || 8787); +const host = process.env.HOST || "127.0.0.1"; +const server = createApp(); + +server.listen(port, host, () => { + console.log(`sales-intelligence-workbench-api listening on http://${host}:${port}`); +}); + +process.on("SIGTERM", () => { + server.close(() => process.exit(0)); +}); + +process.on("SIGINT", () => { + server.close(() => process.exit(0)); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/src/services/adminStatusService.js b/demohouse/sales-intelligence-workbench/backend/src/services/adminStatusService.js new file mode 100644 index 00000000..617ab8bd --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/services/adminStatusService.js @@ -0,0 +1,162 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +function finiteNumber(value, fallback = 0) { + const number = Number(value); + return Number.isFinite(number) ? number : fallback; +} + +function safeCode(value) { + return String(value || "").replace(/[^A-Za-z0-9_.-]/g, "").slice(0, 80) || null; +} + +async function readJson(filePath) { + return JSON.parse(await fs.readFile(filePath, "utf8")); +} + +async function inspectBackups(backupDir) { + if (!backupDir) { + return { + configured: false, + status: "unavailable", + backup_count: 0, + invalid_package_count: 0, + latest: null, + }; + } + + try { + const entries = await fs.readdir(backupDir, { withFileTypes: true }); + const packages = []; + let invalidPackageCount = 0; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + try { + const manifest = await readJson(path.join(backupDir, entry.name, "manifest.json")); + if (!manifest?.backup_id || !manifest?.created_at || !manifest?.row_counts) { + invalidPackageCount += 1; + continue; + } + packages.push({ + backup_id: String(manifest.backup_id).slice(0, 160), + created_at: String(manifest.created_at), + format_version: finiteNumber(manifest.format_version, 0), + table_count: Object.keys(manifest.row_counts || {}).length, + row_count: Object.values(manifest.row_counts || {}) + .reduce((total, value) => total + finiteNumber(value, 0), 0), + file_count: Array.isArray(manifest.files) ? manifest.files.length : 0, + checksums_declared: Array.isArray(manifest.files) + && manifest.files.length > 0 + && manifest.files.every((file) => /^[a-f0-9]{64}$/i.test(String(file?.sha256 || ""))), + }); + } catch { + invalidPackageCount += 1; + } + } + packages.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at))); + return { + configured: true, + status: packages.length ? "ready" : "not_created", + backup_count: packages.length, + invalid_package_count: invalidPackageCount, + latest: packages[0] || null, + }; + } catch (error) { + return { + configured: true, + status: error?.code === "ENOENT" ? "not_created" : "unreadable", + backup_count: 0, + invalid_package_count: 0, + latest: null, + }; + } +} + +async function inspectLiveDoctor(filePath, ttlMs) { + if (!filePath) return { configured: false, status: "unavailable", checked_at: null, fresh: false, checks: [] }; + try { + const report = await readJson(filePath); + const checkedAt = report.checked_at || report.backend?.finished_at || null; + const ageMs = checkedAt ? Math.max(0, Date.now() - new Date(checkedAt).getTime()) : null; + const fresh = ageMs !== null && Number.isFinite(ageMs) && ageMs <= ttlMs; + const checks = Object.entries(report.backend?.checks || {}).map(([provider, check]) => { + const normalized = check?.health && check?.find + ? { called: Boolean(check.health.called || check.find.called), ok: Boolean(check.ok), provider_mode: check.health.provider_mode } + : check || {}; + return { + provider, + called: Boolean(normalized.called), + ok: Boolean(normalized.ok), + provider_mode: String(normalized.provider_mode || "unknown").slice(0, 40), + error_code: safeCode(normalized.error?.code), + }; + }); + return { + configured: true, + status: !fresh ? "stale" : report.ok ? "passed" : "failed", + check_type: String(report.check_type || report.backend?.check_type || "read_only_live").slice(0, 80), + selected_provider: safeCode(report.selected_provider || report.backend?.selected_provider), + checked_at: checkedAt, + fresh, + age_ms: ageMs, + ttl_ms: ttlMs, + runtime_ready: Boolean(report.backend?.runtime_ready), + blocker_count: Array.isArray(report.backend?.blockers) ? report.backend.blockers.length : 0, + checks, + }; + } catch (error) { + return { + configured: true, + status: error?.code === "ENOENT" ? "not_run" : "unreadable", + checked_at: null, + fresh: false, + checks: [], + }; + } +} + +export class AdminStatusService { + constructor(options = {}) { + this.env = options.env; + this.runtimePolicy = options.runtimePolicy; + this.getProviderStatus = options.getProviderStatus || (() => ({ providers: [], repository: {} })); + } + + async getStatus() { + const value = (name, fallback = "") => this.env?.value?.(name, fallback) ?? fallback; + const host = String(value("HOST", "127.0.0.1")); + const ttlMs = Math.max(60_000, finiteNumber(value("LIVE_DOCTOR_TTL_MS", "900000"), 900_000)); + const [backup, liveDoctor] = await Promise.all([ + inspectBackups(String(value("SALES_WORKBENCH_BACKUP_DIR", "")).trim()), + inspectLiveDoctor(String(value("SALES_WORKBENCH_LIVE_DOCTOR_FILE", "")).trim(), ttlMs), + ]); + const providerStatus = this.getProviderStatus(); + return { + schema_version: 1, + read_only: true, + deployment: { + repository_mode: providerStatus.repository?.active || value("REPOSITORY_MODE", "supabase"), + fail_closed: Boolean(this.runtimePolicy.fail_closed), + host, + port: finiteNumber(value("PORT", "8787"), 8787), + loopback_only: ["127.0.0.1", "::1", "localhost"].includes(host), + http_auth_enabled: Boolean(this.runtimePolicy.http_auth_enabled), + }, + workspace: { + slug: String(value("APP_WORKSPACE_SLUG", "default")).slice(0, 120), + name: String(value("APP_WORKSPACE_NAME", "Sales Workbench")).slice(0, 160), + }, + providers: (providerStatus.providers || []) + .map((provider) => ({ + id: provider.id, + label: provider.label, + status: provider.status, + configured: !["missing_config", "disabled"].includes(provider.status), + run_enabled: provider.safe_config?.run_enabled !== false, + missing: provider.missing || [], + })), + backup, + live_doctor: liveDoctor, + }; + } +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/services/feishuImportTaskService.js b/demohouse/sales-intelligence-workbench/backend/src/services/feishuImportTaskService.js new file mode 100644 index 00000000..254d6b88 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/services/feishuImportTaskService.js @@ -0,0 +1,274 @@ +import { runFeishuImport } from "../../scripts/import-feishu-cli.mjs"; +import { HttpError } from "../utils/http.js"; +import { makeId } from "../utils/ids.js"; +import { nowIso } from "../utils/time.js"; + +const ACTIVE_STATUSES = new Set(["queued", "running"]); +const ALLOWED_DOCUMENT_HOSTS = [ + "feishu.cn", + "larkoffice.com", + "larksuite.com", +]; + +function enabledValue(value, fallback) { + const text = String(value ?? "").trim().toLowerCase(); + if (!text) return fallback; + return ["1", "true", "yes", "on"].includes(text); +} + +function compact(value, maxLength) { + return String(value || "").replace(/\s+/g, " ").trim().slice(0, maxLength); +} + +function validateDate(value, field) { + const text = compact(value, 80); + if (!text) return ""; + if (!Number.isFinite(Date.parse(text))) { + throw new HttpError(400, "bad_request", `${field}不是有效日期。`); + } + return text; +} + +function validDocumentTarget(value) { + try { + const url = new URL(value); + const allowedHost = ALLOWED_DOCUMENT_HOSTS.some((host) => ( + url.hostname === host || url.hostname.endsWith(`.${host}`) + )); + return ( + url.protocol === "https:" + && allowedHost + && /^\/(?:wiki|docx)\//.test(url.pathname) + ); + } catch { + return false; + } +} + +function validConversationTarget(value) { + if (/^ou_[A-Za-z0-9_-]+$/i.test(value)) return false; + if (value.startsWith("oc_")) return /^oc_[A-Za-z0-9]+$/.test(value); + return value.length <= 100; +} + +function publicImport(imported) { + return { + source_type: imported.source_type || "", + title: imported.title || "", + action: imported.action || "", + status: imported.status || "", + material_id: imported.material_id || imported.imported_material_id || null, + duration_ms: Number(imported.duration_ms || 0), + error: imported.error?.message + ? { message: compact(imported.error.message, 300) } + : null, + }; +} + +function publicTask(task) { + return { + id: task.id, + company_id: task.company_id, + source_kind: task.source_kind, + source_label: task.source_label, + status: task.status, + summary: task.summary, + created_at: task.created_at, + started_at: task.started_at, + completed_at: task.completed_at, + result: task.result + ? { + ok: Boolean(task.result.ok), + summary: { ...task.result.summary }, + imports: (task.result.imports || []).map(publicImport), + } + : null, + error: task.error ? { message: task.error.message } : null, + }; +} + +export class FeishuImportTaskService { + constructor(options = {}) { + this.env = options.env; + this.salesService = options.salesService; + this.runner = options.runner || runFeishuImport; + this.tasks = new Map(); + this.enabled = enabledValue( + this.env?.value?.("FEISHU_CLI_IMPORT_ENABLED", "") + || this.env?.value?.("FEISHU_SYNC_ENABLED", ""), + false, + ); + this.maxTasks = Math.max(20, Number(this.env?.value?.("FEISHU_CLI_IMPORT_TASK_LIMIT", "100")) || 100); + } + + status() { + return { + available: this.enabled, + supported_sources: ["conversation", "document"], + }; + } + + normalizeRequest(companyId, body = {}) { + if (!this.enabled) { + throw new HttpError( + 503, + "feishu_import_unavailable", + "当前部署未启用飞书资料导入。", + ); + } + this.salesService.requireCompany(companyId); + const sourceKind = compact(body.source_kind, 40); + if (!["conversation", "document"].includes(sourceKind)) { + throw new HttpError(400, "bad_request", "资料类型必须是飞书会话或云文档。"); + } + const target = compact(body.target, sourceKind === "document" ? 1000 : 200); + if (!target) throw new HttpError(400, "bad_request", "请输入要导入的飞书资料。"); + if (/[\u0000-\u001f]/.test(target)) { + throw new HttpError(400, "bad_request", "飞书资料标识包含无效字符。"); + } + if (sourceKind === "document" && !validDocumentTarget(target)) { + throw new HttpError(400, "bad_request", "请输入完整的 https:// 飞书云文档或知识库链接。"); + } + if (sourceKind === "conversation" && !validConversationTarget(target)) { + throw new HttpError(400, "bad_request", "飞书会话请填写联系人姓名或 oc_ 开头的会话 ID,不支持 Open ID。"); + } + + const start = validateDate(body.start, "开始时间"); + const end = validateDate(body.end, "结束时间"); + if (start && end && Date.parse(start) > Date.parse(end)) { + throw new HttpError(400, "bad_request", "开始时间不能晚于结束时间。"); + } + const pageLimit = Math.min(10, Math.max(1, Number(body.page_limit || 3) || 3)); + return { + companyId, + sourceKind, + target, + start, + end, + pageLimit, + }; + } + + pruneTasks() { + if (this.tasks.size < this.maxTasks) return; + const removable = [...this.tasks.values()] + .filter((task) => !ACTIVE_STATUSES.has(task.status)) + .sort((left, right) => String(left.created_at).localeCompare(String(right.created_at))); + while (this.tasks.size >= this.maxTasks && removable.length) { + this.tasks.delete(removable.shift().id); + } + } + + async start(companyId, body = {}) { + const request = this.normalizeRequest(companyId, body); + const active = [...this.tasks.values()].find((task) => ( + task.company_id === companyId && ACTIVE_STATUSES.has(task.status) + )); + if (active) { + throw new HttpError(409, "feishu_import_in_progress", "该企业已有飞书资料正在导入。", { + task_id: active.id, + }); + } + + this.pruneTasks(); + const task = { + id: makeId("feishu_import"), + company_id: companyId, + source_kind: request.sourceKind, + source_label: request.sourceKind === "document" ? "云文档" : "飞书会话", + status: "queued", + summary: "导入任务已创建。", + created_at: nowIso(), + started_at: null, + completed_at: null, + result: null, + error: null, + }; + this.tasks.set(task.id, task); + queueMicrotask(() => { + this.run(task, request).catch(() => { + // run() records a public-safe terminal error on the task. + }); + }); + return publicTask(task); + } + + async run(task, request) { + task.status = "running"; + task.summary = "正在从飞书读取并写入企业资料库。"; + task.started_at = nowIso(); + try { + const options = { + apiUrl: "", + companyId: request.companyId, + docs: request.sourceKind === "document" ? [request.target] : [], + p2pUser: request.sourceKind === "conversation" && !request.target.startsWith("oc_") + ? request.target + : "", + chatId: request.sourceKind === "conversation" && request.target.startsWith("oc_") + ? request.target + : "", + messageQuery: "", + start: request.start, + end: request.end, + pageSize: 50, + pageLimit: request.pageLimit, + titlePrefix: "", + maxAttempts: 3, + retryDelayMs: 800, + incremental: true, + resumeSource: false, + dryRun: false, + authSession: "", + syncStateLoader: async (source) => this.salesService.getMaterialSyncState( + request.companyId, + { + title: source.display_name || request.target, + source, + }, + ), + materialImporter: async (material) => this.salesService.importMaterial( + request.companyId, + material, + ), + }; + const result = await this.runner(options); + task.result = { + ok: Boolean(result.ok), + summary: { ...(result.summary || {}) }, + imports: (result.imports || []).map(publicImport), + }; + task.status = result.ok ? "succeeded" : "failed"; + task.summary = result.ok + ? "飞书资料已导入,可在历史资料中查看。" + : "部分或全部飞书资料导入失败。"; + if (!result.ok) { + const firstError = result.imports?.find((item) => item.error?.message)?.error?.message; + task.error = { message: compact(firstError || "飞书资料导入失败。", 300) }; + } + } catch (error) { + task.status = "failed"; + task.summary = "飞书资料导入失败。"; + task.error = { + message: compact( + error?.code === "ENOENT" + ? "本机未安装或无法找到飞书 CLI。" + : error?.message || "飞书资料导入失败。", + 300, + ), + }; + } finally { + task.completed_at = nowIso(); + } + return publicTask(task); + } + + get(companyId, taskId) { + this.salesService.requireCompany(companyId); + const task = this.tasks.get(taskId); + if (!task || task.company_id !== companyId) { + throw new HttpError(404, "feishu_import_not_found", "未找到该飞书导入任务。"); + } + return publicTask(task); + } +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/services/providerService.js b/demohouse/sales-intelligence-workbench/backend/src/services/providerService.js new file mode 100644 index 00000000..7b3a0249 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/services/providerService.js @@ -0,0 +1,83 @@ +import { HttpError } from "../utils/http.js"; + +export class ProviderService { + constructor(options = {}) { + this.getProviderStatusSnapshot = options.getProviderStatus || (() => ({})); + this.webSearchProvider = options.webSearchProvider || null; + this.modelProvider = options.modelProvider || null; + this.dataProProvider = options.dataProProvider || null; + this.openVikingProvider = options.openVikingProvider || null; + this.supabaseDataProvider = options.supabaseDataProvider || null; + } + + getProviderStatus() { + return this.getProviderStatusSnapshot(); + } + + async probeWebSearch(body) { + if (!this.webSearchProvider) throw new HttpError(500, "provider_unavailable", "Web search provider is not available."); + const query = String(body.query || "").trim(); + if (!query) throw new HttpError(400, "bad_request", "query is required."); + const result = await this.webSearchProvider.search({ + query, + count: body.count, + search_type: body.search_type, + time_range: body.time_range, + auth_level: body.auth_level, + need_summary: body.need_summary, + }); + return this.requireSuccess("web_search", result, "Web search probe failed."); + } + + async probeDataPro(body = {}) { + if (!this.dataProProvider) throw new HttpError(500, "provider_unavailable", "DataPro provider is not available."); + const query = String(body.query || "").trim(); + if (!query) throw new HttpError(400, "bad_request", "query is required."); + const result = await this.dataProProvider.callTool(query); + return this.requireSuccess("datapro", result, "DataPro probe failed."); + } + + async probeModel() { + if (!this.modelProvider) throw new HttpError(500, "provider_unavailable", "Model provider is not available."); + const result = await this.modelProvider.callJson({ + operation: "connectivity_probe", + system: "只输出 JSON,返回 {\"ok\":true}。", + payload: { task: "验证 Agent Plan 模型结构化响应连接" }, + maxTokens: 80, + }); + return this.requireSuccess("model", result, "Model probe failed."); + } + + async probeOpenViking(body = {}) { + if (!this.openVikingProvider) throw new HttpError(500, "provider_unavailable", "OpenViking provider is not available."); + const query = String(body.query || "").trim(); + const result = query + ? await this.openVikingProvider.findMemories(query, { limit: body.limit }) + : await this.openVikingProvider.health(); + return this.requireSuccess("openviking", result, "OpenViking probe failed."); + } + + async probeSupabase() { + if (!this.supabaseDataProvider) { + throw new HttpError(500, "provider_unavailable", "Supabase Data API provider is not available."); + } + let result; + try { + result = await this.supabaseDataProvider.probe(); + } catch (error) { + throw new HttpError(502, error.code || "provider_error", error.message || "Supabase probe failed.", { + provider: "supabase", + }); + } + return this.requireSuccess("supabase", result, "Supabase probe failed."); + } + + requireSuccess(provider, result, fallbackMessage) { + if (result?.ok) return result; + const status = result?.error?.code === "missing_config" ? 503 : 502; + throw new HttpError(status, result?.error?.code || "provider_error", result?.error?.message || fallbackMessage, { + provider, + request_id: result?.request_id || null, + }); + } +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/services/salesService.js b/demohouse/sales-intelligence-workbench/backend/src/services/salesService.js new file mode 100644 index 00000000..16385458 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/services/salesService.js @@ -0,0 +1,6375 @@ +import { createHash } from "node:crypto"; +import { createEnvReader } from "../config/runtimeEnv.js"; +import { createRuntimePolicy } from "../config/runtimePolicy.js"; +import { ProviderRunStore } from "../observability/providerRunStore.js"; +import { PaidWorkflowGuard } from "../limits/paidWorkflowGuard.js"; +import { ProviderCircuitBreaker } from "../limits/providerCircuitBreaker.js"; +import { + buildDossierAgentContext, + DossierAgent, + dossierSourceUsageErrors, +} from "../agents/dossierAgent.js"; +import { + deriveEvidenceDataAsOf, + extractGroundingDates, + extractGroundingNumbers, + groundedTextErrors, +} from "../evidence/claimGrounding.js"; +import { compileDossierEvidenceAtoms } from "../evidence/dossierEvidenceCompiler.js"; +import { + analyzeQaQuestion, + assessQaAnswerability, + buildDossierEvidencePack, + buildQaEnumerationRequirements, + buildQaEvidence, + evidencePackCitations, + fuseQaRetrievalContexts, + makeDossierFingerprint, + resolveCompanyEntity, + validateDossierModelAnswer, + validateProductionEvidencePack, + validateQaModelAnswer, +} from "../evidence/salesEvidence.js"; +import { + buildMaterialSyncIdentity, + decodeMaterialSnapshot, + encodeMaterialSnapshot, + makeMaterialContentHash, + mergeSourceItems, + normalizeSourceItems, + renderSourceItems, +} from "../sync/materialSync.js"; +import { HttpError } from "../utils/http.js"; +import { makeId } from "../utils/ids.js"; +import { nowIso } from "../utils/time.js"; + +const clone = (value) => JSON.parse(JSON.stringify(value)); +const enabled = (value) => ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase()); +const QA_SESSION_MESSAGE_PATTERN = /(?:\n|^)\s*$/; +const ASYNC_JOB_TYPES = new Set([ + "sales_dossier_generation", + "sales_material_openviking_sync", +]); +const DOSSIER_SECTION_TITLES = Object.freeze([ + "企业与业务概览", + "经营与业务动态", + "近期公开动态", + "风险与关注事项", + "销售机会判断", + "建议行动", +]); +const DOSSIER_INTERNAL_META_PATTERNS = Object.freeze([ + /关键(?:字段|数字).{0,20}(?:来源(?:存在)?差异|来源冲突|口径冲突)/, + /(?:来源|口径)[^。;\n]{0,80}(?:不一致|冲突|存在差异|等级不足|一致性问题)/, + /冲突字段|evidence_conflicts|source_selection_policy/i, + /本次(?:未|没有)(?:检索|获取|返回|发现|查询到)/, + /(?:专业数据集|豆包搜索|联网搜索|(?:企业)?数据库).{0,20}(?:调用成功|没有返回|未返回|可用于核验|完成核验但)/, + /缺少(?:两个|独立).{0,10}来源/, + /(?:资料|信息|证据)(?:仍然|依然|尚)?(?:不足|缺口|不充分|未覆盖)/, + /(?:不作为|不写为|不将[^。;\n]{0,20}写为)(?:确定|已确认)?事实/, + /(?:需|仍需|建议)(?:进一步|持续|交叉)?核验(?:来源|口径|日期|主体|数字)/, + /(?:已|可)核验的(?:风险|信息|数据|来源|经营|变化|事项)/, +]); +const DOSSIER_EVIDENCE_DEBRIS_PATTERNS = Object.freeze([ + /查看详情|查看更多(?:相关)?|立即注册|免费查看|点击查看|登录后查看|打开\s*(?:APP|客户端)/i, + /<\/?(?:table|thead|tbody|tr|th|td)\b/i, + /(?:^|[\s::])Untitled(?:[\s。;]|$)/i, + /来源返回可引用信息/, + /\b20\d{2}年\d{1,2}月\d{1,2}日\s+\d{1,2}:\d{2}\b/u, + /(?:市场|行业|公司|商业)?资讯\s*[((]来源[::]/u, + /[((]来源[::][^))]{1,80}[))]/u, +]); +const DOSSIER_LOW_VALUE_PUBLIC_SOURCE_PATTERNS = Object.freeze([ + /for better experience.{0,80}(?:verification|verify)/iu, + /(?:complete|pass).{0,40}(?:the )?verification process/iu, + /(?:verify you are human|captcha|access denied|robot check|security check)/iu, + /(?:请|需要).{0,16}(?:完成|通过).{0,12}(?:人机|安全|访问|滑动)?验证/iu, + /(?:人机验证|安全验证|访问验证|滑动验证|验证码页面|页面不存在|内容已下线)/iu, + /(?:网站|官网|网页|站群)(?:建设|设计|制作|改版|升级)(?:案例|服务|项目|方案)?/iu, + /(?:建站|SEO|数字营销|品牌网站).{0,24}(?:案例|服务商|公司|解决方案)/iu, + /(?:客户案例|成功案例).{0,24}(?:网站|官网|网页|建站)/iu, + /(?:我们|小伙伴们|项目团队).{0,32}(?:网站|官网).{0,32}(?:上线|交付|建设)/iu, + /(?:全新|新版|品牌)?官网(?:全面)?(?:焕新|上线).{0,36}(?:网站建设|建站|网页设计)/iu, + /(?:杀人诛心|让对方下不来台|狠狠打脸|瞬间打脸|当场傻眼|彻底慌了|坐不住了|真相曝光|惊天内幕)/iu, +]); +const DOSSIER_ACTION_TERMS = /发布|公告|披露|签署|合作|中标|招标|采购|投产|量产|扩产|建设|回购|融资|研发|推出|上线|召回|处罚|诉讼|失信|经营异常|监管|交付|供应链|营收|利润|销量|市占率/; +const DOSSIER_RISK_TERMS = /企业风险数据库|风险事项|行政处罚|司法诉讼|失信被执行|限制高消费|经营异常|监管处罚|产品召回|安全事故|供应中断|交付延期|合规整改/; +const DOSSIER_SPECIFIC_RISK_TERMS = /行政处罚|司法诉讼|失信被执行|限制高消费|经营异常|监管处罚|产品召回|安全事故|供应中断|交付延期|交付周期延长|被罚|索赔|赔偿/; +const DOSSIER_COMPANY_WIDE_INFERENCE = /(?:说明|表明|显示|可见|由此可见)[^。!?\n]{0,48}(?:订单结构|客户结构|业务结构|收入结构|采购结构|项目结构)[^。!?\n]{0,36}(?:为主|集中|分散|偏[大小高低]|单一|多元|稳定|不稳定|依赖)/u; +const DOSSIER_BUSINESS_TRAJECTORY_INFERENCE = /(?:业务|能力|产品|市场)[^。!?\n]{0,16}(?:(?:已|正)?(?:从|由)[^。!?\n]{1,36}(?:扩展|转向|升级|延伸)(?:到|至|为)|(?:布局)?(?:延伸|扩展)(?:到|至))/u; +const DOSSIER_RECENT_DEMAND_INFERENCE = /(?:采购|配套|交付|项目|资源)[^。!?\n]{0,12}(?:需求|意向)[^。!?\n]{0,20}(?:活跃|明确|形成|增加|释放|旺盛|存在)/u; +const DOSSIER_SENTENCE_PREDICATE_TERMS = /(?:为|是|成立|设立|注册|位于|经营|主营|从事|提供|覆盖|包含|涉及|专注|聚焦|布局|拥有|具备|采用|应用|承担|承接|生产|制造|销售|投资|收购|发布|披露|签署|合作|中标|招标|采购|建设|上线|推出|新增|更新|升级|交付|部署|扩展|扩大|进入|成为|列为|入选|获评|增长|提升|保持|减少|下降|实现|达到|存在|需要|需|应当|应|可以|可|建议|确认|核实|核验|准备|跟进|联系|验证|判断|表明|显示|反映|计划|推进|开展|完成|获得|发生|面临|影响|有助于|属于|形成|支持|服务于|负责|拟)/u; +const DOSSIER_TITLE_FRAGMENT_PATTERNS = Object.freeze([ + /(?:有限责任公司|股份有限公司|集团|公司)\s*[-—|]\s*(?:最新|近期)?.{0,24}(?:结果|公告|新闻|动态|发布)$/u, + /(?:最新|近期).{0,24}(?:中标|招标|采购|合作|签约|融资|处罚|诉讼)(?:结果)?(?:发布|公告)$/u, + /(?:中标|招标|采购|合作|签约|融资|处罚|诉讼)(?:结果|公告|新闻|动态)$/u, +]); +const DOSSIER_GENERIC_TEMPLATE_PATTERNS = Object.freeze([ + /上述业务动作指向.{0,40}(?:经营与技术方向|相关方向)/u, + /当前信息更适合作为.{0,30}背景材料/u, + /可优先验证.{0,40}相关的采购、技术协同或项目交付场景/u, + /企业近期发布产品升级公告并需要继续关注/u, + /可进一步核验重点产品线/u, + /需持续关注相关风险/u, +]); + +function safeValidationErrors(value, limit = 16) { + return firstJsonArray(value) + .map((item) => String(item || "") + .replace(/Bearer\s+[^\s,;]+/gi, "Bearer [REDACTED]") + .replace(/ark-[0-9a-f-]{24,}/gi, "[REDACTED]") + .replace(/\s+/g, " ") + .trim() + .slice(0, 500)) + .filter(Boolean) + .slice(0, limit); +} + +function hasDossierInternalMetaText(value) { + const text = String(value || ""); + return DOSSIER_INTERNAL_META_PATTERNS.some((pattern) => pattern.test(text)); +} + +function hasUnbalancedDossierPunctuation(value) { + const text = String(value || ""); + return [ + ["(", ")"], + ["(", ")"], + ["[", "]"], + ["【", "】"], + ["“", "”"], + ].some(([left, right]) => ( + text.split(left).length - 1 !== text.split(right).length - 1 + )); +} + +function hasTruncatedDossierNumber(value) { + return /(?:营业收入|营收|净利润|利润|金额|产能|市占率)[^。;\n]{0,24}\d+(?:\.\d+)?(?=\s*(?:[。;]|$))/.test( + String(value || ""), + ); +} + +function hasDossierEvidenceDebris(value) { + const text = String(value || ""); + return DOSSIER_EVIDENCE_DEBRIS_PATTERNS.some((pattern) => pattern.test(text)); +} + +function isQuestionLikeDossierText(value) { + const text = stripDossierSectionTitle(value) + .replace(/[。!?!?]+$/gu, "") + .trim(); + if (!text || text.length > 120) return false; + if (/[??]\s*$/u.test(stripDossierSectionTitle(value))) return true; + const interrogative = text.match(/是否|有无|有没有|能否|可否|如何|为什么|为何|怎样|怎么/u); + if (!interrogative) return false; + const prefix = text.slice(0, interrogative.index); + return !/(?:需要|需|应当|应|建议|确认|核实|核验|评估|判断|了解|询问|联系|验证|调查)/u.test(prefix); +} + +function dossierPointQualityErrors(value) { + const errors = []; + if (hasDossierEvidenceDebris(value)) errors.push("包含搜索站点模板或引流文字"); + if (isQuestionLikeDossierText(value)) errors.push("把检索问题或问句当作企业事实"); + if (hasUnbalancedDossierPunctuation(value)) errors.push("存在未闭合的括号、引号或方括号"); + if (hasTruncatedDossierNumber(value)) errors.push("存在缺少单位或上下文的截断数字"); + return errors; +} + +function isSubstantiveDossierSummary(value) { + const text = compactText(value, 360); + return text.length >= 40 + && !hasBadDisplayText(text) + && !hasDossierInternalMetaText(text) + && dossierPointQualityErrors(text).length === 0; +} + +function stripDossierSectionTitle(value) { + return String(value || "") + .replace(new RegExp(`^(?:${DOSSIER_SECTION_TITLES.join("|")})[::]\\s*`), "") + .trim(); +} + +function normalizeChineseDossierPunctuation(value) { + return String(value || "") + .replace(/([\p{Script=Han}”’)】])\s*:\s*/gu, "$1:") + .replace(/(? item.replace(/^\d{1,2}[.、]\s*/u, "").trim()) + .filter((item) => item.length >= 12) + .map((item) => item + .toLowerCase() + .replace(/\[[0-9]+\]/gu, "") + .replace(/[\s,。;:!?、,.!?;:'"“”‘’()()【】[\]《》<>-]/gu, "")); +} + +function dossierSentenceUnits(value) { + return stripDossierSectionTitle(value) + .split(/(?:\n+|[。!?]\s*)/u) + .map((item) => item.replace(/^\d{1,2}[.、]\s*/u, "").trim()) + .filter(Boolean); +} + +function dossierSentenceQualityErrors(value) { + const errors = []; + for (const sentence of dossierSentenceUnits(value)) { + if (DOSSIER_TITLE_FRAGMENT_PATTERNS.some((pattern) => pattern.test(sentence))) { + errors.push("包含被当作正文的搜索标题或事件标题残片"); + continue; + } + if (!DOSSIER_SENTENCE_PREDICATE_TERMS.test(sentence)) { + errors.push("包含缺少明确陈述或行动谓语的名词片段"); + } + } + if (DOSSIER_GENERIC_TEMPLATE_PATTERNS.some((pattern) => pattern.test(String(value || "")))) { + errors.push("包含不能直接形成销售结论的通用模板话术"); + } + return [...new Set(errors)]; +} + +function dossierBigramSimilarity(left, right) { + if (!left || !right) return 0; + if (left === right) return 1; + const shorter = left.length <= right.length ? left : right; + const longer = left.length > right.length ? left : right; + if (shorter.length >= 28 && longer.includes(shorter)) { + return shorter.length / longer.length; + } + const bigrams = (value) => { + const result = new Set(); + for (let index = 0; index < value.length - 1; index += 1) { + result.add(value.slice(index, index + 2)); + } + return result; + }; + const leftBigrams = bigrams(left); + const rightBigrams = bigrams(right); + if (!leftBigrams.size || !rightBigrams.size) return 0; + let overlap = 0; + for (const item of leftBigrams) { + if (rightBigrams.has(item)) overlap += 1; + } + return (2 * overlap) / (leftBigrams.size + rightBigrams.size); +} + +function dossierSectionContentErrors(body) { + const errors = []; + DOSSIER_SECTION_TITLES.forEach((title, index) => { + const text = String(body[index]?.text || ""); + const content = stripDossierSectionTitle(text); + if (!content) { + errors.push(`${title}缺少正文`); + } + if (content.length > 1200) { + errors.push(`${title}超过 1200 个字符的异常输出保护上限`); + } + const incompleteLines = content + .split(/\n+/u) + .map((item) => item.trim()) + .filter(Boolean) + .filter((item) => !/[。!?]$/u.test(item)); + if (incompleteLines.length) { + errors.push(`${title}存在未使用完整句末标点的段落或分点`); + } + if (hasDossierInternalMetaText(content)) { + errors.push(`${title}包含仅供系统内部使用的检索或证据诊断话术`); + } + dossierPointQualityErrors(content).forEach((error) => { + errors.push(`${title}${error}`); + }); + dossierSentenceQualityErrors(content).forEach((error) => { + errors.push(`${title}${error}`); + }); + }); + const seenFacts = []; + DOSSIER_SECTION_TITLES.forEach((title, index) => { + for (const fact of dossierFactUnits(body[index]?.text)) { + const duplicate = seenFacts.find((item) => dossierBigramSimilarity(item.fact, fact) >= 0.82); + if (duplicate) { + errors.push(`${title}与${duplicate.title}存在重复或高度相似的事实表述`); + continue; + } + seenFacts.push({ title, fact }); + } + }); + return errors; +} + +function dossierSourceIds(citations, predicate) { + return new Set( + citations + .filter(predicate) + .map((item) => String(item.id)), + ); +} + +function isUsableProfessionalDossierCitation(item) { + const point = safeDeterministicDossierPoint(conciseProfessionalPoint(item)); + return Boolean( + point + && !isLowValueProfessionalPoint(point) + && isSubstantiveDossierEvidencePoint(point) + ); +} + +function isUsablePublicDossierCitation(item) { + const point = safeDeterministicDossierPoint(concisePublicPoint(item)); + return Boolean( + point + && !isLowValuePublicDossierSource(item) + && isSubstantiveDossierEvidencePoint(point) + ); +} + +function dossierSectionSourcePolicy(citations, company = null) { + const professional = dossierSourceIds( + citations, + (item) => item.source_kind === "专业数据集" && isUsableProfessionalDossierCitation(item), + ); + const web = dossierSourceIds( + citations, + (item) => ( + item.source_kind === "联网搜索" + && isUsablePublicDossierCitation(item) + && ( + !company + || isRecentPublicDossierCitation(item, concisePublicPoint(item), company) + ) + ), + ); + const business = dossierSourceIds( + citations, + (item) => { + if ( + item.source_kind !== "专业数据集" + || !/企业工商数据库/.test(String(item.label || "")) + || !isUsableProfessionalDossierCitation(item) + ) return false; + if (!company) return true; + const record = dossierBusinessEntityRecord(item); + const targetName = String(company?.name || company?.legal_name || "").trim(); + return Boolean( + record + && targetName + && normalizeLegalEntityName(record.name) === normalizeLegalEntityName(targetName) + ); + }, + ); + const risk = dossierSourceIds( + citations, + (item) => ( + item.source_kind === "专业数据集" + && /企业风险数据库/.test(String(item.label || "")) + && !dossierBusinessEntityRecord(item) + && isUsableProfessionalDossierCitation(item) + ), + ); + const market = dossierSourceIds( + citations, + (item) => ( + item.source_kind === "专业数据集" + && /金融数据库|汽车销量数据库|科研学术数据搜索服务/.test(String(item.label || "")) + && !dossierBusinessEntityRecord(item) + && isUsableProfessionalDossierCitation(item) + ), + ); + const businessDynamics = market.size + ? new Set(market) + : web.size >= 2 + ? new Set(web) + : new Set(); + return { professional, web, business, risk, market, businessDynamics }; +} + +function dossierSectionSourceErrors(body, citations, company = null) { + const policy = dossierSectionSourcePolicy(citations, company); + const errors = []; + const usesAny = (index, ids) => ( + ids.size > 0 && (body[index]?.citation_ids || []).some((id) => ids.has(String(id))) + ); + const requireWhenAvailable = (index, ids, message) => { + if (ids.size && !usesAny(index, ids)) errors.push(message); + }; + + requireWhenAvailable(0, policy.business, "企业与业务概览必须优先引用企业工商数据库"); + requireWhenAvailable(1, policy.market, "经营与业务动态必须优先引用语义匹配的专业数据库"); + requireWhenAvailable(2, policy.web, "近期公开动态必须引用豆包搜索的可追溯公开来源"); + if (policy.risk.size) { + requireWhenAvailable(3, policy.risk, "风险与关注事项必须优先引用企业风险数据库"); + } + return errors; +} + +function normalizeLegalEntityName(value) { + return String(value || "") + .normalize("NFKC") + .toLowerCase() + .replace(/[\s·•()()\[\]【】_-]+/gu, ""); +} + +function escapeRegularExpression(value) { + return String(value || "").replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function dossierBusinessEntityRecord(citation) { + if (citation?.source_kind !== "专业数据集") return null; + const summary = String(citation?.summary || ""); + const name = summary.match(/(?:^|[;;])\s*公司名称\s*[::]\s*([^;;]+)/u)?.[1]?.trim() || ""; + const registryFieldCount = [ + /(?:^|[;;])\s*统一社会信用代码\s*[::]/u, + /(?:^|[;;])\s*注册号\s*[::]/u, + /(?:^|[;;])\s*(?:公司组织类型|企业类型)\s*[::]/u, + /(?:^|[;;])\s*(?:注册地址|住所)\s*[::]/u, + /(?:^|[;;])\s*成立日期\s*[::]/u, + /(?:^|[;;])\s*(?:经营范围|法人姓名|法定代表人)\s*[::]/u, + ].filter((pattern) => pattern.test(summary)).length; + // DataPro can return a registry row from a query labelled as finance, + // research, sales or risk data. Entity isolation must therefore be based on + // the structured fields in the payload instead of trusting the query label. + if (!name || registryFieldCount < 1) return null; + return name ? { id: String(citation.id || ""), name, summary } : null; +} + +function normalizeDossierCitationSemantics(citations) { + const registryKeeperByFingerprint = new Map(); + const registryFingerprint = (citation, record) => `${normalizeLegalEntityName(record.name)}:${String( + citation.summary || "", + ) + .normalize("NFKC") + .replace(/\s+/gu, "") + .replace(/[;;]/gu, ";") + .replace(/[::]/gu, ":")}`; + + firstJsonArray(citations).forEach((citation) => { + const record = dossierBusinessEntityRecord(citation); + if (!record) return; + const fingerprint = registryFingerprint(citation, record); + const current = registryKeeperByFingerprint.get(fingerprint); + if ( + !current + || ( + /企业工商数据库/u.test(String(citation.label || "")) + && !/企业工商数据库/u.test(String(current.label || "")) + ) + ) { + registryKeeperByFingerprint.set(fingerprint, citation); + } + }); + + return firstJsonArray(citations).flatMap((citation) => { + const record = dossierBusinessEntityRecord(citation); + if (!record) return [citation]; + const fingerprint = registryFingerprint(citation, record); + if (registryKeeperByFingerprint.get(fingerprint) !== citation) return []; + if (/企业工商数据库/u.test(String(citation.label || ""))) return [citation]; + const recordSuffix = String(citation.label || "").match(/\s*·\s*记录\s*\d+/u)?.[0] || ""; + return [{ + ...citation, + label: `企业工商数据库${recordSuffix || " · 自动识别记录"}`, + }]; + }); +} + +function isExplicitTargetBranchRecord(record, targetName) { + const recordKey = normalizeLegalEntityName(record?.name || ""); + const targetKey = normalizeLegalEntityName(targetName || ""); + return Boolean( + recordKey + && targetKey + && recordKey !== targetKey + && recordKey.startsWith(targetKey) + && /分公司$/u.test(String(record?.name || "").trim()) + ); +} + +function businessEntityAnchorErrors(body, citations, company) { + const targetName = String(company?.name || company?.legal_name || "").trim(); + const targetKey = normalizeLegalEntityName(targetName); + if (!targetKey) return []; + const records = citations.map(dossierBusinessEntityRecord).filter(Boolean); + const selectedIds = new Set(firstJsonArray(body[0]?.citation_ids).map(String)); + const selectedRecords = records.filter((record) => selectedIds.has(record.id)); + const targetRecords = records.filter((record) => normalizeLegalEntityName(record.name) === targetKey); + const selectedTargetRecords = selectedRecords.filter((record) => normalizeLegalEntityName(record.name) === targetKey); + if (!targetRecords.length) return []; + const errors = []; + if (!selectedTargetRecords.length) { + errors.push(`企业与业务概览必须引用公司名称完全等于“${targetName}”的工商记录`); + return errors; + } + const branchPattern = new RegExp( + `${escapeRegularExpression(targetName)}[\\p{Script=Han}A-Za-z0-9()()·]{1,24}(?:分公司|子公司)`, + "gu", + ); + for (const match of String(body[0]?.text || "").matchAll(branchPattern)) { + const referencedName = match[0]; + if (!selectedRecords.some((record) => ( + normalizeLegalEntityName(record.name) === normalizeLegalEntityName(referencedName) + ))) { + errors.push(`企业与业务概览提到“${referencedName}”,但本章没有引用该分支机构自己的工商记录`); + } + } + const sentences = dossierSentenceUnits(body[0]?.text || ""); + const sameAnchor = (anchor, recordAnchors) => recordAnchors.some((candidate) => ( + String(candidate).replace(/[,,]/gu, "") === String(anchor).replace(/[,,]/gu, "") + )); + sentences.forEach((sentence, sentenceIndex) => { + const explicitOtherRecords = selectedRecords.filter((record) => ( + normalizeLegalEntityName(record.name) !== targetKey + && sentence.includes(record.name) + )); + const allowedRecords = explicitOtherRecords.length ? explicitOtherRecords : selectedTargetRecords; + const allowedSummaries = allowedRecords.map((record) => record.summary); + for (const date of extractGroundingDates(sentence)) { + if (!allowedSummaries.some((summary) => extractGroundingDates(summary).includes(date))) { + errors.push(`企业与业务概览第 ${sentenceIndex + 1} 条把日期 ${date} 归属给“${explicitOtherRecords[0]?.name || targetName}”,但对应工商记录不支持该归属`); + } + } + for (const number of extractGroundingNumbers(sentence)) { + if (!allowedSummaries.some((summary) => sameAnchor(number, extractGroundingNumbers(summary)))) { + errors.push(`企业与业务概览第 ${sentenceIndex + 1} 条把数值 ${number} 归属给“${explicitOtherRecords[0]?.name || targetName}”,但对应工商记录不支持该归属`); + } + } + const identifiers = sentence.match(/\b[0-9A-Z]{12,24}\b/gu) || []; + for (const identifier of identifiers) { + if (!allowedSummaries.some((summary) => summary.includes(identifier))) { + errors.push(`企业与业务概览第 ${sentenceIndex + 1} 条把登记标识 ${identifier} 归属给“${explicitOtherRecords[0]?.name || targetName}”,但对应工商记录不支持该归属`); + } + } + }); + return [...new Set(errors)]; +} + +function unrelatedBusinessEntityCitationErrors(body, citations, company) { + const targetName = String(company?.name || company?.legal_name || "").trim(); + const targetKey = normalizeLegalEntityName(targetName); + if (!targetKey) return []; + const recordById = new Map( + citations + .map(dossierBusinessEntityRecord) + .filter(Boolean) + .map((record) => [record.id, record]), + ); + const errors = []; + firstJsonArray(body).slice(1).forEach((paragraph, offset) => { + const sectionIndex = offset + 1; + const paragraphText = String(paragraph?.text || ""); + const unrelated = firstJsonArray(paragraph?.citation_ids) + .map((id) => recordById.get(String(id))) + .filter((record) => { + if (!record || normalizeLegalEntityName(record.name) === targetKey) return false; + return !( + isExplicitTargetBranchRecord(record, targetName) + && paragraphText.includes(record.name) + ); + }); + for (const record of unrelated) { + errors.push( + `${DOSSIER_SECTION_TITLES[sectionIndex]}不得把未明确点名或未经关系核验的其他主体工商记录归属到目标企业:${record.name}`, + ); + } + }); + return [...new Set(errors)]; +} + +function staticRegistryInferenceErrors(body, citations) { + const citationById = new Map(citations.map((item) => [String(item.id), item])); + const registryOnly = (sectionIndex) => { + const selected = firstJsonArray(body[sectionIndex]?.citation_ids) + .map((id) => citationById.get(String(id))) + .filter(Boolean); + return Boolean( + selected.length + && selected.every((citation) => dossierBusinessEntityRecord(citation)), + ); + }; + const errors = []; + const dynamicsText = String(body[1]?.text || ""); + if ( + registryOnly(1) + && ( + /业务动作(?:主要)?聚焦/u.test(dynamicsText) + || /构成[^。!?]{0,40}(?:独立产品线|业务增长|业务变化)/u.test(dynamicsText) + || /具备直接开展[^。!?]{0,40}(?:经营条件|业务条件)/u.test(dynamicsText) + ) + ) { + errors.push("经营与业务动态不能把静态工商登记范围提升为当前业务动作、独立产品线或现实经营能力"); + } + const overviewText = String(body[0]?.text || ""); + if ( + registryOnly(0) + && /(?:同时承担|形成[^。!?]{0,30}业务定位|制造基地[^。!?]{0,20}法定主体|实际从事|主营)/u.test(overviewText) + ) { + errors.push("企业与业务概览只能把工商信息表述为登记范围,不能提升为实际主营、制造主体或现实业务定位"); + } + const opportunityText = String(body[4]?.text || ""); + if ( + registryOnly(4) + && /(?:同时承担|已具备|具备直接|已形成|现实业务能力)/u.test(opportunityText) + ) { + errors.push("销售机会判断可以把登记范围作为对接方向,但不能写成企业已承担该业务或已具备现实能力"); + } + return errors; +} + +function dossierSectionSemanticErrors(body, citations, company) { + const errors = [ + ...businessEntityAnchorErrors(body, citations, company), + ...unrelatedBusinessEntityCitationErrors(body, citations, company), + ...staticRegistryInferenceErrors(body, citations), + ]; + const citationById = new Map(citations.map((item) => [String(item.id), item])); + const sectionEvidenceText = (index) => firstJsonArray(body[index]?.citation_ids) + .map((id) => citationById.get(String(id))) + .filter(Boolean) + .map((item) => `${item.label || ""} ${item.summary || ""}`) + .join(" "); + body.slice(0, 4).forEach((paragraph, index) => { + const paragraphCitations = firstJsonArray(paragraph?.citation_ids) + .map((id) => citationById.get(String(id))) + .filter(Boolean); + if ( + paragraphCitations.some((item) => item.entity_match === "alias_scoped") + && !paragraphCitations.some((item) => item.entity_match === "verified") + && !/(?:品牌|集团|相关业务|在华业务|中国业务|公开信息显示)/u.test(String(paragraph?.text || "")) + ) { + errors.push(`${DOSSIER_SECTION_TITLES[index]}使用品牌或简称来源时必须明确主体边界`); + } + }); + const recentCitations = firstJsonArray(body[2]?.citation_ids) + .map((id) => citationById.get(String(id))) + .filter((item) => item?.source_kind === "联网搜索"); + if ( + recentCitations.length + && recentCitations.some((item) => ( + !isRecentPublicDossierCitation(item, concisePublicPoint(item), company) + )) + ) { + errors.push("近期公开动态引用了不具备明确业务事件的网页或低价值营销页面"); + } + [0, 1].forEach((sectionIndex) => { + const trajectoryText = stripDossierSectionTitle(body[sectionIndex]?.text || ""); + if ( + DOSSIER_BUSINESS_TRAJECTORY_INFERENCE.test(trajectoryText) + && !DOSSIER_BUSINESS_TRAJECTORY_INFERENCE.test(sectionEvidenceText(sectionIndex)) + ) { + errors.push("企业概览或经营动态不能把静态经营范围或少量项目外推为业务转型或能力扩展"); + } + }); + const recentText = stripDossierSectionTitle(body[2]?.text || ""); + if ( + DOSSIER_RECENT_DEMAND_INFERENCE.test(recentText) + && !/(?:采购|配套|交付|项目|资源)[^。!?\n]{0,12}(?:需求|意向)/u.test(sectionEvidenceText(2)) + ) { + errors.push("近期公开动态不能把中标或公告节奏写成来源未披露的采购需求或采购意向"); + } + const riskText = stripDossierSectionTitle(body[3]?.text || ""); + if (DOSSIER_COMPANY_WIDE_INFERENCE.test(riskText)) { + errors.push("风险与关注事项不能把个别项目或单条公开信息外推为企业整体结构性结论"); + } + if (!DOSSIER_SPECIFIC_RISK_TERMS.test(riskText)) return errors; + const riskCitations = firstJsonArray(body[3]?.citation_ids) + .map((id) => citationById.get(String(id))) + .filter(Boolean); + const hasProfessionalRisk = riskCitations.some((item) => ( + item.source_kind === "专业数据集" + && /企业风险数据库/.test(String(item.label || "")) + && isUsableProfessionalDossierCitation(item) + )); + const hasTargetSpecificPublicRisk = riskCitations.some((item) => ( + item.source_kind === "联网搜索" + && isPublicRiskEvidenceForCompany(item, concisePublicPoint(item), company) + )); + if (!hasProfessionalRisk && !hasTargetSpecificPublicRisk) { + errors.push("风险与关注事项包含未明确归属于目标企业的风险事实"); + } + return errors; +} + +function dossierSectionEvidenceGroundingErrors(body, citations) { + const citationById = new Map(citations.map((item) => [String(item?.id || ""), item])); + const errors = []; + firstJsonArray(body).forEach((paragraph, sectionIndex) => { + const title = DOSSIER_SECTION_TITLES[sectionIndex] || `第 ${sectionIndex + 1} 章`; + const segments = firstJsonArray(paragraph?.segments).length + ? firstJsonArray(paragraph.segments) + : [{ + text: stripDossierSectionTitle(paragraph?.text || ""), + citation_ids: firstJsonArray(paragraph?.citation_ids), + }]; + segments.forEach((segment, segmentIndex) => { + const evidenceTexts = firstJsonArray(segment?.citation_ids) + .map((id) => citationById.get(String(id))) + .filter(Boolean) + .flatMap((citation) => [citation.summary, citation.excerpt].filter(Boolean)); + errors.push(...groundedTextErrors({ + text: segment?.text || "", + evidenceTexts, + path: `${title}第 ${segmentIndex + 1} 段`, + requireEventFamily: false, + checkOrganizations: false, + })); + }); + }); + return [...new Set(errors)]; +} + +const JOB_STAGE_LABELS = Object.freeze({ + queued: "等待执行", + retry_wait: "正在等待自动重试", + starting: "正在准备", + collecting_evidence: "正在收集可信资料", + collecting_professional: "正在核验专业资料", + collecting_public: "正在检索公开资料", + building_evidence: "正在整理可信资料", + retrieving_memory: "正在检索历史资料", + validating_evidence: "正在校验资料", + generating_dossier: "正在生成档案", + validating_dossier: "正在核验档案", + storing_memory: "正在保存长期资料", + persisting_result: "正在保存结果", + syncing_materials: "正在同步历史资料", + cancelling: "正在取消", + succeeded: "已完成", + failed: "执行失败", + cancelled: "已取消", +}); + +function objectValue(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +function safeJobProgressDetail(value) { + const detail = objectValue(value); + const current = Number(detail.current); + const total = Number(detail.total); + const nextRetryAt = String(detail.next_retry_at || ""); + return { + ...(detail.message ? { message: String(detail.message).replace(/\s+/g, " ").trim().slice(0, 100) } : {}), + ...(Number.isInteger(current) && current >= 0 ? { current } : {}), + ...(Number.isInteger(total) && total > 0 ? { total } : {}), + ...(nextRetryAt && Number.isFinite(new Date(nextRetryAt).getTime()) + ? { next_retry_at: new Date(nextRetryAt).toISOString() } + : {}), + }; +} + +async function mapWithConcurrency(items, limit, operation) { + const values = [...items]; + const results = new Array(values.length); + let cursor = 0; + const workers = Array.from( + { length: Math.min(Math.max(1, Number(limit) || 1), Math.max(1, values.length)) }, + async () => { + while (cursor < values.length) { + const index = cursor; + cursor += 1; + results[index] = await operation(values[index], index); + } + }, + ); + await Promise.all(workers); + return results; +} + +function workflowQueryKey(provider, query) { + return `${provider}:${createHash("sha256").update(String(query || "")).digest("hex").slice(0, 24)}`; +} + +function reusableDossierCheckpoint(checkpoint, companyId, ttlMs) { + const value = objectValue(checkpoint); + if ( + Number(value.schema_version) !== 1 + || String(value.company_id || "") !== String(companyId || "") + ) return null; + const savedAt = new Date(value.updated_at || value.collected_at || "").getTime(); + if (!Number.isFinite(savedAt) || Date.now() - savedAt > ttlMs) return null; + return value; +} + +function emptySalesData() { + return { + goals: [], + companies: {}, + dossiers: {}, + materials: {}, + qa_messages: {}, + sync_sources: {}, + sync_checkpoints: {}, + jobs: {}, + }; +} + +function compactText(value, maxLength = 900) { + return normalizeImportedText(value).replace(/\s+/g, " ").trim().slice(0, maxLength); +} + +function compactCompleteSentences(value, maxLength = 300) { + const text = normalizeImportedText(value).replace(/\s+/g, " ").trim(); + if (!text || text.length <= maxLength) return text; + const sentences = text.match(/[^。!?!?]+[。!?!?]/gu) || []; + let result = ""; + for (const sentence of sentences) { + const normalized = sentence.trim(); + const candidate = result ? `${result} ${normalized}` : normalized; + if (candidate.length > maxLength) break; + result = candidate; + } + if (result) return result; + const bounded = text.slice(0, maxLength); + const boundary = Math.max( + bounded.lastIndexOf(";"), + bounded.lastIndexOf(";"), + bounded.lastIndexOf(","), + bounded.lastIndexOf(","), + ); + const completeClause = boundary >= 40 ? bounded.slice(0, boundary) : bounded; + return ensureDossierLinePunctuation(completeClause); +} + +function qaConversationHistory(messages, { maxMessages = 10, maxCharacters = 6000 } = {}) { + const history = []; + let remaining = maxCharacters; + for (const message of firstJsonArray(messages).slice(-maxMessages).reverse()) { + if (!message || !["user", "assistant"].includes(message.role) || remaining <= 0) continue; + const text = compactText(message.text || "", Math.min(1200, remaining)); + if (!text) continue; + history.push({ role: message.role, text }); + remaining -= text.length; + } + return history.reverse(); +} + +function encodeQaSessionMessage(message) { + const snapshot = { + id: String(message?.id || ""), + role: ["assistant", "user"].includes(message?.role) ? message.role : "user", + text: String(message?.text || "").trim(), + paragraphs: firstJsonArray(message?.paragraphs), + citation_ids: firstJsonArray(message?.citation_ids).map(String), + citations: firstJsonArray(message?.citations), + insufficient: Boolean(message?.insufficient), + created_at: message?.created_at || null, + }; + const encoded = Buffer.from(JSON.stringify(snapshot), "utf8").toString("base64"); + return `${snapshot.text}\n`; +} + +function decodeQaSessionMessage(message, index = 0) { + const sourceText = String(message?.text || message?.content || "").trim(); + const match = sourceText.match(QA_SESSION_MESSAGE_PATTERN); + let snapshot = null; + if (match?.[1]) { + try { + snapshot = JSON.parse(Buffer.from(match[1], "base64").toString("utf8")); + } catch { + snapshot = null; + } + } + const plainText = sourceText.replace(QA_SESSION_MESSAGE_PATTERN, "").trim(); + return { + id: String(snapshot?.id || message?.id || `openviking-qa-${index + 1}`), + role: ["assistant", "user"].includes(snapshot?.role) + ? snapshot.role + : ["assistant", "user"].includes(message?.role) ? message.role : "user", + text: String(snapshot?.text || plainText).trim(), + paragraphs: firstJsonArray(snapshot?.paragraphs), + citation_ids: firstJsonArray(snapshot?.citation_ids).map(String), + citations: firstJsonArray(snapshot?.citations), + insufficient: Boolean(snapshot?.insufficient), + created_at: snapshot?.created_at || message?.created_at || null, + }; +} + +function openVikingNotFound(result) { + const code = String(result?.error?.code || "").toLowerCase(); + const message = String(result?.error?.message || "").toLowerCase(); + return Number(result?.http_status || 0) === 404 + || ["404", "not_found", "session_not_found"].includes(code) + || /not found|does not exist|不存在|未找到/.test(message); +} + +function legacyMaterialText(content) { + const text = String(content || ""); + const body = text.match(/资料正文:([\s\S]*?)(?:\n使用边界:|$)/)?.[1]; + return cleanMaterialText(body || ""); +} + +function normalizeImportedText(value) { + return String(value || "") + .replace(//g, "") + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, " "); +} + +function normalizeInitial(name) { + const trimmed = String(name || "").trim(); + return trimmed ? trimmed.slice(0, 1) : "企"; +} + +const dataProCompanyFields = Object.freeze({ + name: ["公司名称", "企业名称", "企业全称", "company_name", "companyName", "ent_name", "entName", "name"], + unified_social_credit_code: ["统一社会信用代码", "社会信用代码", "信用代码", "unified_social_credit_code", "credit_code", "creditCode"], + legal_representative: ["法定代表人", "法人姓名", "法人", "legal_representative", "legalRepresentative", "legal_person", "legalPerson"], + registered_capital: ["注册资本", "注册资金", "registered_capital", "registeredCapital", "reg_capital", "regCapital"], + business_status: ["经营状态", "企业状态", "登记状态", "business_status", "businessStatus", "ent_status", "entStatus", "status"], + industry: ["所属行业", "行业分类", "行业", "industry_name", "industryName", "industry"], + address: ["注册地址", "住所", "企业地址", "address", "registered_address", "registeredAddress"], + province: ["省", "省份", "province", "province_name", "provinceName"], + city: ["市", "城市", "city", "city_name", "cityName"], + district: ["区县", "区/县", "区", "县", "district", "district_name", "districtName"], + established_at: ["成立日期", "成立时间", "established_at", "establishedAt", "establish_date", "establishDate"], + business_scope: ["经营范围", "business_scope", "businessScope"], +}); + +function normalizeDataFieldName(value) { + return String(value || "").normalize("NFKC").toLowerCase().replace(/[\s_.\-/()()]/g, ""); +} + +function scalarDataValue(value, maxLength = 500) { + if (!["string", "number", "boolean"].includes(typeof value)) return ""; + return String(value).replace(/\s+/g, " ").trim().slice(0, maxLength); +} + +function dataProField(item, aliases, maxLength = 500) { + if (!item || typeof item !== "object" || Array.isArray(item)) return ""; + const byNormalizedKey = new Map(Object.entries(item).map(([key, value]) => [normalizeDataFieldName(key), value])); + for (const alias of aliases) { + const value = byNormalizedKey.get(normalizeDataFieldName(alias)); + const text = scalarDataValue(value, maxLength); + if (text) return text; + } + return ""; +} + +function dataProItemLooksLikeCompany(item) { + const name = dataProField(item, dataProCompanyFields.name, 180); + if (!name) return false; + return [ + "unified_social_credit_code", + "legal_representative", + "registered_capital", + "business_status", + "address", + "established_at", + "business_scope", + ].some((field) => dataProField(item, dataProCompanyFields[field], 500)); +} + +function collectDataProCompanyItems(parsed, limit = 8) { + if (!parsed || typeof parsed !== "object") return []; + const queue = [parsed]; + const visited = new Set(); + const items = []; + while (queue.length && visited.size < 300 && items.length < limit) { + const current = queue.shift(); + if (!current || typeof current !== "object" || visited.has(current)) continue; + visited.add(current); + if (!Array.isArray(current) && dataProItemLooksLikeCompany(current)) items.push(current); + const children = Array.isArray(current) ? current : Object.values(current); + for (const child of children) { + if (child && typeof child === "object") queue.push(child); + } + } + return items; +} + +function companyItemFromDataProSummary(summary) { + const text = String(summary || ""); + const item = {}; + for (const aliases of Object.values(dataProCompanyFields)) { + for (const alias of aliases.filter((value) => /[\u4e00-\u9fff]/.test(value))) { + const match = text.match(new RegExp(`(?:^|[;;|])\\s*${alias}\\s*[::]\\s*([^;;|]+)`)); + if (match?.[1]) { + item[alias] = match[1].trim(); + break; + } + } + } + return dataProItemLooksLikeCompany(item) ? item : null; +} + +function compactCompanyLocation(item, address) { + const explicit = [ + dataProField(item, dataProCompanyFields.province, 40), + dataProField(item, dataProCompanyFields.city, 40), + dataProField(item, dataProCompanyFields.district, 40), + ].filter((value, index, values) => value && values.indexOf(value) === index).join(""); + if (explicit) return explicit.slice(0, 80); + const text = String(address || "").trim(); + const municipality = text.match(/^(北京市|上海市|天津市|重庆市)/)?.[1]; + if (municipality) return municipality; + const provinceAndCity = text.match(/^(.{2,10}?(?:省|自治区))(.{2,10}?市)/); + if (provinceAndCity) return `${provinceAndCity[1]}${provinceAndCity[2]}`.slice(0, 80); + return text.match(/^(.{2,10}?市)/)?.[1] || ""; +} + +function normalizedCompanyIdentity(value) { + return String(value || "").normalize("NFKC").toLowerCase().replace(/[\s·_.\-/()()]/g, ""); +} + +function parentheticalBrandAlias(value) { + const match = String(value || "").trim().match(/^([^()()]{2,16})\s*[((]\s*(?:中国|China)\s*[))]/iu); + return String(match?.[1] || "").trim(); +} + +const GENERIC_COMPANY_SEARCH_TERMS = new Set([ + "公司", + "企业", + "集团", + "车企", + "汽车", + "新能源", + "科技", + "制造业", + "供应商", +]); + +function companyIdentityAliases(company = {}) { + const canonicalName = normalizedCompanyIdentity(company.name); + const names = [ + company.name, + ...firstJsonArray(company.aliases), + parentheticalBrandAlias(company.name), + ].map(normalizedCompanyIdentity).filter(Boolean); + const safeNames = names.filter((name) => ( + name.length >= 4 + || ( + name.length >= 2 + && canonicalName.includes(name) + && !GENERIC_COMPANY_SEARCH_TERMS.has(name) + ) + )); + const derived = safeNames.flatMap((name) => { + const withoutLegalSuffix = name.replace(/(?:股份有限公司|有限责任公司|有限公司|股份公司|集团公司|集团)$/u, ""); + const withoutIndustrySuffix = withoutLegalSuffix.replace(/(?:新能源科技|汽车工业|汽车科技|信息技术|网络科技)$/u, ""); + return [name, withoutLegalSuffix, withoutIndustrySuffix]; + }); + return [...new Set(derived)] + .filter((item) => ( + item.length >= 4 + || ( + item.length >= 2 + && canonicalName.includes(item) + && !GENERIC_COMPANY_SEARCH_TERMS.has(item) + ) + )) + .sort((left, right) => right.length - left.length); +} + +function dossierTextMentionsCompany(value, company) { + const text = normalizedCompanyIdentity(value); + return companyIdentityAliases(company).some((alias) => text.includes(alias)); +} + +function dossierTextHasCompetingCompany(value, company) { + let text = normalizedCompanyIdentity(value); + for (const alias of companyIdentityAliases(company)) { + text = text.split(alias).join(""); + } + return /[\p{Script=Han}a-z0-9]{2,24}(?:有限责任公司|股份有限公司|有限公司|集团|股份|科技|汽车|新能源|能源|银行|证券|电建)/iu.test(text); +} + +function isPublicCitationRelevantToCompany(source, point, company) { + const label = String(source?.label || ""); + if (dossierTextMentionsCompany(point, company)) return true; + if (!dossierTextMentionsCompany(label, company)) return false; + return !dossierTextHasCompetingCompany(point, company); +} + +function isPublicRiskEvidenceForCompany(source, point, company) { + return Boolean( + point + && DOSSIER_SPECIFIC_RISK_TERMS.test(point) + && isPublicCitationRelevantToCompany(source, point, company) + && !dossierTextHasCompetingCompany(source?.label, company) + && !dossierTextHasCompetingCompany(point, company) + ); +} + +function companySearchAlias(query, companyName) { + const rawQuery = String(query || "").trim().slice(0, 80); + const normalizedQuery = normalizedCompanyIdentity(rawQuery); + const normalizedName = normalizedCompanyIdentity(companyName); + if ( + normalizedQuery.length < 2 + || normalizedQuery.length > 24 + || GENERIC_COMPANY_SEARCH_TERMS.has(normalizedQuery) + || !normalizedName.includes(normalizedQuery) + ) { + return ""; + } + return rawQuery; +} + +function preferredCompanySearchName(company) { + const aliases = [ + ...firstJsonArray(company?.aliases), + parentheticalBrandAlias(company?.name), + ] + .map((value) => companySearchAlias(value, company?.name)) + .filter(Boolean) + .sort((left, right) => normalizedCompanyIdentity(left).length - normalizedCompanyIdentity(right).length); + return aliases[0] || company?.name || ""; +} + +function stableProfessionalCompanyId(identity) { + return `company_dp_${createHash("sha256").update(identity).digest("hex").slice(0, 24)}`; +} + +function formatCitationText(paragraph) { + const ids = paragraph.citation_ids || []; + const marks = ids.map((id) => `[${id}]`).join(""); + return `${paragraph.text}${marks}`; +} + +function citationRank(citation) { + const text = `${citation?.source_kind || ""} ${citation?.label || ""}`; + if (/专业数据|专业数据库|工商|招投标/.test(text)) return 0; + if (/联网搜索|公开|新闻|公告|媒体|官网/.test(text)) return 1; + return 2; +} + +function isPlaceholderUrl(value) { + return /(^https?:\/\/)?(www\.)?example\.(com|test)\b/i.test(String(value || "")); +} + +function publicSourceUrl(value) { + const text = compactText(value, 500); + if (!text || isPlaceholderUrl(text)) return ""; + try { + const url = new URL(text); + return ["http:", "https:"].includes(url.protocol) ? url.toString() : ""; + } catch { + return ""; + } +} + +function publicSourceHostname(value) { + try { + return new URL(publicSourceUrl(value)).hostname.replace(/^www\./i, "").toLowerCase(); + } catch { + return ""; + } +} + +function publicCitationView(citation, id = citation?.id) { + const sourceKind = compactText(citation?.source_kind || "资料来源", 40); + const rawLabel = compactText(normalizeSalesText(citation?.label || ""), 160); + const sanitizedRawLabel = sourceKind === "联网搜索" + ? cleanPublicEvidenceLabel(rawLabel) + : rawLabel; + const label = /^(?:viking|openviking|datapro|model|fixture|demo-[^:]*):\/\//i.test(sanitizedRawLabel) + || /^(?:viking|openviking|datapro|model|fixture|demo-[^:]*):/i.test(rawLabel) + ? sourceKind + : sanitizedRawLabel || sourceKind; + const entityMatch = String(citation?.entity_match || ""); + const cleanedPublicSummary = sourceKind === "联网搜索" + ? cleanPublicEvidenceText(citation?.summary || citation?.excerpt || "", 2400) + : ""; + const summary = sourceKind === "联网搜索" + ? (cleanedPublicSummary || label) + : businessText(citation?.summary || citation?.excerpt || "", "", 2400); + return { + id: String(id || ""), + label, + source_kind: sourceKind, + url: publicSourceUrl(citation?.url), + summary, + site_name: sourceKind === "联网搜索" + ? compactText(citation?.site_name || "", 160) + : "", + published_at: citation?.published_at || null, + source_updated_at: citation?.source_updated_at || null, + source_quality_label: compactText(citation?.source_quality_label || "", 80), + freshness_label: compactText(citation?.freshness_label || "", 80), + verification_label: entityMatch === "alias_scoped" + ? "品牌或简称相关,需核验法定主体归属" + : /^(verified|query_bound|company_scoped)$/.test(entityMatch) + ? "企业主体已核验" + : "", + }; +} + +function firstJsonArray(value) { + return Array.isArray(value) ? value : []; +} + +function hasTechnicalErrorText(value) { + const text = String(value || ""); + return /APIKey|鉴权失败|Unauthorized|provider_error|fetch failed/.test(text) + || /"code"\s*:\s*(?:4\d{3}|5\d{3})/.test(text) + || /(?:错误码|error_code|code)\s*[::]\s*(?:4\d{3}|5\d{3})/i.test(text) + || /企业ID\s*[\((]\s*关联主键\s*[\))]\s*[::]/i.test(text) + || /(?:trace|request|record|relation)[ _-]?id\s*[::]/i.test(text); +} + +function businessText(value, fallback, maxLength = 900) { + const text = compactText(value, maxLength); + if (!text || hasTechnicalErrorText(text)) return fallback; + return text; +} + +function providerUnavailable(provider, message, details = {}) { + const error = new HttpError(503, `${provider}_unavailable`, message, { + provider, + ...details, + }); + error.retryable = Boolean(details.retryable); + if (details.category) error.category = details.category; + return error; +} + +function providerFailureDetails(failures = []) { + const lastFailure = failures[failures.length - 1] || {}; + return { + reason: lastFailure.code || "provider_error", + category: lastFailure.category || "upstream", + retryable: failures.some((failure) => ( + Boolean(failure?.retryable) + || Number(failure?.status || failure?.http_status || 0) >= 500 + )), + }; +} + +function hasBadDisplayText(value) { + const text = String(value || ""); + return hasTechnicalErrorText(text) || /�|\\u[0-9a-fA-F]{4}|undefined|null/.test(text); +} + +function cleanEvidenceSummary(value, fallback = "", maxLength = 420) { + const text = compactText(normalizeSalesText(value), maxLength); + if (!text || hasBadDisplayText(text)) return fallback; + return text; +} + +function dataProEvidenceSummaries(result) { + const itemSummaries = firstJsonArray(result?.item_summaries) + .map((item) => cleanEvidenceSummary(item, "", 1600)) + .filter(Boolean); + if (itemSummaries.length) return itemSummaries; + const summary = cleanEvidenceSummary(result?.summary, "", 2400); + return summary ? [summary] : []; +} + +function qaRetrievalQueries(company, question, conversationHistory = []) { + const plan = analyzeQaQuestion(question, conversationHistory); + const intentTerms = { + risk: "风险 合规 处罚 诉讼 顾虑", + timeline: "时间 节点 计划 进度", + people: "负责人 联系人 决策部门 对接人", + requirement: "需求 痛点 关注 场景 预算", + action: "下一步 建议 跟进 推进", + overview: "业务概览 当前情况", + fact: "", + }; + const expansion = plan.intents.map((intent) => intentTerms[intent] || "").filter(Boolean).join(" "); + return [...new Set([ + `${company.name} ${plan.resolved_question}`, + ...plan.subqueries.map((query) => `${company.name} ${query} ${expansion}`), + ].map((query) => compactText(query, 1800)).filter(Boolean))].slice(0, 3); +} + +function shortSourcePoint(source, maxLength = 120) { + const label = cleanEvidenceSummary(source?.label, "", 80); + const summary = cleanEvidenceSummary(source?.summary, "", maxLength); + const sentence = summary.split(/[。.!!??]/).find(Boolean) || summary; + return compactText(sentence || label || "来源返回可引用信息", maxLength); +} + +function cleanPublicEvidenceText(value, maxLength = 900) { + return cleanEvidenceSummary(value, "", maxLength) + .replace(/^雷递网\s+\S+\s+\d{1,2}月\d{1,2}日\s*/u, "") + .replace(/^[^。;]{0,28}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s*/u, "") + .replace(/\b20\d{2}年\d{1,2}月\d{1,2}日\s+\d{1,2}:\d{2}(?::\d{2})?\s*(?:(?:市场|行业|公司|商业)?资讯)?\s*(?:[((]来源[::][^))]{1,80}[))])?\s*/gu, "") + .replace(/(?:市场|行业|公司|商业)?资讯\s*[((]来源[::][^))]{1,80}[))]\s*/gu, "") + .replace(/[((]来源[::][^))]{1,80}[))]\s*/gu, "") + .replace(/查看更多(?:相关)?[\s\S]*$/u, "") + .replace(/(?:立即注册|免费查看|点击查看|登录后查看)[\s\S]*$/u, "") + .trim(); +} + +function cleanPublicEvidenceLabel(value) { + return cleanPublicEvidenceText(value, 260) + .replace(/_(?:新浪财经|新浪网|财经头条|雷递|百科|搜狐|腾讯新闻).*$/u, "") + .replace(/_[^_]{2,24}$/u, "") + .replace(/[\s_-]+(?:首页|Untitled)$/iu, "") + .trim(); +} + +function dossierEvidencePointScore(value, { fromSummary = false } = {}) { + const text = String(value || ""); + let score = fromSummary ? 2 : 0; + if (DOSSIER_ACTION_TERMS.test(text)) score += 6; + if (/(?:20\d{2}年|\d{1,2}月\d{1,2}日)/.test(text)) score += 2; + if (text.length >= 28 && text.length <= 220) score += 2; + if (/[。!?!?]$/.test(text)) score += 1; + if (/如何|为什么|为何|怎样|是否|吗[??]?$|[??]$/.test(text)) score -= 8; + if (/^(?:公司简介|企业信息|招标信息|最新消息|新闻资讯)$/.test(text)) score -= 10; + return score; +} + +function isSubstantiveDossierEvidencePoint(value) { + const text = compactText(value, 500); + return text.length >= 18 + && !hasBadDisplayText(text) + && !hasDossierInternalMetaText(text) + && !/(?:^|[-—::])(?:招标信息|公司简介|企业信息|最新消息|新闻资讯)$/.test(text) + && dossierPointQualityErrors(text).length === 0; +} + +function concisePublicPoint(source, maxLength = 220) { + const label = cleanPublicEvidenceLabel(source?.label); + const rawSummary = cleanPublicEvidenceText(source?.summary, 1200); + const comparableLabel = normalizeChineseDossierPunctuation(label).replace(/[。!?;\s]+$/u, ""); + const summary = comparableLabel && normalizeChineseDossierPunctuation(rawSummary).startsWith(comparableLabel) + ? normalizeChineseDossierPunctuation(rawSummary) + .slice(comparableLabel.length) + .replace(/^[\s,。;:!?!?:、-]+/u, "") + .trim() + : rawSummary; + const summarySegments = summary + .split(/(?<=[。!?!?])\s*/u) + .map((item) => item.trim()) + .filter(Boolean); + const candidates = [ + ...summarySegments.map((text) => ({ text, fromSummary: true })), + { text: label, fromSummary: false }, + ] + .filter((item) => isSubstantiveDossierEvidencePoint(item.text)) + .filter((item) => item.text.length <= maxLength) + .sort((a, b) => ( + dossierEvidencePointScore(b.text, b) + - dossierEvidencePointScore(a.text, a) + )); + return String(candidates[0]?.text || "").replace(/[。;\s]+$/u, ""); +} + +function publicDossierSourceText(source, point = "") { + return [ + source?.label, + source?.site_name, + source?.auth_description, + source?.summary, + point, + source?.url, + ].map((value) => String(value || "")).join(" "); +} + +function isLowValuePublicDossierSource(source, point = "") { + const text = publicDossierSourceText(source, point); + return DOSSIER_LOW_VALUE_PUBLIC_SOURCE_PATTERNS.some((pattern) => pattern.test(text)); +} + +function isDisplayableDossierCitation(citation, company) { + if (!/专业数据集|联网搜索/.test(String(citation?.source_kind || ""))) return false; + if (hasDossierEvidenceDebris(citation?.label || "")) return false; + if (!businessText(citation?.summary || citation?.excerpt, "", 600)) return false; + if (citation.source_kind !== "联网搜索") { + const point = safeDeterministicDossierPoint(conciseProfessionalPoint(citation)); + return Boolean( + point + && !isLowValueProfessionalPoint(point) + && isSubstantiveDossierEvidencePoint(point) + ); + } + const point = concisePublicPoint(citation); + if (!point || isLowValuePublicDossierSource(citation, point)) return false; + if (!isPublicCitationRelevantToCompany(citation, point, company)) return false; + const hasSpecificRisk = DOSSIER_SPECIFIC_RISK_TERMS.test(`${citation.label || ""} ${point}`); + return !hasSpecificRisk || isPublicRiskEvidenceForCompany(citation, point, company); +} + +function dossierCitationAnchorsLegalEntity(citation, company = {}) { + if (citation?.source_kind !== "专业数据集") return false; + const sourceText = normalizedCompanyIdentity(`${citation.label || ""} ${citation.summary || citation.excerpt || ""}`); + const canonicalName = normalizedCompanyIdentity(company.name); + const creditCode = normalizedCompanyIdentity( + company.unified_social_credit_code || company.credit_code || "", + ); + return Boolean( + (canonicalName && sourceText.includes(canonicalName)) + || (creditCode && sourceText.includes(creditCode)) + ); +} + +function dossierGroundingErrors(citations = [], body = null, company = {}) { + const citedIds = Array.isArray(body) + ? new Set(body.flatMap((paragraph) => firstJsonArray(paragraph?.citation_ids).map(String))) + : null; + const used = citations.filter((citation) => !citedIds || citedIds.has(String(citation.id))); + const errors = []; + if (!used.length) errors.push("档案没有引用可展示的外部来源"); + if (!used.some((citation) => dossierCitationAnchorsLegalEntity(citation, company))) { + errors.push("档案没有实际引用能够确认目标法定主体的专业来源"); + } + return errors; +} + +function isGenericCompanyLandingPage(source, point, company) { + const label = normalizedCompanyIdentity(cleanPublicEvidenceLabel(source?.label)); + if (!label) return false; + const genericLabel = companyIdentityAliases(company).some((alias) => { + const remainder = label + .split(alias).join("") + .replace(/(?:官方网站|官网|首页|officialsite|official|website)/giu, "") + .replace(/[a-z]{2,12}\d{0,6}/giu, "") + .replace(/\d{2,8}/gu, ""); + return remainder.length === 0; + }); + if (!genericLabel) return false; + let rootPage = false; + try { + const url = new URL(String(source?.url || "")); + rootPage = /^\/(?:index\.(?:html?|shtml))?$/iu.test(url.pathname || "/"); + } catch { + rootPage = false; + } + return rootPage || !DOSSIER_ACTION_TERMS.test(`${point || ""} ${source?.label || ""}`); +} + +function isRecentPublicDossierCitation(source, point, company) { + const text = `${point || ""} ${source?.label || ""}`; + return Boolean( + point + && isSubstantiveDossierEvidencePoint(point) + && !isLowValuePublicDossierSource(source, point) + && !isGenericCompanyLandingPage(source, point, company) + && DOSSIER_ACTION_TERMS.test(text) + && isPublicCitationRelevantToCompany(source, point, company) + ); +} + +export function assessDossierEvidenceCoverage(company, collected = {}) { + const professional = firstJsonArray(collected.professional) + .map((source) => ({ ...source, source_kind: "专业数据集" })) + .filter((source) => isDisplayableDossierCitation(source, company)); + const publicSources = firstJsonArray(collected.public_sources) + .map((source) => ({ ...source, source_kind: "联网搜索" })) + .filter((source) => isDisplayableDossierCitation(source, company)); + const recentPublic = publicSources.filter((source) => ( + isRecentPublicDossierCitation(source, concisePublicPoint(source), company) + )); + const riskSources = [ + ...professional.filter((source) => ( + /企业风险数据库/.test(String(source.label || "")) + && !dossierBusinessEntityRecord(source) + )), + ...publicSources.filter((source) => ( + isPublicRiskEvidenceForCompany(source, concisePublicPoint(source), company) + )), + ]; + const operationsSources = [ + ...professional.filter((source) => ( + /金融数据库|汽车销量数据库|科研学术数据搜索服务/.test(String(source.label || "")) + && !dossierBusinessEntityRecord(source) + )), + ...recentPublic.filter((source) => ( + DOSSIER_ACTION_TERMS.test(`${source.label || ""} ${concisePublicPoint(source)}`) + && !DOSSIER_SPECIFIC_RISK_TERMS.test(`${source.label || ""} ${concisePublicPoint(source)}`) + )), + ]; + const procurementSources = recentPublic.filter((source) => ( + /招标|采购|中标|供应商|框架协议|项目/.test(`${source.label || ""} ${concisePublicPoint(source)}`) + )); + const publicHosts = new Set( + publicSources.map((source) => publicSourceHostname(source.url)).filter(Boolean), + ); + const coverage = { + legal_entity: professional.some((source) => ( + /企业工商数据库/.test(String(source.label || "")) + || dossierTextMentionsCompany(`${source.label || ""} ${source.summary || ""}`, company) + )), + operations: operationsSources.length > 0, + recent_public: recentPublic.length > 0, + risk: riskSources.length > 0, + procurement_or_project: procurementSources.length > 0, + public_host_count: publicHosts.size, + usable_professional_count: professional.length, + usable_public_count: publicSources.length, + }; + coverage.missing_topics = [ + ...(!coverage.recent_public ? ["recent_public"] : []), + ...(!coverage.operations ? ["operations"] : []), + ...(!coverage.risk ? ["risk"] : []), + ...(!coverage.procurement_or_project ? ["procurement_or_project"] : []), + ...(coverage.public_host_count < Math.min(3, coverage.usable_public_count + 1) + ? ["source_diversity"] + : []), + ]; + return coverage; +} + +function publicDossierEvidenceScore(source, point, company) { + if (isLowValuePublicDossierSource(source, point)) return -100; + let score = dossierEvidencePointScore(point, { fromSummary: true }); + if (source?.published_at) score += 3; + const authLevel = Number(source?.auth_level); + if (Number.isFinite(authLevel) && authLevel > 0) score += Math.min(authLevel, 4); + const sourceText = publicDossierSourceText(source, point); + if (/(?:gov\.cn|cninfo\.com\.cn|sse\.com\.cn|szse\.cn)\b/iu.test(sourceText)) score += 5; + if (/官方公告|投资者关系|证券交易所|政府网站|监管机构|官网新闻/iu.test(sourceText)) score += 3; + if (isRecentPublicDossierCitation(source, point, company)) score += 4; + return score; +} + +function sourcePointList(sources, limit = 3) { + return sources + .slice(0, limit) + .map((source) => shortSourcePoint(source)) + .filter(Boolean); +} + +function isWeakCompanySituationText(value) { + const text = String(value || ""); + return /专业数据库(?:返回|显示|依据|来源).*专业数据库/.test(text) + || /专业数据集(?:返回|显示|依据|来源).*专业数据库/.test(text) + || /只.*返回.*数据库/.test(text); +} + +function isLowValueProfessionalPoint(value) { + return /^企业ID\s*[\((]\s*关联主键\s*[\))]/.test(String(value || "").trim()); +} + +function isOverlongLatestText(value) { + const text = String(value || ""); + return text.length > 420 || (/来源:|发布时间:|NYSE|HK/.test(text) && /联网搜索|公开来源/.test(text)); +} + +function extractSourceField(summary, key) { + const match = String(summary || "").match(new RegExp(`${key}\\s*[::]\\s*([^;;|。]+)`)); + return match ? match[1].trim() : ""; +} + +function conciseProfessionalPoint(source, targetCompanyName = "") { + const summary = String(source?.summary || ""); + if (/公司名称|统一社会信用代码|法人姓名|法定代表人/.test(summary)) { + const leadingCompanyName = summary.match(/^([^;;|。]{4,80})[;;]/)?.[1]?.trim() || ""; + const companyName = extractSourceField(summary, "公司名称") + || extractSourceField(summary, "企业名称") + || ( + targetCompanyName + && normalizedCompanyIdentity(leadingCompanyName) === normalizedCompanyIdentity(targetCompanyName) + ? leadingCompanyName + : "" + ); + if ( + targetCompanyName + && companyName + && normalizedCompanyIdentity(companyName) !== normalizedCompanyIdentity(targetCompanyName) + ) { + return ""; + } + const creditCode = extractSourceField(summary, "统一社会信用代码"); + const legalPerson = extractSourceField(summary, "法人姓名") || extractSourceField(summary, "法定代表人"); + const address = extractSourceField(summary, "注册地址"); + const startedAt = extractSourceField(summary, "成立日期").slice(0, 10); + const businessScope = extractSourceField(summary, "经营范围"); + return [ + companyName ? `公司名称:${companyName}` : "", + creditCode ? `统一社会信用代码:${creditCode}` : "", + legalPerson ? `法定代表人:${legalPerson}` : "", + address ? `注册地址:${address}` : "", + startedAt ? `成立日期:${startedAt}` : "", + businessScope ? `经营范围:${businessScope}` : "", + ].filter(Boolean).join(";"); + } + return shortSourcePoint(source, 120); +} + +function safeDeterministicDossierPoint(value) { + const text = String(value || "").trim(); + if (!text) return ""; + const unsafeNumericClaim = /(?:注册资本|营业收入|营收|净利润|利润|融资|估值|回购|市值|市占率)[^。;\n]{0,48}\d/; + const unsafeRiskClaim = /(?:(?:行政处罚|司法诉讼|失信被执行|限制高消费|经营异常|重大风险).{0,24}(?:存在|涉及|新增|发生|受到|列入|被执行|\d))|(?:(?:存在|涉及|新增|发生|受到|列入|被执行|\d).{0,24}(?:行政处罚|司法诉讼|失信被执行|限制高消费|经营异常|重大风险))/; + return text + .split(/(?<=[。!?])\s*|(?<=;)\s*/u) + .map((item) => item.trim()) + .filter((item) => item && !unsafeNumericClaim.test(item) && !unsafeRiskClaim.test(item)) + .join("") + .replace(/[。;\s]+$/u, ""); +} + +function dossierSalesThemes(values = [], company = {}) { + const text = values.filter(Boolean).join(" "); + const candidates = [ + [/知识库|知识管理|内容检索|智能问答/, "知识库与智能问答"], + [/数据安全|隐私|合规|私有化|权限/, "数据安全与合规部署"], + [/人工智能|大模型|智能化|算法/, "AI 应用与智能化升级"], + [/储能|电池|电芯|锂电|光伏/, "储能与电池供应链"], + [/汽车|车企|座舱|车主服务|新能源车/, "汽车智能化与车主服务"], + [/供应链|采购|招标|中标|供应商/, "供应链与采购协同"], + [/软件|系统|平台|SaaS/, "企业软件与系统集成"], + [/产线|制造|工厂|设备|量产/, "生产制造与设备交付"], + ] + .filter(([pattern]) => pattern.test(text)) + .map(([, label]) => label); + const industry = compactText(company?.industry || "", 40); + if (industry && !candidates.includes(industry)) candidates.push(industry); + return [...new Set(candidates)].slice(0, 3).length + ? [...new Set(candidates)].slice(0, 3) + : ["主营业务相关产品与服务"]; +} + +function dossierDisplayText(dossier) { + return [ + dossier?.title, + dossier?.summary, + dossier?.memory_summary, + ...firstJsonArray(dossier?.body).map((paragraph) => paragraph?.text), + ].filter(Boolean).join(" "); +} + +function isDisplayableDossier(dossier) { + const text = dossierDisplayText(dossier); + if (!compactText(dossier?.summary || firstJsonArray(dossier?.body)[0]?.text || dossier?.memory_summary, 80)) return false; + if (hasTechnicalErrorText(text) || /这份档案需要重新获取|provider_error|fetch failed|鉴权失败|Unauthorized/.test(text)) { + return false; + } + const body = firstJsonArray(dossier?.body); + if (body.length !== DOSSIER_SECTION_TITLES.length) return false; + const citationIds = new Set(firstJsonArray(dossier?.citations).map((item) => String(item?.id || ""))); + if (!citationIds.size) return false; + if (dossierSectionContentErrors(body).length) return false; + if (body.some((paragraph) => ( + !firstJsonArray(paragraph?.citation_ids).some((id) => citationIds.has(String(id))) + ))) { + return false; + } + return true; +} + +function cleanMaterialText(value) { + return normalizeImportedText(value) + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim() + .slice(0, 12000); +} + +function isFeishuMaterial(material) { + const identity = [ + material?.source_type, + material?.title, + material?.source_url, + ].filter(Boolean).join(" "); + return /(?:^|\b)feishu[_-]|lark|飞书|云文档|会议纪要|会话/i.test(identity); +} + +function feishuMaterialSourceKind(material) { + const sourceType = String(material?.source_type || "").toLowerCase(); + if (/feishu_(?:chat|p2p|search)|会话|群聊|单聊|消息/.test(sourceType)) return "飞书会话"; + if (/feishu_doc|云文档|会议纪要|文档/.test(sourceType)) return "云文档"; + return "飞书资料"; +} + +function canonicalOpenVikingResourceUri(value) { + return String(value || "") + .trim() + .replace(/[?#].*$/, "") + .replace(/\/+$/, "") + .replace(/\.(?:md|markdown|txt)$/i, "") + .toLowerCase(); +} + +function isOpenVikingOverviewItem(item) { + const uri = String(item?.uri || "").replace(/[?#].*$/, "").replace(/\/+$/, ""); + const leaf = uri.split("/").pop() || ""; + const title = compactText(item?.title || item?.name || "", 80); + return /^overview(?:\.(?:md|markdown|txt))?$/i.test(leaf) + || /^overview$/i.test(title); +} + +function sanitizeQaDisplayText(value) { + const normalized = normalizeSalesText(value); + const containsInternalImplementation = /(?:viking|openviking):\/\//i.test(normalized) + || /\/materials(?:\/|\b)/i.test(normalized) + || /\b(?:company|mat|sync)_[a-z0-9_-]{8,}\b/i.test(normalized) + || /(?:内部|资源)?(?:目录|路径).{0,80}\bmaterials\b/i.test(normalized); + if (containsInternalImplementation) { + return "该历史回答包含内部实现信息,已隐藏;请重新提问以获取仅基于业务资料的回答。"; + } + return normalized; +} + +function qaDisplayCitationIdentity(citation, index = 0) { + const materialId = compactText(citation?.material_id || "", 240); + if (materialId) return `material:${materialId}`; + const sourceKind = compactText(citation?.source_kind || "资料来源", 80); + const label = compactText(citation?.label || "", 240); + if (sourceKind === "企业档案") { + return `dossier-section:${label || index}`; + } + const uri = canonicalOpenVikingResourceUri(citation?.uri || ""); + if (uri) return `uri:${uri}`; + const url = publicSourceUrl(citation?.url); + if (url) return `url:${url}`; + return `source:${sourceKind}:${label || index}`; +} + +function mergeQaDisplayCitations(message) { + const groups = []; + const groupByIdentity = new Map(); + const citationIdMap = new Map(); + firstJsonArray(message?.citations).forEach((citation, index) => { + const originalId = String(citation?.id || index + 1); + const identity = qaDisplayCitationIdentity(citation, index); + let group = groupByIdentity.get(identity); + if (!group) { + group = { + citation: { + ...citation, + id: String(groups.length + 1), + }, + original_ids: [], + }; + groups.push(group); + groupByIdentity.set(identity, group); + } + group.original_ids.push(originalId); + citationIdMap.set(originalId, String(group.citation.id)); + }); + const remapIds = (ids) => [...new Set( + firstJsonArray(ids) + .map((id) => citationIdMap.get(String(id))) + .filter(Boolean), + )]; + return { + citations: groups.map((group) => group.citation), + citation_ids: remapIds(message?.citation_ids), + paragraphs: firstJsonArray(message?.paragraphs).map((paragraph) => ({ + ...paragraph, + citation_ids: remapIds(paragraph?.citation_ids), + })), + }; +} + +function hasLegacyGenericQaCitations(message) { + return firstJsonArray(message?.citations).some((citation) => { + const sourceKind = compactText(citation?.source_kind || "", 80); + const label = compactText(citation?.label || "", 240); + return sourceKind === "内部资料" || label === "内部资料"; + }); +} + +function progressLevel(label) { + const text = String(label || ""); + if (/签约|成交|已确认|方案|推进/.test(text)) return 78; + if (/需求确认/.test(text)) return 58; + if (/初步|接触/.test(text)) return 34; + if (/暂无|不足/.test(text)) return 12; + if (/新商机/.test(text)) return 22; + return 42; +} + +function normalizedSalesStatus(label) { + const text = String(label || ""); + if (/签约|成交|归档|已成交/.test(text)) return "成交归档"; + if (/方案|报价|商务|推进/.test(text)) return "商务推进"; + if (/需求确认|需求/.test(text)) return "需求确认中"; + if (/初步|接触/.test(text)) return "初步接触"; + if (/暂无|不足/.test(text)) return "暂无有效信号"; + return "新商机"; +} + +function conciseProgressSummary(label, summary = "") { + const status = normalizedSalesStatus(label); + const text = compactText(summary, 220); + if (text && text.length <= 28 && !/最近档案|企业情况|近期动态|销售判断|下一步建议|专业数据库|联网搜索|但|需要/.test(text)) { + return text; + } + const fallback = { + 新商机: "已加入目标企业池,当前无历史资料,待生成最新档案。", + 初步接触: "已完成基础信息了解,尚未形成明确采购计划。", + 需求确认中: "已识别数据安全与私有化部署需求,待确认预算和排期。", + 商务推进: "已进入方案沟通阶段,待确认商务条件和决策流程。", + 成交归档: "已完成合作归档,后续关注续约和扩展机会。", + 暂无有效信号: "当前资料不足,需先补充有效企业信息。", + }; + return fallback[status] || "当前进度待补充。"; +} + +function normalizeSalesText(value) { + return String(value || ""); +} + +export class SalesService { + constructor(options = {}) { + this.env = options.env || createEnvReader(); + this.runtimePolicy = options.runtimePolicy || createRuntimePolicy({ env: this.env }); + const initialData = options.seed !== undefined ? options.seed : emptySalesData(); + this.data = clone(initialData); + this.data.jobs = this.data.jobs || {}; + this.dataProProvider = options.dataProProvider || null; + this.webSearchProvider = options.webSearchProvider || null; + this.modelProvider = options.modelProvider || null; + this.openVikingProvider = options.openVikingProvider || null; + this.repository = options.repository || null; + this.workspaceId = String(this.env.value("APP_WORKSPACE_ID", "local-workspace") || "local-workspace").trim(); + this.qaAutoCommitEvery = Math.max(0, Math.min( + 20, + Number(this.env.value("OPENVIKING_QA_AUTO_COMMIT_EVERY", "4")) || 0, + )); + this.qaKeepRecentMessages = Math.max(0, Math.min( + 40, + Number(this.env.value("OPENVIKING_QA_KEEP_RECENT_MESSAGES", "6")) || 0, + )); + this.asyncJobsEnabled = enabled(this.env.value( + "ASYNC_JOBS_ENABLED", + "true", + )); + this.dossierCheckpointTtlMs = Math.max( + 5 * 60_000, + Math.min( + 2 * 60 * 60_000, + Number(this.env.value("DOSSIER_CHECKPOINT_TTL_MS", "1800000")) || 1_800_000, + ), + ); + this.dossierDataProConcurrency = Math.max( + 1, + Math.min(3, Number(this.env.value("DOSSIER_DATAPRO_CONCURRENCY", "2")) || 2), + ); + this.dossierWebConcurrency = Math.max( + 1, + Math.min(4, Number(this.env.value("DOSSIER_WEB_CONCURRENCY", "3")) || 3), + ); + this.providerRuns = options.providerRunStore || new ProviderRunStore({ + repository: this.repository, + failOnPersistenceError: this.runtimePolicy.fail_closed, + circuitBreaker: options.providerCircuitBreaker || new ProviderCircuitBreaker({ + enabled: enabled(this.env.value( + "PROVIDER_CIRCUIT_BREAKER_ENABLED", + "true", + )), + failureThreshold: Number(this.env.value("PROVIDER_CIRCUIT_BREAKER_FAILURE_THRESHOLD", "5")), + cooldownSeconds: Number(this.env.value("PROVIDER_CIRCUIT_BREAKER_COOLDOWN_SECONDS", "60")), + }), + }); + this.paidWorkflowGuard = options.paidWorkflowGuard || new PaidWorkflowGuard({ + env: this.env, + repository: this.repository, + failClosed: this.runtimePolicy.fail_closed, + listLocalJobs: () => Object.values(this.data.jobs || {}), + }); + this.persistence = { enabled: false, last_error: null }; + this.lastPersistedRefreshAt = 0; + this.persistedRefreshPromise = null; + this.initialization = this.loadPersistedState(); + } + + async loadPersistedState() { + if (typeof this.repository?.getSalesState !== "function") return; + try { + const state = await this.repository.getSalesState(this.data); + if (state && Array.isArray(state.goals)) { + this.data = { + goals: state.goals, + companies: state.companies || {}, + dossiers: state.dossiers || {}, + materials: state.materials || {}, + qa_messages: state.qa_messages || {}, + sync_sources: state.sync_sources || {}, + sync_checkpoints: state.sync_checkpoints || {}, + jobs: state.jobs || {}, + }; + } + this.persistence = { enabled: true, last_error: null }; + this.lastPersistedRefreshAt = Date.now(); + } catch (error) { + this.persistence = { enabled: false, last_error: error.message }; + } + } + + async refreshPersistedState(options = {}) { + await this.initialization; + if (typeof this.repository?.getSalesState !== "function") return false; + const minIntervalMs = Math.max(0, Number(options.minIntervalMs ?? 250) || 0); + if (!options.force && Date.now() - this.lastPersistedRefreshAt < minIntervalMs) return false; + if (this.persistedRefreshPromise) return this.persistedRefreshPromise; + + const refresh = this.loadPersistedState().then(() => { + if (this.runtimePolicy.fail_closed && !this.persistence.enabled) { + throw providerUnavailable("supabase", "Persistent storage refresh failed.", { + reason: this.persistence.last_error || "repository_refresh_failed", + }); + } + return true; + }); + this.persistedRefreshPromise = refresh.finally(() => { + this.persistedRefreshPromise = null; + }); + return this.persistedRefreshPromise; + } + + async assertRuntimeReady() { + await this.initialization; + if (this.runtimePolicy.fail_closed && !this.persistence.enabled) { + throw providerUnavailable("supabase", "Persistent storage is unavailable.", { + reason: this.persistence.last_error || "repository_not_ready", + }); + } + } + + async persist(operation) { + await this.initialization; + if (!this.persistence.enabled || !this.repository) return null; + try { + const result = await operation(); + this.persistence.last_error = null; + return result; + } catch (error) { + this.persistence.last_error = error.message; + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("supabase", "Persistent storage write failed.", { + reason: error.message || "repository_write_failed", + }); + } + return null; + } + } + + async listProviderRuns(filters = {}) { + return (await this.providerRuns.list(filters)).map((run) => this.publicProviderRun(run)); + } + + async getProviderRun(runId) { + const run = await this.providerRuns.get(runId); + if (!run) { + throw new HttpError(404, "provider_run_not_found", "Provider 运行记录不存在。", { + provider_run_id: runId, + }); + } + return this.publicProviderRun(run); + } + + publicProviderRun(run) { + return { + id: run.id, + operation: run.operation, + status: run.status, + entity_type: run.entity_type || "", + entity_id: run.entity_id || "", + job_id: run.job_id || null, + started_at: run.started_at || null, + finished_at: run.finished_at || null, + duration_ms: run.duration_ms ?? null, + error: run.error ? clone(run.error) : null, + steps: firstJsonArray(run.steps).map((step) => ({ + id: step.id, + sequence: step.sequence, + provider: step.provider, + operation: step.operation, + status: step.status, + input_summary: step.input_summary || "", + output_summary: step.output_summary || "", + usage: step.usage ? clone(step.usage) : null, + attempts: step.attempts, + started_at: step.started_at || null, + finished_at: step.finished_at || null, + latency_ms: step.latency_ms ?? null, + error: step.error ? clone(step.error) : null, + })), + }; + } + + async requireJob(jobId, options = {}) { + await this.assertRuntimeReady(); + let job = this.data.jobs?.[jobId] || null; + if ((options.refresh || !job) && typeof this.repository?.getJob === "function" && this.persistence.enabled) { + const persisted = await this.repository.getJob(jobId); + if (persisted) { + job = persisted; + this.data.jobs[job.id] = job; + } + } + if (!job) throw new HttpError(404, "job_not_found", "任务记录不存在。", { job_id: jobId }); + return job; + } + + async startJob(input = {}) { + if (input.retry_job_id) { + const existing = await this.requireJob(input.retry_job_id, { refresh: true }); + if (!["failed", "cancelled"].includes(existing.status)) { + throw new HttpError(409, "job_not_retryable", "只有失败或已取消的任务可以重试。", { + job_id: existing.id, + status: existing.status, + }); + } + if (Number(existing.attempt_count || 0) >= Number(existing.max_attempts || 1)) { + throw new HttpError(409, "job_attempts_exhausted", "任务已达到最大执行次数。", { + job_id: existing.id, + attempt_count: Number(existing.attempt_count || 0), + max_attempts: Number(existing.max_attempts || 1), + }); + } + if (input.job_type && input.job_type !== existing.job_type) { + throw new HttpError(409, "job_type_mismatch", "重试任务类型与原任务不一致。", { job_id: existing.id }); + } + existing.status = "running"; + existing.attempt_count = Number(existing.attempt_count || 0) + 1; + existing.started_at = nowIso(); + existing.finished_at = null; + existing.error = null; + existing.result_ref = null; + existing.result = null; + existing.cancel_requested_at = null; + existing.updated_at = existing.started_at; + existing.is_paid = input.is_paid !== false; + return this.reserveJob(existing); + } + + const createdAt = nowIso(); + const job = { + id: makeId("job"), + job_type: String(input.job_type || "workflow"), + status: "running", + entity_type: String(input.entity_type || ""), + entity_id: String(input.entity_id || ""), + idempotency_key: input.idempotency_key || null, + request: clone(input.request || {}), + attempt_count: 1, + max_attempts: Math.max(1, Number(input.max_attempts || 1)), + scheduled_at: createdAt, + started_at: createdAt, + finished_at: null, + error: null, + result_ref: null, + result: null, + is_paid: input.is_paid !== false, + created_at: createdAt, + updated_at: createdAt, + }; + return this.reserveJob(job); + } + + publicJob(job) { + if (!job) return null; + const status = String(job.status || "queued"); + const stage = String(job.stage || status || "queued"); + const retryable = ["failed", "cancelled"].includes(status) + && Number(job.attempt_count || 0) < Number(job.max_attempts || 1); + const safeResult = job.result && typeof job.result === "object" + ? Object.fromEntries(Object.entries(job.result).filter(([key]) => [ + "action", + "dossier_id", + "version_no", + "status", + "material_count", + "failed_count", + ].includes(key))) + : null; + return { + id: job.id, + job_type: job.job_type, + status, + stage, + stage_label: JOB_STAGE_LABELS[stage] || JOB_STAGE_LABELS[status] || "正在处理", + stage_detail: safeJobProgressDetail(job.progress_detail), + progress: Math.max(0, Math.min(Number(job.progress ?? (status === "succeeded" ? 100 : 0)), 100)), + entity_type: job.entity_type || "", + entity_id: job.entity_id || "", + attempt_count: Number(job.attempt_count || 0), + max_attempts: Number(job.max_attempts || 1), + retryable, + error: job.error ? { + code: String(job.error.code || "workflow_failed"), + message: status === "failed" ? "任务执行失败,请重试或联系管理员。" : "", + } : null, + result: safeResult && Object.keys(safeResult).length ? safeResult : null, + scheduled_at: job.scheduled_at || null, + started_at: job.started_at || null, + finished_at: job.finished_at || null, + created_at: job.created_at || null, + updated_at: job.updated_at || null, + }; + } + + async enqueueJob(input = {}) { + await this.assertRuntimeReady(); + const createdAt = nowIso(); + const job = { + id: makeId("job"), + job_type: String(input.job_type || "workflow"), + status: "queued", + stage: "queued", + progress: 0, + checkpoint: {}, + progress_detail: {}, + entity_type: String(input.entity_type || ""), + entity_id: String(input.entity_id || ""), + idempotency_key: input.idempotency_key || null, + request: clone(input.request || {}), + attempt_count: 0, + max_attempts: Math.max(1, Number(input.max_attempts || 3)), + scheduled_at: input.scheduled_at || createdAt, + started_at: null, + finished_at: null, + error: null, + result_ref: null, + result: null, + is_paid: input.is_paid !== false, + created_by: input.created_by || null, + created_at: createdAt, + updated_at: createdAt, + }; + + let queued = job; + if (typeof this.repository?.enqueueJob === "function" && this.persistence.enabled) { + queued = await this.persist(() => this.repository.enqueueJob(job)); + if (!queued) { + throw new HttpError(503, "job_queue_unavailable", "后台任务队列暂不可用,任务未执行。"); + } + } else { + if (this.runtimePolicy.fail_closed) { + throw new HttpError(503, "job_queue_unavailable", "后台任务队列暂不可用,任务未执行。"); + } + this.data.jobs[job.id] = job; + await this.persist(() => this.repository.persistJob(job)); + } + this.data.jobs[queued.id] = queued; + return clone(queued); + } + + async enqueueDossier(companyId, body = {}, options = {}) { + const company = this.requireCompany(companyId); + return this.publicJob(await this.enqueueJob({ + job_type: "sales_dossier_generation", + entity_type: "target_enterprise", + entity_id: company.id, + max_attempts: 3, + idempotency_key: body.idempotency_key || null, + request: { ...body, idempotency_key: undefined }, + created_by: options.created_by || null, + })); + } + + async enqueueMaterialsToOpenViking(companyId, options = {}) { + const company = this.requireCompany(companyId); + const materials = (company.material_ids || []).map((id) => this.data.materials[id]).filter(Boolean); + if (!materials.length) { + return { + status: "skipped", + summary: "当前企业还没有可同步的历史资料。", + records: [], + }; + } + return this.publicJob(await this.enqueueJob({ + job_type: "sales_material_openviking_sync", + entity_type: "target_enterprise", + entity_id: company.id, + max_attempts: 3, + idempotency_key: options.idempotency_key || null, + request: { material_count: materials.length }, + created_by: options.created_by || null, + })); + } + + async activateClaimedJob(input, expectedType) { + const job = clone(input || {}); + if (!job.id || job.status !== "running" || job.job_type !== expectedType || !job.worker_id) { + throw new HttpError(409, "job_claim_invalid", "后台任务领取状态无效,未执行外部调用。"); + } + const reserved = await this.reserveJob(job); + return this.assertJobActive(reserved.id); + } + + async executeQueuedJob(input, options = {}) { + await this.loadPersistedState(); + await this.assertRuntimeReady(); + const job = await this.requireJob(input.id, { refresh: true }); + if (job.status !== "running" || job.worker_id !== options.worker_id) { + throw new HttpError(409, "job_claim_lost", "后台任务领取权已失效。"); + } + await this.assertJobActive(job.id); + const workflowOptions = { + claimed_job: job, + report_progress: options.report_progress, + save_checkpoint: options.save_checkpoint, + }; + if (job.job_type === "sales_dossier_generation") { + return this.createDossier(job.entity_id, job.request || {}, workflowOptions); + } + if (job.job_type === "sales_material_openviking_sync") { + return this.syncMaterialsToOpenViking(job.entity_id, workflowOptions); + } + throw new HttpError(422, "job_type_unsupported", "后台任务类型暂不支持执行。", { + job_type: job.job_type, + }); + } + + async reserveJob(job) { + const reservation = await this.paidWorkflowGuard.reserve(job); + const reservedJob = { + ...reservation.job, + usage_budget: reservation.budget || null, + }; + this.data.jobs[reservedJob.id] = reservedJob; + if (typeof this.repository?.reservePaidWorkflow !== "function") { + await this.persist(() => this.repository.persistJob(reservedJob)); + } + return clone(reservedJob); + } + + async persistTerminalJob(job) { + if (job.is_paid && job.reservation_id) { + await this.paidWorkflowGuard.finish(job); + if (typeof this.repository?.finishPaidWorkflow === "function") return clone(job); + } + await this.persist(() => this.repository.persistJob(job)); + return clone(job); + } + + async completeJob(jobId, input = {}) { + const job = await this.requireJob(jobId, { refresh: true }); + if (["cancelled", "failed"].includes(job.status)) return clone(job); + if (job.cancel_requested_at && this.asyncJobsEnabled && ASYNC_JOB_TYPES.has(job.job_type)) { + await this.acknowledgeJobCancellation(job); + throw new HttpError(409, "job_cancelled", "任务已取消,结果不会继续提交。", { + job_id: job.id, + }); + } + job.status = "succeeded"; + job.finished_at = nowIso(); + job.updated_at = job.finished_at; + job.result_ref = input.result_ref || null; + job.result = input.result || null; + return this.persistTerminalJob(job); + } + + async failJob(jobId, error = {}) { + const job = await this.requireJob(jobId, { refresh: true }); + if (["cancelled", "failed"].includes(job.status)) return clone(job); + if (job.cancel_requested_at && this.asyncJobsEnabled && ASYNC_JOB_TYPES.has(job.job_type)) { + return this.acknowledgeJobCancellation(job); + } + job.status = "failed"; + job.finished_at = nowIso(); + job.updated_at = job.finished_at; + job.error = { + code: String(error.code || "workflow_failed"), + message: String(error.message || "Workflow failed."), + retryable: Boolean(error.retryable), + validation_errors: safeValidationErrors( + error.details?.validation_errors || error.validation_errors, + ), + }; + return this.persistTerminalJob(job); + } + + async assertJobActive(jobId) { + const job = await this.requireJob(jobId, { refresh: true }); + if (job.status === "cancelled" || job.cancel_requested_at) { + if (job.status !== "cancelled") await this.acknowledgeJobCancellation(job); + throw new HttpError(409, "job_cancelled", "任务已取消,后续步骤不会继续执行。", { + job_id: job.id, + }); + } + return job; + } + + async acknowledgeJobCancellation(input) { + const job = typeof input === "string" + ? await this.requireJob(input, { refresh: true }) + : clone(input); + if (job.status === "cancelled") return job; + + if (typeof this.repository?.acknowledgeJobCancellation === "function" + && this.persistence.enabled + && job.worker_id) { + const cancelled = await this.persist( + () => this.repository.acknowledgeJobCancellation(job.id, job.worker_id), + ); + if (cancelled) { + this.data.jobs[cancelled.id] = cancelled; + return clone(cancelled); + } + } + + const cancelledAt = job.cancel_requested_at || nowIso(); + job.status = "cancelled"; + job.stage = "cancelled"; + job.cancel_requested_at = cancelledAt; + job.finished_at = cancelledAt; + job.updated_at = cancelledAt; + job.worker_id = null; + job.lease_expires_at = null; + job.error = null; + this.data.jobs[job.id] = job; + return this.persistTerminalJob(job); + } + + async cancelJob(jobId) { + const job = await this.requireJob(jobId, { refresh: true }); + if (job.status === "cancelled") return clone(job); + if (!["queued", "running"].includes(job.status)) { + throw new HttpError(409, "job_not_cancellable", "只有等待中或执行中的任务可以取消。", { + job_id: job.id, + status: job.status, + }); + } + if (this.asyncJobsEnabled + && ASYNC_JOB_TYPES.has(job.job_type) + && typeof this.repository?.requestJobCancellation === "function" + && this.persistence.enabled) { + const requested = await this.persist(() => this.repository.requestJobCancellation(job.id)); + if (requested) { + this.data.jobs[requested.id] = requested; + return clone(requested); + } + } + const cancelledAt = nowIso(); + job.status = "cancelled"; + job.cancel_requested_at = cancelledAt; + job.finished_at = cancelledAt; + job.updated_at = cancelledAt; + job.error = null; + return this.persistTerminalJob(job); + } + + async retryJob(jobId) { + const job = await this.requireJob(jobId, { refresh: true }); + if (!["failed", "cancelled"].includes(job.status)) { + throw new HttpError(409, "job_not_retryable", "只有失败或已取消的任务可以重试。", { + job_id: job.id, + status: job.status, + }); + } + if (this.asyncJobsEnabled && ASYNC_JOB_TYPES.has(job.job_type)) { + if (Number(job.attempt_count || 0) >= Number(job.max_attempts || 1)) { + throw new HttpError(409, "job_attempts_exhausted", "任务已达到最大执行次数。", { + job_id: job.id, + attempt_count: Number(job.attempt_count || 0), + max_attempts: Number(job.max_attempts || 1), + }); + } + if (typeof this.repository?.retryQueuedJob !== "function") { + throw new HttpError(503, "job_queue_unavailable", "后台任务队列暂不可用,无法重试。"); + } + const queued = await this.persist(() => this.repository.retryQueuedJob(job.id)); + this.data.jobs[queued.id] = queued; + return this.publicJob(queued); + } + if (job.job_type === "sales_dossier_generation") { + return this.createDossier(job.entity_id, job.request || {}, { retry_job_id: job.id }); + } + if (job.job_type === "sales_qa") { + return this.askQuestion(job.entity_id, job.request || {}, { retry_job_id: job.id }); + } + if (job.job_type === "sales_company_search") { + return this.searchCompanies(job.entity_id, job.request || {}, { retry_job_id: job.id }); + } + throw new HttpError(422, "job_retry_unsupported", "该任务类型暂不支持手动重试。", { + job_id: job.id, + job_type: job.job_type, + }); + } + + async listJobs(filters = {}) { + await this.assertRuntimeReady(); + if (typeof this.repository?.listJobs === "function" && this.persistence.enabled) { + return this.repository.listJobs(filters); + } + const requestedLimit = Number(filters.limit || 20); + const limit = Math.max(1, Math.min(Number.isFinite(requestedLimit) ? requestedLimit : 20, 100)); + return Object.values(this.data.jobs || {}) + .filter((job) => !filters.job_type || job.job_type === filters.job_type) + .filter((job) => !filters.status || job.status === filters.status) + .filter((job) => !filters.entity_id || job.entity_id === filters.entity_id) + .sort((a, b) => String(b.created_at || "").localeCompare(String(a.created_at || ""))) + .slice(0, limit) + .map(clone); + } + + async getJob(jobId) { + return clone(await this.requireJob(jobId, { refresh: true })); + } + + async listPublicJobs(filters = {}) { + return (await this.listJobs(filters)).map((job) => this.publicJob(job)); + } + + async getPublicJob(jobId) { + return this.publicJob(await this.requireJob(jobId, { refresh: true })); + } + + async getPaidWorkflowUsage() { + await this.assertRuntimeReady(); + return this.paidWorkflowGuard.snapshot(); + } + + trackProviderStep(runId, input, operation) { + if (!runId) return operation(); + return this.providerRuns.executeStep(runId, input, operation); + } + + async skipProviderStep(runId, input) { + if (!runId) return null; + return this.providerRuns.skipStep(runId, input); + } + + listGoals() { + return this.data.goals.map((goal) => this.goalView(goal)); + } + + exportWorkspaceData() { + const goals = this.data.goals.map((goal) => ({ + ...this.goalView(goal), + target_enterprise_ids: [...new Set(goal.company_ids || [])], + candidate_company_ids: [...new Set(goal.candidate_ids || [])], + })); + const goalIdsByCompany = new Map(); + for (const goal of this.data.goals) { + for (const companyId of goal.company_ids || []) { + const goalIds = goalIdsByCompany.get(companyId) || []; + goalIds.push(goal.id); + goalIdsByCompany.set(companyId, goalIds); + } + } + + const enterprises = Object.values(this.data.companies) + .filter(Boolean) + .sort((left, right) => String(left.name || "").localeCompare(String(right.name || ""), "zh-CN")) + .map((company) => { + const materials = (company.material_ids || []) + .map((id) => this.data.materials[id]) + .filter(Boolean) + .map((material) => ({ + id: material.id, + company_id: company.id, + title: String(material.title || ""), + summary: String(material.summary || ""), + source_type: String(material.source_type || ""), + source_url: publicSourceUrl(material.source_url), + source_id: material.source_id || null, + source_external_id: material.source_external_id || "", + source_version: material.source_version || "", + content_hash: material.content_hash || null, + raw_text: normalizeImportedText(material.text || ""), + source_items: normalizeSourceItems(material.source_items || []), + occurred_at: material.occurred_at || null, + last_synced_at: material.last_synced_at || null, + created_at: material.created_at || null, + updated_at: material.updated_at || material.created_at || null, + })); + const materialSources = this.listMaterialSyncSources(company.id).map((source) => ({ + id: source.id, + source_type: source.source_type, + external_id: source.external_id, + display_name: source.display_name, + status: source.status, + material_ids: source.material_ids, + last_synced_at: source.last_synced_at, + updated_at: source.updated_at, + checkpoint: source.checkpoint ? { + checkpoint_key: source.checkpoint.checkpoint_key, + checkpoint_value: source.checkpoint.checkpoint_value, + last_success_at: source.checkpoint.last_success_at, + updated_at: source.checkpoint.updated_at, + } : null, + })); + return { + ...this.companyView(company, { in_pool: (goalIdsByCompany.get(company.id) || []).length > 0 }), + goal_ids: goalIdsByCompany.get(company.id) || [], + progress_detail: this.progressView(company), + dossiers: (company.dossier_ids || []) + .map((id) => this.data.dossiers[id]) + .filter(Boolean) + .map((dossier) => this.publicDossier(dossier)), + materials, + material_sources: materialSources, + qa: this.cachedQa(company.id), + }; + }); + + return { + format: "sales-intelligence-workbench-workspace-export", + format_version: 1, + exported_at: nowIso(), + scope: "single_workspace", + contains_private_business_data: true, + goals, + enterprises, + }; + } + + async createGoal(body = {}) { + const name = String(body.name || "").trim(); + if (!name) throw new HttpError(400, "bad_request", "销售目标名称不能为空。"); + const now = nowIso(); + const goal = { + id: makeId("sales_goal"), + name, + description: String(body.description || "新的销售目标,等待查找并加入目标企业。").trim(), + keywords: Array.isArray(body.keywords) ? body.keywords.map((item) => String(item).trim()).filter(Boolean) : [], + company_ids: [], + candidate_ids: [], + created_at: now, + updated_at: now, + }; + this.data.goals.unshift(goal); + await this.persist(() => this.repository.persistSalesGoal(goal)); + return this.goalView(goal); + } + + getGoal(goalId) { + const goal = this.data.goals.find((item) => item.id === goalId); + if (!goal) throw new HttpError(404, "sales_goal_not_found", "销售目标不存在。", { goal_id: goalId }); + return goal; + } + + goalView(goal) { + const companies = (goal.company_ids || []).map((id) => this.data.companies[id]).filter(Boolean); + return { + id: goal.id, + name: goal.name, + description: String(goal.description || "").replace(/^新建/, "新的"), + stats: `${companies.length} 家企业`, + keywords: goal.keywords || [], + created_at: goal.created_at, + updated_at: goal.updated_at, + }; + } + + companyView(company, options = {}) { + if (!company) return null; + const progress = company.progress || {}; + const progressFallback = "当前资料不足,需要补充最新档案或历史沟通资料。"; + const status = progress.label || options.status || "新商机"; + return { + id: company.id, + name: company.name, + initial: company.initial || normalizeInitial(company.name), + industry: company.industry || "企业", + location: company.location || "", + tags: company.tags || [company.industry, company.location].filter(Boolean), + status, + progress: conciseProgressSummary(status, businessText(progress.summary, progressFallback)), + evidence: businessText(progress.evidence, "依据:当前企业档案", 180), + progress_level: progressLevel(status), + updated_at: progress.updated_at || company.updated_at || null, + identity_status: company.identity_status || "unverified", + unified_social_credit_code: company.unified_social_credit_code || "", + legal_representative: company.legal_representative || "", + registered_capital: company.registered_capital || "", + business_status: company.business_status || "", + registered_address: company.registered_address || "", + established_at: company.established_at || "", + professional_verified_at: company.professional_verified_at || null, + in_pool: Boolean(options.in_pool), + reason: options.reason || "", + }; + } + + listTargetEnterprises(goalId) { + const goal = this.getGoal(goalId); + return (goal.company_ids || []).map((id) => this.companyView(this.data.companies[id], { in_pool: true })).filter(Boolean); + } + + async searchCompanies(goalId, body = {}, options = {}) { + const goal = this.getGoal(goalId); + const query = String(body.query || "").trim(); + if (!query) return []; + const job = await this.startJob({ + job_type: "sales_company_search", + entity_type: "sales_goal", + entity_id: goal.id, + max_attempts: 3, + request: { query }, + retry_job_id: options.retry_job_id || "", + }); + let run = null; + try { + run = await this.providerRuns.startRun({ + operation: "sales_company_search", + entity_type: "sales_goal", + entity_id: goal.id, + job_id: job.id, + }); + const searchText = query; + const localCandidates = this.localCompanySearch(goal, searchText); + const realEvidence = await this.collectSearchEvidence(searchText, run.id); + await this.assertJobActive(job.id); + const professionalCandidates = await this.professionalCompaniesFromEvidence(realEvidence); + const candidates = [...new Map( + [...professionalCandidates, ...localCandidates].map((company) => [company.id, company]), + ).values()].slice(0, 8); + + if (!candidates.length && query) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("datapro", "Professional data did not return an identifiable company entity.", { + reason: "company_identity_unavailable", + raw_ref: realEvidence.professional?.raw_ref || null, + }); + } + const company = await this.createCompanyFromQuery(query, realEvidence); + goal.candidate_ids = [company.id, ...(goal.candidate_ids || []).filter((id) => id !== company.id)]; + const results = [this.companyView(company, { + reason: "未识别到可核验企业主体,已保留为待确认候选。", + in_pool: goal.company_ids.includes(company.id), + provider_run_id: run.id, + job_id: job.id, + })]; + results[0].provider_run_id = run.id; + results[0].job_id = job.id; + await this.persist(() => this.repository.persistSalesGoal(goal)); + await this.persist(() => this.repository.persistSalesSearchResults(goal.id, query, results)); + await this.providerRuns.completeRun(run.id, { result_ref: `sales_search:${goal.id}:${results.length}` }); + await this.completeJob(job.id, { + result_ref: `sales_search:${goal.id}:${results.length}`, + result: { candidate_ids: results.map((item) => item.id) }, + }); + return results; + } + + goal.candidate_ids = [...new Set(candidates.map((item) => item.id))]; + const results = candidates.map((company) => ({ + ...this.companyView(company, { + reason: company.identity_status === "verified" + ? realEvidence.public_sources.length + ? "专业数据集已核验该企业主体,并已补充公开来源。" + : "专业数据集已核验该企业主体;联网公开信息暂不可用,可先加入企业池。" + : realEvidence.summary || "与当前销售目标关键词匹配。", + in_pool: goal.company_ids.includes(company.id), + }), + warnings: [...realEvidence.issues], + provider_run_id: run.id, + job_id: job.id, + })); + await this.persist(() => this.repository.persistSalesGoal(goal)); + await this.persist(() => this.repository.persistSalesSearchResults(goal.id, query, results)); + await this.providerRuns.completeRun(run.id, { result_ref: `sales_search:${goal.id}:${results.length}` }); + await this.completeJob(job.id, { + result_ref: `sales_search:${goal.id}:${results.length}`, + result: { candidate_ids: results.map((item) => item.id) }, + }); + return results; + } catch (error) { + if (run) { + try { + if (error.code === "job_cancelled") { + await this.providerRuns.cancelRun(run.id, { summary: "企业搜索任务已由用户取消。" }); + } else { + await this.providerRuns.failRun(run.id, { + code: error.code || "company_search_failed", + message: error.message || "Company search failed.", + category: error.category || "workflow", + retryable: error.retryable, + }); + } + } catch (persistenceError) { + if (this.runtimePolicy.fail_closed) throw persistenceError; + } + } + await this.failJob(job.id, error); + throw error; + } + } + + defaultCandidateIds(goal) { + return [...(goal.candidate_ids || [])]; + } + + defaultCandidateCompanies(goal) { + return this.defaultCandidateIds(goal).map((id) => this.data.companies[id]).filter(Boolean); + } + + localCompanySearch(goal, query) { + const text = String(query || "").toLowerCase(); + const ids = goal.candidate_ids || []; + const fromGoal = ids.map((id) => this.data.companies[id]).filter(Boolean); + const allCompanies = Object.values(this.data.companies); + const searched = allCompanies.filter((company) => { + const haystack = [company.name, company.industry, company.location, ...(company.tags || [])].join(" ").toLowerCase(); + return text ? haystack.includes(text) || text.split(/\s+/).some((part) => part && haystack.includes(part)) : ids.includes(company.id); + }); + if (text && searched.length) return searched.slice(0, 8); + return [...new Map([...searched, ...fromGoal].map((company) => [company.id, company])).values()].slice(0, 8); + } + + async professionalCompaniesFromEvidence(evidence = {}) { + const source = evidence.professional; + if (!source || source.ok === false) return []; + const parsedItems = collectDataProCompanyItems(source.parsed); + const summaryItem = parsedItems.length ? null : companyItemFromDataProSummary(source.summary || source.text); + const items = summaryItem ? [summaryItem] : parsedItems; + const companies = []; + for (const item of items.slice(0, 5)) { + const company = await this.upsertProfessionalCompany(item, source, { + search_alias: evidence.search_query || "", + }); + if (company) companies.push(company); + } + return [...new Map(companies.map((company) => [company.id, company])).values()]; + } + + async upsertProfessionalCompany(item, source = {}, options = {}) { + const name = dataProField(item, dataProCompanyFields.name, 180); + if (!name) return null; + const unifiedSocialCreditCode = dataProField(item, dataProCompanyFields.unified_social_credit_code, 80); + const normalizedName = normalizedCompanyIdentity(name); + const existing = Object.values(this.data.companies).find((company) => { + if (unifiedSocialCreditCode && company.unified_social_credit_code === unifiedSocialCreditCode) return true; + return normalizedCompanyIdentity(company.name) === normalizedName; + }); + const identity = unifiedSocialCreditCode || normalizedName; + if (!identity) return null; + + const now = nowIso(); + const address = dataProField(item, dataProCompanyFields.address, 500); + const industry = dataProField(item, dataProCompanyFields.industry, 120) || existing?.industry || "待确认行业"; + const location = compactCompanyLocation(item, address) || existing?.location || ""; + const tags = [...new Set([ + ...(existing?.tags || []), + industry === "待确认行业" ? "" : industry, + location, + "专业数据集已核验", + ].filter(Boolean))].slice(0, 8); + const id = existing?.id || stableProfessionalCompanyId(identity); + const searchAlias = companySearchAlias(options.search_alias, name); + const company = { + ...(existing || {}), + id, + name, + initial: normalizeInitial(name), + industry, + location, + tags, + aliases: [...new Set([ + ...(existing?.aliases || []), + existing?.name, + name, + searchAlias, + parentheticalBrandAlias(name), + ].filter(Boolean))], + unified_social_credit_code: unifiedSocialCreditCode || existing?.unified_social_credit_code || "", + legal_representative: dataProField(item, dataProCompanyFields.legal_representative, 120) || existing?.legal_representative || "", + registered_capital: dataProField(item, dataProCompanyFields.registered_capital, 120) || existing?.registered_capital || "", + business_status: dataProField(item, dataProCompanyFields.business_status, 120) || existing?.business_status || "", + registered_address: address || existing?.registered_address || "", + established_at: dataProField(item, dataProCompanyFields.established_at, 80) || existing?.established_at || "", + business_scope: dataProField(item, dataProCompanyFields.business_scope, 1200) || existing?.business_scope || "", + identity_status: "verified", + data_origin: "datapro", + professional_source_ref: source.raw_ref || source.request_id || existing?.professional_source_ref || null, + professional_verified_at: now, + progress: existing?.progress || { + label: "新商机", + summary: "企业主体已通过专业数据集核验,待生成最新档案。", + evidence: "依据:专业数据集", + updated_at: now, + }, + dossier_ids: existing?.dossier_ids || [], + material_ids: existing?.material_ids || [], + qa_session_id: existing?.qa_session_id || `sales-${id}`, + created_at: existing?.created_at || now, + updated_at: now, + }; + this.data.companies[id] = company; + this.data.qa_messages[id] = this.data.qa_messages[id] || []; + await this.persist(() => this.repository.persistSalesCompany(company)); + return company; + } + + async createCompanyFromQuery(query, evidence = {}) { + const normalizedQuery = normalizedCompanyIdentity(query); + const existing = Object.values(this.data.companies) + .find((company) => normalizedCompanyIdentity(company.name) === normalizedQuery); + if (existing) return existing; + const now = nowIso(); + const id = makeId("company"); + const company = { + id, + name: query, + initial: normalizeInitial(query), + industry: "待确认行业", + location: "", + tags: ["待确认"], + identity_status: "unverified", + data_origin: "user_input", + progress: { + label: "新商机", + summary: evidence.summary || "已创建目标企业,等待获取最新档案和历史资料。", + evidence: "依据:用户输入", + updated_at: now, + }, + dossier_ids: [], + material_ids: [], + qa_session_id: `sales-${id}`, + created_at: now, + updated_at: now, + }; + this.data.companies[id] = company; + this.data.qa_messages[id] = []; + await this.persist(() => this.repository.persistSalesCompany(company)); + return company; + } + + async collectSearchEvidence(query, providerRunId = "") { + const result = { + search_query: String(query || "").trim(), + summary: "", + professional: null, + public_sources: [], + issues: [], + }; + if (!query) return result; + + if (this.dataProProvider?.isRunEnabled?.()) { + try { + const dataPro = await this.trackProviderStep(providerRunId, { + provider: "datapro", + operation: "search_company_professional_data", + input_summary: `查询 ${query} 的企业主体信息`, + output_summary: "已完成企业主体查询。", + }, () => this.dataProProvider.callTool(`${query} 企业工商信息 招投标 公告`)); + if (dataPro.ok) { + result.professional = dataPro; + result.summary = "已调用专业数据集补充企业候选依据。"; + } else { + result.issues.push(`专业数据集暂时不可用:${dataPro.error?.code || "provider_error"}`); + } + } catch (error) { + result.issues.push(`专业数据集暂时不可用:${error.message}`); + } + } else { + await this.skipProviderStep(providerRunId, { + provider: "datapro", + operation: "search_company_professional_data", + input_summary: `查询 ${query} 的企业主体信息`, + output_summary: "DataPro 未启用。", + error: { code: "provider_disabled", message: "DataPro is not enabled." }, + }); + } + + if (this.webSearchProvider?.isRunEnabled?.()) { + try { + const web = await this.trackProviderStep(providerRunId, { + provider: "web_search", + operation: "search_company_public_sources", + input_summary: `检索 ${query} 的公开信息`, + output_summary: "已完成候选企业公开信息检索。", + }, () => this.webSearchProvider.search({ query: `${query} 公司 公告 新闻`.slice(0, 100), count: 3, need_summary: true })); + if (web.ok) { + result.public_sources = web.results || []; + result.summary = result.summary || "已调用联网搜索补充公开信息。"; + } else { + result.issues.push(`联网搜索暂时不可用:${web.error?.code || "provider_error"}`); + } + } catch (error) { + result.issues.push(`联网搜索暂时不可用:${error.message}`); + } + } else { + await this.skipProviderStep(providerRunId, { + provider: "web_search", + operation: "search_company_public_sources", + input_summary: `检索 ${query} 的公开信息`, + output_summary: "联网搜索未启用。", + error: { code: "provider_disabled", message: "Web search is not enabled." }, + }); + } + + if (this.runtimePolicy.fail_closed && !result.professional) { + throw providerUnavailable("datapro", "No verified professional-data result was returned.", { + issues: result.issues, + }); + } + return result; + } + + async addTargetEnterprise(goalId, body = {}) { + const goal = this.getGoal(goalId); + let companyId = String(body.company_id || "").trim(); + if (!companyId && body.company?.name) { + companyId = (await this.createCompanyFromQuery(body.company.name)).id; + } + if (!companyId) throw new HttpError(400, "bad_request", "company_id 不能为空。"); + const company = this.data.companies[companyId]; + if (!company) throw new HttpError(404, "company_not_found", "企业不存在。", { company_id: companyId }); + if (!goal.company_ids.includes(companyId)) goal.company_ids.push(companyId); + goal.updated_at = nowIso(); + await this.persist(() => this.repository.persistSalesGoal(goal)); + await this.persist(() => this.repository.persistSalesTargetEnterprise(goal.id, company)); + return this.enterpriseDetail(companyId, { goal_id: goalId }); + } + + requireCompany(companyId) { + const company = this.data.companies[companyId]; + if (!company) throw new HttpError(404, "company_not_found", "企业不存在。", { company_id: companyId }); + return company; + } + + async enterpriseDetail(companyId, options = {}) { + const company = this.requireCompany(companyId); + return { + ...this.companyView(company, { in_pool: true }), + goal_id: options.goal_id || null, + progress_detail: this.progressView(company), + dossiers: this.listDossiers(companyId), + materials: this.listMaterials(companyId), + qa: await this.getQa(companyId), + }; + } + + progressView(companyOrId) { + const company = typeof companyOrId === "string" ? this.requireCompany(companyOrId) : companyOrId; + const progress = company.progress || {}; + const label = progress.label || "新商机"; + return { + label, + summary: conciseProgressSummary(label, businessText(progress.summary, "暂未形成明确进展。")), + evidence: businessText(progress.evidence, "依据:当前企业档案", 180), + updated_at: progress.updated_at || null, + }; + } + + listDossiers(companyId) { + const company = this.requireCompany(companyId); + return (company.dossier_ids || []) + .map((id) => this.data.dossiers[id]) + .filter(Boolean) + .filter(isDisplayableDossier) + .map((dossier) => ({ + dossier, + publicView: this.publicDossier(dossier), + })) + .filter(({ publicView }) => ( + !this.runtimePolicy.fail_closed + || !this.publicDossierQualityErrors(publicView, company).length + )) + .sort((a, b) => String(b.dossier.created_at).localeCompare(String(a.dossier.created_at))) + .map(({ dossier, publicView }) => ({ + id: publicView.id, + company_id: publicView.company_id, + title: publicView.title, + summary: publicView.summary, + version_no: Number(publicView.version_no || 1), + previous_dossier_id: publicView.previous_dossier_id || null, + change_status: publicView.change_status || "initial", + data_as_of: publicView.data_as_of ?? null, + generated_at: publicView.generated_at || dossier.created_at, + created_at: publicView.created_at, + })); + } + + dossierDetail(dossierId) { + const dossier = this.data.dossiers[dossierId]; + if (!dossier) throw new HttpError(404, "dossier_not_found", "档案不存在。", { dossier_id: dossierId }); + const publicView = this.publicDossier(dossier); + const company = this.data.companies[dossier.company_id] || { + name: String(dossier.title || "").replace(/\s*(?:最近档案|销售情报报告).*/, ""), + aliases: [], + }; + if ( + this.runtimePolicy.fail_closed + && this.publicDossierQualityErrors(publicView, company).length + ) { + throw new HttpError(404, "dossier_not_found", "档案不存在。", { dossier_id: dossierId }); + } + return publicView; + } + + publicDossier(dossier) { + const summary = normalizeSalesText( + businessText(dossier.summary, "这份档案需要重新获取最新资料后再展示。", 300), + ); + const storedCompany = this.data.companies[dossier.company_id]; + const companyName = cleanEvidenceSummary( + storedCompany?.name + || String(dossier.title || "").replace(/\s*(?:最近档案|销售情报报告).*/, ""), + "目标企业", + 80, + ); + const company = storedCompany || { name: companyName, aliases: [] }; + const storedCitations = firstJsonArray(dossier.citations); + const storedCitationIds = new Set(storedCitations.map((citation) => String(citation.id))); + const evidencePackCitationCandidates = evidencePackCitations({ + items: firstJsonArray(dossier.evidence_pack), + }).filter((citation) => !storedCitationIds.has(String(citation.id))); + const keptCitations = [...storedCitations, ...evidencePackCitationCandidates] + .filter((citation) => isDisplayableDossierCitation(citation, company)) + .sort((a, b) => citationRank(a) - citationRank(b)); + const citationIdMap = new Map(); + const keptCitationIds = new Set(keptCitations.map((citation) => String(citation.id))); + const bodySectionsWithRemovedCitation = new Set( + firstJsonArray(dossier.body) + .map((paragraph, index) => ( + firstJsonArray(paragraph.citation_ids).some((id) => !keptCitationIds.has(String(id))) + ? index + : -1 + )) + .filter((index) => index >= 0), + ); + const citations = keptCitations.map((citation, index) => { + const id = String(index + 1); + citationIdMap.set(String(citation.id), id); + return publicCitationView(citation, id); + }); + const validationCitations = keptCitations.map((citation, index) => ({ + ...citation, + id: String(index + 1), + })); + const body = firstJsonArray(dossier.body).map((paragraph) => ({ + text: normalizeSalesText(compactCompleteSentences( + businessText(paragraph.text, summary, 1400), + 1400, + )), + citation_ids: firstJsonArray(paragraph.citation_ids) + .map((id) => citationIdMap.get(String(id))) + .filter(Boolean), + segments: firstJsonArray(paragraph.segments).map((segment) => ({ + text: normalizeSalesText(compactCompleteSentences( + businessText(segment.text, "", 1400), + 1200, + )), + citation_ids: firstJsonArray(segment.citation_ids) + .map((id) => citationIdMap.get(String(id))) + .filter(Boolean), + })).filter((segment) => segment.text), + })); + const publicView = { + id: dossier.id, + company_id: dossier.company_id, + title: `${companyName} 销售情报报告`, + summary, + body: [], + citations, + version_no: Number(dossier.version_no || 1), + previous_dossier_id: dossier.previous_dossier_id || null, + change_status: dossier.change_status || "initial", + data_as_of: dossier.data_as_of ?? null, + generated_at: dossier.generated_at || dossier.created_at || null, + created_at: dossier.created_at || null, + updated_at: dossier.updated_at || dossier.created_at || null, + }; + publicView.body = bodySectionsWithRemovedCitation.size + ? [] + : this.fixedPublicDossierBody(publicView, body, company, validationCitations); + if (!publicView.body.length) { + publicView.summary = ""; + publicView.citations = []; + return publicView; + } + const usedCitationIds = new Set( + publicView.body.flatMap((paragraph) => firstJsonArray(paragraph.citation_ids).map(String)), + ); + const usedCitations = citations.filter((citation) => usedCitationIds.has(String(citation.id))); + const finalCitationIdMap = new Map( + usedCitations.map((citation, index) => [String(citation.id), String(index + 1)]), + ); + publicView.citations = usedCitations.map((citation, index) => ({ + ...citation, + id: String(index + 1), + })); + Object.defineProperty(publicView, "_validation_citations", { + configurable: false, + enumerable: false, + writable: false, + value: usedCitations.map((citation, index) => ({ + ...citation, + id: String(index + 1), + })), + }); + publicView.body = publicView.body.map((paragraph) => ({ + ...paragraph, + citation_ids: [...new Set( + firstJsonArray(paragraph.citation_ids) + .map((id) => finalCitationIdMap.get(String(id))) + .filter(Boolean), + )], + segments: firstJsonArray(paragraph.segments).map((segment) => ({ + ...segment, + citation_ids: [...new Set( + firstJsonArray(segment.citation_ids) + .map((id) => finalCitationIdMap.get(String(id))) + .filter(Boolean), + )], + })).filter((segment) => segment.text && segment.citation_ids.length), + })); + publicView.data_as_of = deriveEvidenceDataAsOf( + publicView.citations, + publicView.generated_at || new Date().toISOString(), + ); + const groundedSummary = [ + publicView.body.find((item) => item.text.startsWith("近期公开动态:"))?.text.replace(/^近期公开动态:/, ""), + publicView.body.find((item) => item.text.startsWith("销售机会判断:"))?.text.replace(/^销售机会判断:/, ""), + ].filter(Boolean).join(" "); + publicView.summary = compactCompleteSentences( + isSubstantiveDossierSummary(groundedSummary) ? groundedSummary : publicView.summary, + 300, + ); + return publicView; + } + + publicDossierQualityErrors(publicView, company) { + const validationCitations = publicView?._validation_citations || publicView?.citations || []; + const validated = validateDossierModelAnswer(publicView, validationCitations); + return [ + ...validated.errors, + ...(this.runtimePolicy.fail_closed + ? dossierGroundingErrors(validationCitations, validated.body, company) + : []), + ...dossierSectionSourceErrors(validated.body, validationCitations, company), + ...dossierSectionContentErrors(validated.body), + ...dossierSectionSemanticErrors(validated.body, validationCitations, company), + ...dossierSectionEvidenceGroundingErrors(validated.body, validationCitations), + ]; + } + + async createDossier(companyId, body = {}, options = {}) { + const company = this.requireCompany(companyId); + const reportProgress = typeof options.report_progress === "function" + ? options.report_progress + : async () => {}; + const saveCheckpoint = typeof options.save_checkpoint === "function" + ? options.save_checkpoint + : async () => null; + const job = options.claimed_job + ? await this.activateClaimedJob(options.claimed_job, "sales_dossier_generation") + : await this.startJob({ + job_type: "sales_dossier_generation", + entity_type: "target_enterprise", + entity_id: company.id, + max_attempts: 3, + request: body, + retry_job_id: options.retry_job_id || "", + }); + let dossierCheckpoint = reusableDossierCheckpoint( + job.checkpoint?.dossier, + company.id, + this.dossierCheckpointTtlMs, + ) || { + schema_version: 1, + company_id: company.id, + created_at: nowIso(), + }; + const persistDossierCheckpoint = async (patch = {}, progressOptions = {}) => { + dossierCheckpoint = { + ...dossierCheckpoint, + ...clone(patch), + schema_version: 1, + company_id: company.id, + updated_at: nowIso(), + }; + await saveCheckpoint( + { dossier: dossierCheckpoint }, + progressOptions, + ); + return dossierCheckpoint; + }; + let run = null; + + try { + run = await this.providerRuns.startRun({ + operation: "sales_dossier_generation", + entity_type: "target_enterprise", + entity_id: company.id, + job_id: job.id, + }); + const generatedAt = nowIso(); + let evidencePack = objectValue(dossierCheckpoint.evidence_pack); + if (Array.isArray(evidencePack.items) && evidencePack.evidence_hash) { + await reportProgress("building_evidence", 50); + await this.skipProviderStep(run.id, { + provider: "rule", + operation: "resume_evidence_checkpoint", + input_summary: `恢复 ${company.name} 当前任务中已经完成的资料采集`, + output_summary: `已从任务检查点恢复 ${evidencePack.items.length} 条资料,未重复调用上游服务。`, + }); + evidencePack = clone(evidencePack); + } else { + const collected = await this.collectDossierEvidence(company, run.id, { + checkpoint: dossierCheckpoint.evidence_collection, + report_progress: reportProgress, + save_checkpoint: async (collection, progressOptions = {}) => persistDossierCheckpoint( + { evidence_collection: collection }, + progressOptions, + ), + }); + await this.assertJobActive(job.id); + await reportProgress("building_evidence", 50); + const packed = await this.trackProviderStep(run.id, { + provider: "rule", + operation: "build_evidence_pack", + input_summary: `校验 ${company.name} 的专业数据集与豆包搜索来源,并计算稳定证据哈希`, + }, async () => { + const builtEvidencePack = buildDossierEvidencePack({ + company, + collected, + memoryContexts: [], + generatedAt, + }); + return { + ok: true, + provider: "rule", + provider_mode: "local", + evidence_pack: builtEvidencePack, + summary: `证据包保留 ${builtEvidencePack.items.length} 条,拒绝 ${builtEvidencePack.rejected.length} 条不满足主体或内容质量门禁的来源。`, + }; + }); + evidencePack = packed.evidence_pack; + await persistDossierCheckpoint( + { + collected_at: evidencePack.collected_at, + evidence_pack: evidencePack, + }, + { + stage: "validating_evidence", + progress: 56, + detail: { message: "正在校验资料与企业主体" }, + }, + ); + } + await this.assertJobActive(job.id); + await reportProgress("validating_evidence", 56); + if (this.runtimePolicy.fail_closed) { + const evidenceValidation = validateProductionEvidencePack(evidencePack); + if (!evidenceValidation.ok) { + throw new HttpError(422, "evidence_quality_insufficient", "现有来源不足以生成可对外使用的最新档案。", { + validation_errors: evidenceValidation.errors, + evidence_policy: evidenceValidation.policy, + }); + } + } + await this.assertJobActive(job.id); + const storedDossiers = (company.dossier_ids || []) + .map((id) => this.data.dossiers[id]) + .filter(Boolean) + .sort((a, b) => Number(b.version_no || 1) - Number(a.version_no || 1) + || String(b.created_at || "").localeCompare(String(a.created_at || ""))); + const latestDossier = storedDossiers + .filter(isDisplayableDossier) + .find((item) => ( + !this.runtimePolicy.fail_closed + || !this.publicDossierQualityErrors(this.publicDossier(item), company).length + )) || null; + const nextVersionNo = Math.max( + 0, + ...storedDossiers.map((item) => Number(item.version_no || 0)).filter(Number.isFinite), + ) + 1; + + const currentCitationInputs = this.buildCitationInputs(evidencePack, []); + const currentSourcePolicy = dossierSectionSourcePolicy(currentCitationInputs, company); + const currentTargetName = String(company.name || company.legal_name || "").trim(); + const currentTargetEntityKey = normalizeLegalEntityName(currentTargetName); + const currentAgentContext = buildDossierAgentContext({ + citations: currentCitationInputs, + evidencePolicy: evidencePack.policy || null, + evidenceConflicts: evidencePack.conflicts || [], + sourceSelectionPolicy: { + business_database_ids: [...currentSourcePolicy.business], + business_dynamics_ids: [...currentSourcePolicy.businessDynamics], + risk_database_ids: [...currentSourcePolicy.risk], + market_database_ids: [...currentSourcePolicy.market], + professional_dataset_ids: [...currentSourcePolicy.professional], + web_search_ids: [...currentSourcePolicy.web], + excluded_entity_citation_ids: currentCitationInputs + .map((citation) => ({ citation, record: dossierBusinessEntityRecord(citation) })) + .filter(({ record }) => ( + record + && normalizeLegalEntityName(record.name) !== currentTargetEntityKey + )) + .map(({ citation }) => String(citation.id)), + }, + }); + const latestDossierValidation = latestDossier + ? validateDossierModelAnswer(latestDossier, currentCitationInputs) + : { body: [], errors: ["没有可复用的历史档案"] }; + const latestDossierQualityErrors = latestDossier + ? [ + ...latestDossierValidation.errors, + ...dossierSectionSourceErrors(latestDossierValidation.body, currentCitationInputs, company), + ...dossierSourceUsageErrors( + latestDossierValidation.body.flatMap((paragraph) => paragraph.citation_ids || []), + currentAgentContext.citations, + currentAgentContext.sourceUsageRequirements, + "现有档案", + ), + ] + : latestDossierValidation.errors; + if ( + latestDossier?.evidence_hash + && latestDossier.evidence_hash === evidencePack.evidence_hash + && latestDossierQualityErrors.length === 0 + ) { + await this.skipProviderStep(run.id, { + provider: "model", + operation: "generate_sales_dossier", + input_summary: `检查 ${company.name} 是否需要生成新版本`, + output_summary: "证据内容未变化,未重复调用模型。", + }); + await this.providerRuns.completeRun(run.id, { result_ref: `dossier:${latestDossier.id}:unchanged` }); + await reportProgress("persisting_result", 95); + await this.completeJob(job.id, { + result_ref: `dossier:${latestDossier.id}:unchanged`, + result: { action: "no_material_change", dossier_id: latestDossier.id }, + }); + return { + action: "no_material_change", + checked_at: generatedAt, + record: this.listDossiers(companyId).find((item) => item.id === latestDossier.id), + detail: this.dossierDetail(latestDossier.id), + progress: this.progressView(company), + memory_record: null, + provider_run_id: run.id, + job_id: job.id, + }; + } + + await reportProgress("generating_dossier", 68); + const modelDossier = await this.generateDossierWithModel(company, evidencePack, [], run.id); + await this.assertJobActive(job.id); + await reportProgress("validating_dossier", 86); + let dossier = modelDossier; + if (!dossier) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("model", "The model did not return a publishable dossier.", { + reason: "dossier_quality_gate_failed", + validation_errors: ["模型结果未通过正文、引用或展示质量门禁,未保存规则兜底档案。"], + }); + } + const ruleResult = await this.trackProviderStep(run.id, { + provider: "rule", + operation: "build_dossier_fallback", + input_summary: `为 ${company.name} 生成证据不足时的明确说明`, + output_summary: "已生成不冒充模型结果的规则档案。", + }, async () => ({ + ok: true, + provider: "rule", + provider_mode: "mixed", + dossier: this.buildRuleDossier(company, evidencePack, []), + })); + dossier = ruleResult.dossier; + } + + dossier.provider_run_id = run.id; + dossier.version_no = nextVersionNo; + dossier.previous_dossier_id = latestDossier?.id || null; + dossier.evidence_hash = evidencePack.evidence_hash; + dossier.change_status = latestDossier ? "changed" : "initial"; + dossier.data_as_of = evidencePack.data_as_of; + dossier.generated_at = generatedAt; + dossier.evidence_pack = evidencePack.items; + const usedCitationIds = new Set( + firstJsonArray(dossier.body) + .flatMap((paragraph) => firstJsonArray(paragraph?.citation_ids).map(String)), + ); + dossier.citations = firstJsonArray(dossier.citations) + .filter((citation) => usedCitationIds.has(String(citation?.id || ""))); + dossier.data_as_of = deriveEvidenceDataAsOf(dossier.citations, generatedAt); + const dossierGroundingValidationErrors = dossierGroundingErrors( + dossier.citations, + dossier.body, + company, + ); + if (this.runtimePolicy.fail_closed && dossierGroundingValidationErrors.length) { + throw providerUnavailable("model", "The dossier did not cite a verified legal-entity anchor.", { + reason: "public_dossier_quality_gate_failed", + validation_errors: dossierGroundingValidationErrors, + }); + } + const finalPublicView = this.publicDossier(dossier); + const finalPublicViewErrors = this.publicDossierQualityErrors(finalPublicView, company); + if (this.runtimePolicy.fail_closed && finalPublicViewErrors.length) { + throw providerUnavailable("model", "The dossier failed the final pre-persistence public-view quality gate.", { + reason: "public_dossier_quality_gate_failed", + validation_errors: finalPublicViewErrors, + }); + } + dossier.dossier_fingerprint = makeDossierFingerprint(dossier); + if ( + latestDossier + && makeDossierFingerprint(latestDossier) === dossier.dossier_fingerprint + ) { + await this.providerRuns.completeRun(run.id, { + result_ref: `dossier:${latestDossier.id}:same_report`, + }); + await reportProgress("persisting_result", 95); + await this.completeJob(job.id, { + result_ref: `dossier:${latestDossier.id}:same_report`, + result: { action: "no_report_change", dossier_id: latestDossier.id }, + }); + return { + action: "no_report_change", + checked_at: generatedAt, + record: this.listDossiers(companyId).find((item) => item.id === latestDossier.id), + detail: this.dossierDetail(latestDossier.id), + progress: this.progressView(company), + memory_record: null, + provider_run_id: run.id, + job_id: job.id, + }; + } + + const nextCompany = { + ...company, + dossier_ids: [dossier.id, ...(company.dossier_ids || []).filter((id) => id !== dossier.id)], + progress: this.progressFromDossier(company, dossier), + updated_at: nowIso(), + }; + await this.skipProviderStep(run.id, { + provider: "openviking", + operation: "store_dossier_memory", + input_summary: `判断 ${company.name} 的档案应由哪一层保存`, + output_summary: "档案属于结构化业务记录,由 Supabase 保存,不重复写入 OpenViking。", + }); + const memoryRecord = null; + + if (this.persistence.enabled && this.repository) { + await reportProgress("persisting_result", 90); + await this.trackProviderStep(run.id, { + provider: "supabase", + operation: "persist_dossier", + input_summary: `保存 ${company.name} 的档案、进度和外部引用`, + output_summary: "档案及关联状态已持久化。", + }, async () => { + await this.persist(() => this.repository.persistSalesCompany(nextCompany)); + await this.persist(() => this.repository.persistSalesDossier(dossier)); + return { ok: true, provider: "supabase", provider_mode: "real" }; + }); + } else { + await this.skipProviderStep(run.id, { + provider: "supabase", + operation: "persist_dossier", + input_summary: `保存 ${company.name} 的档案和进度`, + output_summary: "当前配置未启用持久化仓库。", + error: { code: "repository_disabled", message: "Persistent repository is not enabled." }, + }); + } + + this.data.dossiers[dossier.id] = dossier; + this.data.companies[company.id] = nextCompany; + await this.providerRuns.completeRun(run.id, { result_ref: `dossier:${dossier.id}` }); + await this.completeJob(job.id, { + result_ref: `dossier:${dossier.id}`, + result: { action: "created", dossier_id: dossier.id, version_no: dossier.version_no }, + }); + return { + action: "created", + record: this.listDossiers(companyId)[0], + detail: this.dossierDetail(dossier.id), + progress: this.progressView(nextCompany), + memory_record: memoryRecord, + provider_run_id: run.id, + job_id: job.id, + }; + } catch (error) { + if (run) { + try { + if (error.code === "job_cancelled") { + await this.providerRuns.cancelRun(run.id, { summary: "档案生成任务已由用户取消。" }); + } else { + await this.providerRuns.failRun(run.id, { + code: error.code || "dossier_generation_failed", + message: error.message || "Dossier generation failed.", + category: error.category || "workflow", + retryable: error.retryable, + details: { + validation_errors: safeValidationErrors(error.details?.validation_errors), + }, + }); + } + } catch (persistenceError) { + if (this.runtimePolicy.fail_closed) throw persistenceError; + } + } + if (!options.claimed_job) await this.failJob(job.id, error); + throw error; + } + } + + async collectDossierEvidence(company, providerRunId = "", options = {}) { + const checkpoint = objectValue(options.checkpoint); + const professional = firstJsonArray(checkpoint.professional).map(clone); + const publicSources = firstJsonArray(checkpoint.public_sources).map(clone); + const issues = firstJsonArray(checkpoint.issues).map((item) => String(item)).filter(Boolean); + const completedQueryKeys = new Set( + firstJsonArray(checkpoint.completed_query_keys).map(String).filter(Boolean), + ); + const professionalFailures = []; + const publicFailures = []; + const reportProgress = typeof options.report_progress === "function" + ? options.report_progress + : async () => {}; + const saveCheckpoint = typeof options.save_checkpoint === "function" + ? options.save_checkpoint + : async () => {}; + let checkpointWrite = Promise.resolve(); + const persistCollection = (progressOptions = {}) => { + const snapshot = { + schema_version: 1, + company_id: company.id, + professional: clone(professional), + public_sources: clone(publicSources), + issues: [...new Set(issues)].slice(-40), + completed_query_keys: [...completedQueryKeys].sort(), + updated_at: nowIso(), + }; + checkpointWrite = checkpointWrite.then(() => saveCheckpoint(snapshot, progressOptions)); + return checkpointWrite; + }; + + if (this.dataProProvider?.isRunEnabled?.()) { + const maxProfessionalSources = Math.max(1, Math.min(Number(this.dataProProvider.maxSources || 3), 5)); + const dataProQueries = this.dataProProvider.planDossierQueries?.(company, { + maxSources: maxProfessionalSources, + }) || [ + { + label: "企业风险数据库", + purpose: "风险与关注事项核验", + query: `${company.name} 企业风险数据 司法诉讼 行政处罚 失信被执行 经营异常`, + }, + { + label: "企业工商数据库", + purpose: "主体与经营信息核验", + query: `${company.name} 企业工商数据 经营状况 经营范围 知识产权`, + }, + ].slice(0, maxProfessionalSources); + + const dataProQueryKeys = dataProQueries.map((item) => workflowQueryKey("datapro", item.query)); + const dataProCompletedCount = () => dataProQueryKeys + .filter((key) => completedQueryKeys.has(key)).length; + await reportProgress("collecting_professional", 10); + await mapWithConcurrency(dataProQueries, this.dossierDataProConcurrency, async (item) => { + const queryKey = workflowQueryKey("datapro", item.query); + if (completedQueryKeys.has(queryKey)) return; + try { + const result = await this.trackProviderStep(providerRunId, { + provider: "datapro", + operation: "company_evidence_query", + input_summary: `${item.label}:${item.purpose || company.name}`, + output_summary: `已完成 ${item.label} 查询。`, + }, () => this.dataProProvider.callTool(item.query)); + const summaries = dataProEvidenceSummaries(result); + if (result.ok && summaries.length) { + summaries.forEach((summary, index) => { + professional.push({ + label: summaries.length > 1 ? `${item.label} · 记录 ${index + 1}` : item.label, + source_group: item.label, + source_key: `${item.label}:${result.raw_ref || item.query}:${index + 1}`, + summary, + raw_ref: result.raw_ref || "", + query: item.query, + purpose: item.purpose || "", + }); + }); + completedQueryKeys.add(queryKey); + } else if (!result.ok) { + professionalFailures.push(result.error || {}); + issues.push(`专业数据集暂时不可用:${result.error?.message || result.error?.code || "provider_error"}`); + } else { + issues.push(`${item.label}调用成功,但没有返回可展示的业务字段。`); + completedQueryKeys.add(queryKey); + } + } catch (error) { + professionalFailures.push(error); + issues.push(`专业数据集暂时不可用:${error.message}`); + } + const current = dataProCompletedCount(); + await persistCollection({ + stage: "collecting_professional", + progress: Math.round(10 + (current / Math.max(1, dataProQueries.length)) * 18), + detail: { + current, + total: dataProQueries.length, + message: `正在核验专业资料 ${current}/${dataProQueries.length}`, + }, + }); + }); + await checkpointWrite; + } else { + await this.skipProviderStep(providerRunId, { + provider: "datapro", + operation: "collect_professional_evidence", + input_summary: `为 ${company.name} 获取专业资料`, + output_summary: "DataPro 未启用。", + error: { code: "provider_disabled", message: "DataPro is not enabled." }, + }); + } + + if (this.webSearchProvider?.isRunEnabled?.()) { + const seenPublicSources = new Set( + publicSources.map((source) => source.url || source.label).filter(Boolean), + ); + const authoritativeHosts = new Map(); + const publicQueryKeys = new Set( + [...completedQueryKeys].filter((key) => key.startsWith("web_search:")), + ); + const searchName = preferredCompanySearchName(company); + const currentYear = new Date().getFullYear(); + const webQueries = [...new Map([ + { + purpose: "法定主体近期公告与招采事项", + query: `${company.name} ${currentYear} 招标 采购 中标 公告`, + }, + { + purpose: "法定主体监管、司法与经营风险补充核验", + query: `${company.name} ${currentYear} 行政处罚 司法诉讼 失信被执行 经营异常 监管 召回 官方`, + }, + { + purpose: "法定主体官方公告与投资者信息", + query: `${company.name} ${currentYear} 官网 公告 年报 投资者关系`, + }, + { + purpose: "品牌或简称相关的最新项目与合作", + query: `${searchName} ${currentYear} 最新公告 项目 合作`, + }, + { + purpose: "品牌或简称相关的产能、供应链与业务变化", + query: `${searchName} ${currentYear} 产能 供应链 业务动态`, + }, + ].map((item) => [item.query, item])).values()]; + const maxPublicSources = 18; + const rememberAuthoritativeHost = (candidate) => { + const host = publicSourceHostname(candidate.url); + const authorityLevel = Number(candidate.auth_level); + if ( + !host + || !Number.isFinite(authorityLevel) + || authorityLevel < 2 + || !dossierTextMentionsCompany( + `${candidate.site_name} ${candidate.label} ${candidate.summary}`, + company, + ) + ) return; + const score = authorityLevel * 10 + + (dossierTextMentionsCompany(candidate.site_name, company) ? 12 : 0) + + (/\.cn$/i.test(host) ? 4 : 0) + + (/\/(?:news|press|stories|company)\b/i.test(candidate.url) ? 3 : 0); + authoritativeHosts.set(host, Math.max(score, authoritativeHosts.get(host) || 0)); + }; + publicSources.forEach(rememberAuthoritativeHost); + const registerPublicQueries = (queries) => { + queries.forEach((queryItem) => { + publicQueryKeys.add(workflowQueryKey("web_search", queryItem.query)); + }); + }; + const publicCompletedCount = () => [...publicQueryKeys] + .filter((key) => completedQueryKeys.has(key)).length; + + const runPublicQuery = async (queryItem) => { + const queryKey = workflowQueryKey("web_search", queryItem.query); + if (completedQueryKeys.has(queryKey)) return; + try { + const result = await this.trackProviderStep(providerRunId, { + provider: "web_search", + operation: "public_evidence_query", + input_summary: `检索 ${company.name} 的${queryItem.purpose}`, + output_summary: "已完成公开信息检索。", + }, () => this.webSearchProvider.search({ + query: queryItem.query.slice(0, 100), + count: 3, + need_summary: true, + query_rewrite: true, + auth_level: 1, + })); + if (!result.ok) { + publicFailures.push(result.error || {}); + issues.push(`联网搜索暂时不可用:${result.error?.code || "provider_error"}`); + return; + } + for (const searchResult of result.results || []) { + const key = searchResult.url || searchResult.title; + const summary = cleanEvidenceSummary(searchResult.summary || searchResult.snippet, "", 1600); + if (!key || seenPublicSources.has(key) || !summary) continue; + const candidate = { + label: searchResult.title || searchResult.url || `${company.name} 公开来源`, + summary, + url: searchResult.url || "", + published_at: searchResult.publish_time || null, + site_name: searchResult.site_name || "", + auth_description: searchResult.auth_description || "", + auth_level: searchResult.auth_level ?? null, + rank_score: searchResult.rank_score ?? null, + query: queryItem.query, + purpose: queryItem.purpose, + }; + rememberAuthoritativeHost(candidate); + if (isLowValuePublicDossierSource(candidate, concisePublicPoint(candidate))) continue; + if (!isDisplayableDossierCitation({ ...candidate, source_kind: "联网搜索" }, company)) { + continue; + } + seenPublicSources.add(key); + publicSources.push(candidate); + if (publicSources.length >= maxPublicSources) break; + } + completedQueryKeys.add(queryKey); + } catch (error) { + publicFailures.push(error); + issues.push(`联网搜索暂时不可用:${error.message}`); + } + const current = publicCompletedCount(); + const total = Math.max(1, publicQueryKeys.size); + await persistCollection({ + stage: "collecting_public", + progress: Math.round(30 + (current / total) * 18), + detail: { + current, + total, + message: `正在检索公开资料 ${current}/${total}`, + }, + }); + }; + const runPublicBatch = async (queries) => { + const unique = [...new Map(queries.map((item) => [item.query, item])).values()]; + registerPublicQueries(unique); + await mapWithConcurrency(unique, this.dossierWebConcurrency, runPublicQuery); + await checkpointWrite; + }; + + await reportProgress("collecting_public", 30); + await runPublicBatch(webQueries); + + const hasRecentPublicEvidence = () => publicSources.some((source) => { + const citation = { ...source, source_kind: "联网搜索" }; + const point = concisePublicPoint(citation); + return isDisplayableDossierCitation(citation, company) + && isRecentPublicDossierCitation(citation, point, company); + }); + if (!hasRecentPublicEvidence() && authoritativeHosts.size) { + const officialHosts = [...authoritativeHosts.entries()] + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .slice(0, 2) + .map(([host]) => host); + const officialFollowups = officialHosts.flatMap((host) => [ + { + purpose: `权威站点 ${host} 的新闻、公告与合作`, + query: `site:${host} ${searchName} ${currentYear} 新闻 公告 合作 项目`, + }, + { + purpose: `权威站点 ${host} 的投资、产能与供应链变化`, + query: `site:${host} ${searchName} 投资 产能 供应链 业务`, + }, + ]).slice(0, 3); + if (publicSources.length < maxPublicSources && !hasRecentPublicEvidence()) { + await runPublicBatch(officialFollowups.slice(0, 2)); + } + } + + const coverage = assessDossierEvidenceCoverage(company, { + professional, + public_sources: publicSources, + }); + const coverageFollowups = []; + const addCoverageFollowup = (topic, purpose, query) => { + if (coverage.missing_topics.includes(topic)) { + coverageFollowups.push({ purpose, query }); + } + }; + addCoverageFollowup( + "recent_public", + "近期官方公告、项目与合作事件", + `${company.name} ${currentYear} 官方公告 项目 合作 投资`, + ); + addCoverageFollowup( + "operations", + "经营、产品、产能与供应链变化", + `${searchName} ${currentYear} 产品 产能 交付 供应链 业务`, + ); + addCoverageFollowup( + "risk", + "监管、司法、召回与经营风险", + `${company.name} ${currentYear} 监管 处罚 诉讼 召回 经营异常`, + ); + addCoverageFollowup( + "procurement_or_project", + "招采、中标与项目落地信号", + `${company.name} ${currentYear} 招标 采购 中标 项目 供应商`, + ); + addCoverageFollowup( + "source_diversity", + "不同权威公开渠道的企业动态", + `${searchName} ${currentYear} 政府 公告 行业协会 项目 新闻`, + ); + const boundedCoverageFollowups = [...new Map( + coverageFollowups.map((item) => [item.query, item]), + ).values()].slice(0, 4); + if (publicSources.length < maxPublicSources && boundedCoverageFollowups.length) { + await runPublicBatch(boundedCoverageFollowups); + } + } else { + await this.skipProviderStep(providerRunId, { + provider: "web_search", + operation: "collect_public_evidence", + input_summary: `为 ${company.name} 获取最新公开信息`, + output_summary: "联网搜索未启用。", + error: { code: "provider_disabled", message: "Web search is not enabled." }, + }); + } + + if (this.runtimePolicy.fail_closed && !professional.length) { + throw providerUnavailable("datapro", "No verified professional evidence was returned for the dossier.", { + issues, + ...providerFailureDetails(professionalFailures), + }); + } + if (this.runtimePolicy.fail_closed && !publicSources.length && publicFailures.length) { + throw providerUnavailable("web_search", "No verified public evidence was returned for the dossier.", { + issues, + ...providerFailureDetails(publicFailures), + }); + } + + await persistCollection({ + stage: "building_evidence", + progress: 48, + detail: { message: "正在整理可信资料" }, + }); + await checkpointWrite; + return { + professional: professional.slice(0, 30), + public_sources: publicSources.slice(0, 18), + issues, + coverage: assessDossierEvidenceCoverage(company, { + professional, + public_sources: publicSources, + }), + }; + } + + async searchOpenViking(company, query) { + if (!this.openVikingProvider?.isConfigured?.()) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking is not configured for retrieval."); + } + return []; + } + try { + const result = await this.openVikingProvider.findMemories(query, { + limit: 8, + uri: this.openVikingMaterialsUri(company), + }); + if (!result.ok) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking retrieval failed.", { + reason: result.error?.code || "provider_error", + }); + } + return []; + } + const contexts = this.normalizeOpenVikingContexts(result.result, company); + return contexts; + } catch (error) { + if (this.runtimePolicy.fail_closed) { + if (error instanceof HttpError) throw error; + throw providerUnavailable("openviking", "OpenViking retrieval failed.", { + reason: error.message || "provider_error", + }); + } + return []; + } + } + + normalizeOpenVikingContexts(result, company) { + const items = [ + ...firstJsonArray(result?.memories), + ...firstJsonArray(result?.resources), + ...firstJsonArray(result?.skills), + ]; + const materials = (company.material_ids || []) + .map((id) => this.data.materials[id]) + .filter(Boolean) + .filter(isFeishuMaterial); + return items + .filter((item) => String(item.uri || "").includes("/materials/")) + .filter((item) => !isOpenVikingOverviewItem(item)) + .map((item) => { + const uri = item.uri || ""; + const canonicalUri = canonicalOpenVikingResourceUri(uri); + const material = materials.find((candidate) => { + const materialUri = canonicalOpenVikingResourceUri( + candidate.openviking_uri || candidate.openviking_ref, + ); + return materialUri + && (canonicalUri === materialUri || canonicalUri.startsWith(`${materialUri}/`)); + }); + if (!material) return null; + return { + uri, + material_id: material.id, + title: material.title || `${company.name} 飞书资料`, + source_kind: feishuMaterialSourceKind(material), + abstract: compactText(item.abstract || item.overview || item.text || "", 500), + score: item.score ?? null, + }; + }) + .filter((item) => item?.abstract) + .filter((context, index, contexts) => ( + contexts.findIndex((candidate) => ( + canonicalOpenVikingResourceUri(candidate.uri) + === canonicalOpenVikingResourceUri(context.uri) + )) === index + )) + .slice(0, 8); + } + + async hydrateOpenVikingContexts(company, contexts = []) { + const materials = new Map( + (company.material_ids || []) + .map((id) => this.data.materials[id]) + .filter(Boolean) + .map((material) => [material.id, material]), + ); + return Promise.all((contexts || []).map(async (context) => { + const material = materials.get(context.material_id); + let content = ""; + if (context.uri && typeof this.openVikingProvider?.readTextResource === "function") { + try { + const result = await this.openVikingProvider.readTextResource(context.uri); + if (result?.ok) content = normalizeImportedText(result.content).trim().slice(0, 30000); + } catch { + content = ""; + } + } + if (!content) { + content = normalizeImportedText( + material?.text + || material?.content + || material?.summary + || context.abstract, + ).trim().slice(0, 30000); + } + return { + ...context, + content, + source_updated_at: material?.updated_at || null, + }; + })); + } + + materialContexts(company) { + return (company.material_ids || []) + .map((id) => this.data.materials[id]) + .filter(Boolean) + .filter(isFeishuMaterial) + .slice(0, 5) + .map((material) => ({ + uri: material.openviking_uri || material.openviking_ref || "", + material_id: material.id, + title: material.title || `${company.name} 历史资料`, + source_kind: feishuMaterialSourceKind(material), + abstract: compactText(material.summary || material.text || `${material.title || "历史资料"} 已登记为 ${company.name} 的长期资料。`, 500), + score: null, + })); + } + + openVikingCompanyUri(company) { + if (typeof this.openVikingProvider?.salesCompanyUri !== "function") return ""; + return this.openVikingProvider.salesCompanyUri({ + workspaceId: this.workspaceId, + companyId: company.id, + }); + } + + openVikingMaterialsUri(company) { + const companyUri = this.openVikingCompanyUri(company); + return companyUri ? `${companyUri}/materials` : ""; + } + + async generateDossierWithModel(company, collected, memoryContexts, providerRunId = "") { + const evidencePack = Array.isArray(collected?.items) && collected?.evidence_hash + ? collected + : buildDossierEvidencePack({ + company, + collected, + memoryContexts: [], + generatedAt: nowIso(), + }); + const evidenceCompilation = compileDossierEvidenceAtoms({ + evidencePack: evidencePack.entity + ? evidencePack + : { + ...evidencePack, + entity: resolveCompanyEntity(company), + }, + }); + const citationInputs = this.buildCitationInputs(evidencePack, memoryContexts) + .filter((citation) => isDisplayableDossierCitation(citation, company)); + if (!citationInputs.length) { + await this.skipProviderStep(providerRunId, { + provider: "model", + operation: "generate_sales_dossier", + input_summary: `为 ${company.name} 生成最近档案`, + output_summary: "没有可引用证据,未调用模型。", + error: { code: "missing_sources", message: "No verified citations were available." }, + }); + return null; + } + if (!this.modelProvider?.isRunEnabled?.()) { + await this.skipProviderStep(providerRunId, { + provider: "model", + operation: "generate_sales_dossier", + input_summary: `基于 ${citationInputs.length} 条证据生成 ${company.name} 最近档案`, + output_summary: "模型 Provider 未启用。", + error: { code: "provider_disabled", message: "Model provider is not enabled." }, + }); + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("model", "The model provider is not enabled."); + } + return null; + } + const evidenceGroundingErrors = dossierGroundingErrors(citationInputs, null, company); + if (evidenceGroundingErrors.length) { + if (this.runtimePolicy.fail_closed) { + throw new HttpError(422, "evidence_quality_insufficient", "可展示来源不足以生成正式销售档案。", { + validation_errors: evidenceGroundingErrors, + }); + } + } + const sourcePolicy = dossierSectionSourcePolicy(citationInputs, company); + const targetName = String(company.name || company.legal_name || "").trim(); + const sourceSelectionPolicy = { + business_database_ids: [...sourcePolicy.business], + business_dynamics_ids: [...sourcePolicy.businessDynamics], + risk_database_ids: [...sourcePolicy.risk], + market_database_ids: [...sourcePolicy.market], + professional_dataset_ids: [...sourcePolicy.professional], + web_search_ids: [...sourcePolicy.web], + excluded_entity_citation_ids: citationInputs + .map((citation) => ({ citation, record: dossierBusinessEntityRecord(citation) })) + .filter(({ record }) => ( + record + && normalizeLegalEntityName(record.name) + !== normalizeLegalEntityName(targetName) + )) + .map(({ citation }) => String(citation.id)), + }; + const agentContext = buildDossierAgentContext({ + citations: citationInputs, + evidencePolicy: evidencePack.policy || null, + evidenceConflicts: evidencePack.conflicts || [], + sourceSelectionPolicy, + evidenceAtoms: evidenceCompilation.atoms, + evidenceCoverage: evidenceCompilation.coverage, + }); + const modelInstructions = [ + "你是销售情报平台中负责企业档案生成的受约束 Agent。", + "只能基于每章 allowed_evidence 中的 Evidence Atom 生成,不要补编任何事实。", + "本报告只能使用专业数据集和豆包搜索(联网搜索)两类外部来源;不得使用飞书资料、OpenViking 记忆或历史问答。", + "专业数据集可能来自企业工商、企业风险、金融、汽车或科研学术等不同数据库;必须按章节选用语义匹配的来源,不得把所有专业数据都当作工商信息。", + "专业数据集能够覆盖的主体、风险、财务、销量或科研事实,必须优先使用对应专业数据库;豆包搜索只补充近期公告、新闻、项目合作及专业库未覆盖的时效信息。", + "风险与关注事项应优先使用本章 allowed_evidence 中的专业或官方 Atom;公开网页只用于交叉核验或补充公开动态。", + "风险章节只能写来源直接披露的风险事实,或把明确事实改写为需要核验的具体事项;不得从单个项目、单笔金额或少量公告外推企业整体的订单结构、客户结构、收入结构、业务能力、回款状况或长期趋势。", + ...((sourcePolicy.risk || new Set()).size ? [] : [ + "当前没有通过主体和内容门禁的专业风险数据库来源。风险与关注事项章不得写具体诉讼、处罚、失信、营收、利润、融资或估值结论;只能把 allowed_evidence 中已核验的主体或经营事实改写为具体的对接前核验事项,不得声称对方已存在该风险。", + ]), + "企业与业务概览应优先使用能够锚定法定主体的专业 Atom。", + "经营与业务动态应优先使用本章 allowed_evidence 中语义匹配的专业经营、市场或科研 Atom。", + "输出必须是固定六章节报告,且严格按顺序使用标题:企业与业务概览、经营与业务动态、近期公开动态、风险与关注事项、销售机会判断、建议行动。", + "这是一份供销售人员使用的完整企业情报报告,不是接口执行摘要。每章固定生成一个完整段落,段落可以包含 1-3 个紧密相关的完整句子,并且必须包含有信息量的业务表述,不得只写“已返回数据”“可用于核验”“建议继续关注”等空泛模板句。", + "正文只呈现企业事实、事件、影响、销售判断和行动,不得向用户解释检索过程、证据校验过程或数据源之间的差异。", + "禁止在正文中出现“本次未检索到”“本次没有返回”“资料不足”“资料缺口”“来源冲突”“来源不一致”“来源存在差异”“关键字段存在来源差异”“冲突字段”“来源等级不足”等内部诊断话术。", + "不得照抄搜索结果中的站点导航、作者日期前缀、注册引导、广告文字或被截断的摘要;每个事实句必须语义完整,括号和引号必须闭合,财务、产能和市占率数字必须带完整单位与上下文。", + "某一章节的专业数据不足时,只能从该章 allowed_evidence 中选择语义匹配的公开事实补充;仍无可靠事实时不得编造或用检索状态、空泛模板凑成章节。", + "Evidence Atom 的 entity_match=alias_scoped 表示来源只匹配品牌或简称。可以作为品牌、集团或相关业务动态写入,但必须明确主体边界,不得把它表述成输入法定主体已经发生的确定事实。", + "企业与业务概览用于交代主体、主营方向、业务定位和来源能够直接支持的业务应用场景,不要罗列内部字段名,也不得在本章写采购场景、采购需求、采购计划或采购意向。", + "静态的登记经营范围只能写成“经营范围包括”或“登记业务覆盖”,不得写成“延伸至”“扩展至”“布局扩展”等时序变化,也不得写成“主营”“同时承担”“形成业务定位”“已具备现实能力”或“制造基地法定主体”。", + "销售机会判断可以把登记范围作为待确认的对接方向,但必须明确不代表现实业务、采购意向或预算。建议行动不得根据注册地址虚构已存在的厂区采购窗口或技术部门,应先确认负责相关业务的联系人。", + "企业工商数据包含总公司、分公司或子公司记录时,成立日期、注册地址、注册号、统一社会信用代码和法定代表人必须绑定到公司名称完全一致的那条记录;不得把分支机构字段写成目标法定主体字段。只有正文逐字点名分支机构完整名称时,才允许引用该分支机构记录并描述其自身字段。", + "正文提到任何分公司或子公司时,本章必须选择该分支机构自己的工商 Evidence Atom;如果本章没有该记录,就删除分支机构名称和相关断言,不得根据总公司记录补写分支布局、区域覆盖或市场承载能力。", + "经营与业务动态只能写可由来源证明的经营变化、项目、合作、产能、供应链或业务动作;不得复制注册信息或描述检索结果来凑字数。", + "若来源只是少量中标、成交或公告记录,只能逐项陈述这些项目,不得据此写企业整体已从某类业务扩展、转向或升级到另一类业务,也不得声称整体能力、市场或产品结构已经改变。", + "近期公开动态应优先写清日期、事件、合作方或项目,以及该事件为何值得销售关注;不得只复述搜索标题。", + "近期公开动态只陈述来源披露的事件与直接影响;不得把中标密度、公告节奏或框架入围写成对方采购需求、采购意向、预算或资源需求正在形成或活跃。此类内容只能在销售机会判断中作为明确标注的保守推断。", + "同一个事实、事件或数字只能出现在一个最匹配的章节。经营与业务动态写业务变化,近期公开动态写有日期的公开事件,风险与关注事项写风险影响;不得在不同章节复制或轻微改写同一段来源内容。", + "销售机会判断必须从已经引用的业务动作推导具体切入场景和时机,同时明确这只是机会判断,不能写成对方已有采购意向。", + "建议行动必须具体到拟联系的部门或角色、需要核验的问题、可准备的材料和下一步动作,列出 1-2 项,信息量由证据决定,避免通用销售套话。", + "每个自然段和每条编号行动都必须使用完整句子,并以句号、问号或感叹号结束;不得以逗号、冒号或分号收尾。", + "不得展示企业内部主键、关联主键、trace id、request id、record id、接口名、Provider 名或原始响应字段。", + "建议行动要具体到需要核验的对象、事项或销售动作;不得用内部客户沟通内容补齐外部事实。", + "模型每章只提交 text 和 evidence_ids;quote、citation_id、URL、segment、citation_ids 与最终引用全部由服务端根据 Evidence Atom 确定性派生。", + "只引用与正文事实直接相关的来源,不得为了增加引用数量而加入弱相关或重复来源;来源数量本身不是生成目标。", + "source_usage_requirements 只描述当前可用来源,不设置整份报告引用数量门槛。证据充足时优先选择与各事实直接相关的独立来源;证据确实较少时应缩短报告,不得补编或凑引用。", + "专业数据没有 URL 时也可以作为引用来源,但不能伪造链接。", + "source_quality_label 表示来源等级,freshness_label 表示时效;过期资料和日期未知的公开来源不得表述为最新事实。", + "注册资本、营收、净利润、融资、估值及明确的司法/处罚/失信事实属于高风险事实,至少引用两个独立外部来源,且至少一个必须是专业或官方来源。", + "关键数字只有在两个独立来源返回同一数值时才可写成确定事实;若 evidence_conflicts 标记冲突,必须静默省略该数字,改写为其他有一致证据支持的实质事实,不得列出多个口径,也不得向前端解释冲突。", + ]; + const validateGeneratedDossier = (answer) => { + const normalizedAnswer = { + ...(answer || {}), + body: firstJsonArray(answer?.body).map((item, index) => { + const title = DOSSIER_SECTION_TITLES[index]; + return { + ...item, + text: title + ? cleanDossierBodyText(item?.text, title, "", 1400) + : String(item?.text || ""), + }; + }), + }; + const validatedAnswer = validateDossierModelAnswer(normalizedAnswer, citationInputs); + return { + ...validatedAnswer, + errors: [ + ...validatedAnswer.errors, + ...dossierSectionSourceErrors(validatedAnswer.body, citationInputs, company), + ...dossierSectionContentErrors(validatedAnswer.body), + ...dossierSectionSemanticErrors(validatedAnswer.body, citationInputs, company), + ...dossierSectionEvidenceGroundingErrors(validatedAnswer.body, citationInputs), + ...dossierSourceUsageErrors( + validatedAnswer.body.flatMap((paragraph) => paragraph.citation_ids || []), + agentContext.citations, + agentContext.sourceUsageRequirements, + ), + ...(String(answer?.summary || "").length > 160 ? ["档案摘要超过 160 个字符"] : []), + ...(String(answer?.memory_summary || "").length > 200 ? ["记忆摘要超过 200 个字符"] : []), + ], + }; + }; + const agent = new DossierAgent({ + maxCalls: Number(this.env.value("DOSSIER_AGENT_MAX_CALLS", "3")) || 3, + validate: validateGeneratedDossier, + callModel: async (request) => { + if (typeof this.modelProvider.callRequiredFunction !== "function") { + throw new Error("The model provider does not implement strict Function Calling."); + } + return this.trackProviderStep(providerRunId, { + provider: "model", + operation: request.operation, + input_summary: request.operation === "sales_dossier_agent_plan" + ? `基于 ${citationInputs.length} 条已核验证据规划 ${company.name} 的六章节报告` + : `根据质量门禁反馈修订 ${company.name} 的六章节报告规划`, + output_summary: request.operation === "sales_dossier_agent_plan" + ? "档案 Agent 已提交六章节事实、判断、行动与逐项引用规划。" + : "档案 Agent 已提交修订后的六章节规划。", + }, () => this.modelProvider.callRequiredFunction(request)); + }, + }); + try { + const agentResult = await agent.run({ + company: { + name: company.name, + industry: company.industry, + location: company.location, + }, + citations: citationInputs, + evidenceAtoms: evidenceCompilation.atoms, + evidenceCoverage: evidenceCompilation.coverage, + evidencePolicy: evidencePack.policy || null, + evidenceConflicts: evidencePack.conflicts || [], + sourceSelectionPolicy, + instructions: modelInstructions, + }); + if (!agentResult.ok) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("model", "The dossier Agent did not produce a valid result.", { + reason: agentResult.result?.error?.code || "dossier_quality_gate_failed", + validation_errors: agentResult.validation_errors, + }); + } + return null; + } + const normalizedDossier = this.normalizeModelDossier( + company, + agentResult.submission, + citationInputs, + agentResult.result?.raw_ref, + ); + if (!normalizedDossier && this.runtimePolicy.fail_closed) { + throw providerUnavailable("model", "The dossier Agent result failed final display validation.", { + reason: "dossier_quality_gate_failed", + validation_errors: ["档案在最终结构化与展示清洗后不再满足六章节、有效引用和正文质量要求。"], + }); + } + if (normalizedDossier) { + const publicView = this.publicDossier(normalizedDossier); + const publicViewErrors = this.publicDossierQualityErrors(publicView, company); + if (publicViewErrors.length) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("model", "The dossier Agent result failed the final public-view quality gate.", { + reason: "public_dossier_quality_gate_failed", + validation_errors: publicViewErrors, + }); + } + } + } + return normalizedDossier; + } catch (error) { + if (this.runtimePolicy.fail_closed) { + if (error instanceof HttpError) throw error; + throw providerUnavailable("model", "Dossier generation failed.", { + reason: error.message || "provider_error", + }); + } + return null; + } + } + + buildCitationInputs(collected, memoryContexts) { + if (Array.isArray(collected?.items) && collected?.evidence_hash) { + return normalizeDossierCitationSemantics(evidencePackCitations(collected) + .filter((citation) => /专业数据集|联网搜索/.test(citation.source_kind)) + .filter((citation) => cleanEvidenceSummary(citation.summary))); + } + const citations = []; + for (const source of collected.professional || []) { + citations.push({ + id: String(citations.length + 1), + label: source.label, + source_kind: "专业数据集", + url: "", + summary: source.summary, + provider_mode: source.provider_mode || "", + raw_ref: source.raw_ref || "", + query: source.query || "", + purpose: source.purpose || "", + published_at: source.published_at || null, + site_name: source.site_name || "", + auth_description: source.auth_description || "", + auth_level: source.auth_level ?? null, + rank_score: source.rank_score ?? null, + }); + } + for (const source of collected.public_sources || []) { + citations.push({ + id: String(citations.length + 1), + label: source.label, + source_kind: "联网搜索", + url: source.url || "", + summary: source.summary, + provider_mode: source.provider_mode || "", + raw_ref: source.raw_ref || "", + query: source.query || "", + purpose: source.purpose || "", + }); + } + return normalizeDossierCitationSemantics(citations.filter((citation) => ( + /专业数据集|联网搜索/.test(citation.source_kind) + && cleanEvidenceSummary(citation.summary) + ))); + } + + normalizeModelDossier(company, parsed, citationInputs, rawRef) { + const allowed = new Map(citationInputs.map((item) => [String(item.id), item])); + const body = firstJsonArray(parsed?.body) + .slice(0, DOSSIER_SECTION_TITLES.length) + .map((item, index) => { + const title = DOSSIER_SECTION_TITLES[index]; + const segments = firstJsonArray(item.segments) + .map((segment) => ({ + text: ensureDossierLinePunctuation( + normalizeImportedText(normalizeSalesText(segment.text)) + .replace(/\s+/g, " ") + .trim(), + ), + citation_ids: firstJsonArray(segment.citation_ids) + .map(String) + .filter((id) => ( + allowed.has(id) + && /专业数据集|联网搜索/.test(allowed.get(id)?.source_kind || "") + )), + })) + .filter((segment) => ( + segment.text + && segment.citation_ids.length + && !hasBadDisplayText(segment.text) + && !hasDossierInternalMetaText(segment.text) + )); + const text = cleanDossierBodyText( + segments.length + ? `${title}:${segments.map((segment) => segment.text).join("\n\n")}` + : item.text, + title, + "", + 1400, + ); + return { + text, + citation_ids: [...new Set(segments.length + ? segments.flatMap((segment) => segment.citation_ids) + : firstJsonArray(item.citation_ids).map(String).filter((id) => allowed.has(id)))], + segments, + }; + }); + if (body.length !== DOSSIER_SECTION_TITLES.length) return null; + if (body.some((item) => ( + !item.text + || !item.citation_ids.length + || !item.segments.length + || hasBadDisplayText(item.text) + || hasDossierInternalMetaText(item.text) + ))) return null; + const validatedBody = validateDossierModelAnswer({ body }, citationInputs); + const structuredBody = validatedBody.body; + const finalErrors = [ + ...validatedBody.errors, + ...dossierSectionSourceErrors(structuredBody, citationInputs, company), + ...dossierSectionContentErrors(structuredBody), + ...dossierSectionSemanticErrors(structuredBody, citationInputs, company), + ...dossierSectionEvidenceGroundingErrors(structuredBody, citationInputs), + ]; + if (finalErrors.length) return null; + const structuredSummary = [ + structuredBody.find((item) => item.text.startsWith("近期公开动态:"))?.text.replace(/^近期公开动态:/, ""), + structuredBody.find((item) => item.text.startsWith("销售机会判断:"))?.text.replace(/^销售机会判断:/, ""), + ].filter(Boolean).join(" "); + const parsedSummary = cleanEvidenceSummary(parsed.summary, "", 300); + const now = nowIso(); + return { + id: makeId("dossier"), + company_id: company.id, + title: `${company.name} 销售情报报告`, + summary: compactCompleteSentences( + isSubstantiveDossierSummary(structuredSummary) ? structuredSummary : parsedSummary, + 300, + ), + created_at: now, + body: structuredBody, + citations: citationInputs, + memory_summary: cleanEvidenceSummary(parsed.memory_summary, structuredBody.map((item) => item.text).join(" "), 600), + raw_ref: rawRef || null, + }; + } + + buildRuleDossier(company, collected, memoryContexts) { + const citations = this.buildCitationInputs(collected, memoryContexts); + if (!citations.length) { + const now = nowIso(); + const body = [ + { text: "企业与业务概览:暂未从专业数据集获取到可引用的企业信息。", citation_ids: [] }, + { text: "经营与业务动态:当前没有足够的专业数据支撑经营与业务变化判断。", citation_ids: [] }, + { text: "近期公开动态:暂未从豆包搜索获取到带日期和原始链接的近期公开信息。", citation_ids: [] }, + { text: "风险与关注事项:资料不足,当前不输出确定的风险结论。", citation_ids: [] }, + { text: "销售机会判断:资料不足,当前不推断采购意向、预算或销售阶段。", citation_ids: [] }, + { text: "建议行动:请稍后重新获取报告,并确认专业数据集和豆包搜索可正常返回来源。", citation_ids: [] }, + ]; + return { + id: makeId("dossier"), + company_id: company.id, + title: `${company.name} 销售情报报告`, + summary: "暂未获取到可引用的新变化。", + created_at: now, + body, + citations: [], + memory_summary: `${company.name} 销售情报报告暂未获取到可引用的新变化。`, + raw_ref: null, + }; + } + const now = nowIso(); + const body = this.reportDossierBody(company, citations); + return { + id: makeId("dossier"), + company_id: company.id, + title: `${company.name} 销售情报报告`, + summary: compactCompleteSentences(body.find((item) => item.text.startsWith("近期公开动态:"))?.text.replace(/^近期公开动态:/, "") || body[0].text.replace(/^企业与业务概览:/, ""), 300), + created_at: now, + body, + citations, + memory_summary: compactText(`${company.name} 销售情报报告已更新:${body.map((item) => item.text).join(" ")}`, 600), + raw_ref: null, + }; + } + + reportDossierBody(company, citations, preferredBody = []) { + const externalCitations = citations.filter((item) => /专业数据集|联网搜索/.test(item.source_kind || "")); + const allowedIds = new Set(externalCitations.map((item) => String(item.id))); + const preferred = preferredBody + .slice(0, DOSSIER_SECTION_TITLES.length) + .map((item, index) => ({ + text: cleanDossierBodyText(item.text, DOSSIER_SECTION_TITLES[index], "", 1400), + citation_ids: firstJsonArray(item.citation_ids).map(String).filter((id) => allowedIds.has(id)), + })) + .filter((item) => ( + item.text + && item.citation_ids.length + && !hasBadDisplayText(item.text) + && !hasDossierInternalMetaText(item.text) + )); + const completePreferred = DOSSIER_SECTION_TITLES.every((title, index) => ( + preferred[index]?.text.startsWith(`${title}:`) + )) + && dossierSectionSourceErrors(preferred, externalCitations, company).length === 0 + && dossierSectionContentErrors(preferred).length === 0 + && dossierSectionSemanticErrors(preferred, externalCitations, company).length === 0 + && dossierSectionEvidenceGroundingErrors(preferred, externalCitations).length === 0; + if (completePreferred) return preferred.slice(0, DOSSIER_SECTION_TITLES.length); + + const professional = externalCitations.filter((item) => item.source_kind === "专业数据集"); + const publicSources = externalCitations.filter((item) => item.source_kind === "联网搜索"); + const uniqueEvidence = (items) => items.filter((item, index, values) => { + const identity = String(item.point || "") + .toLowerCase() + .replace(/[\s,。;:!?、,.!?;:'"“”‘’()()【】[\]《》<>-]/gu, ""); + return identity && values.findIndex((candidate) => ( + String(candidate.point || "") + .toLowerCase() + .replace(/[\s,。;:!?、,.!?;:'"“”‘’()()【】[\]《》<>-]/gu, "") + === identity + )) === index; + }); + const targetEntityKey = normalizeLegalEntityName(company.name || company.legal_name || ""); + const professionalEvidence = uniqueEvidence(professional + .map((source) => ({ + source, + point: safeDeterministicDossierPoint(conciseProfessionalPoint(source, company.name)), + })) + .filter((item) => ( + item.point + && !isLowValueProfessionalPoint(item.point) + && isSubstantiveDossierEvidencePoint(item.point) + && (() => { + const record = dossierBusinessEntityRecord(item.source); + return !record || normalizeLegalEntityName(record.name) === targetEntityKey; + })() + ))); + const reportSourcePolicy = dossierSectionSourcePolicy(externalCitations, company); + const companyEvidence = professionalEvidence.filter((item) => { + const record = dossierBusinessEntityRecord(item.source); + return Boolean(record && normalizeLegalEntityName(record.name) === targetEntityKey); + }); + const marketEvidence = professionalEvidence.filter((item) => ( + !dossierBusinessEntityRecord(item.source) + && ( + reportSourcePolicy.market.has(String(item.source.id)) + || /金融数据库|汽车销量数据库|科研学术数据搜索服务/.test(String(item.source.label || "")) + || /经营|市场|技术|产能|销量|科研|专利/.test(`${item.source.purpose || ""} ${item.source.query || ""}`) + ) + )); + const selectedCompanyEvidence = (companyEvidence.length ? companyEvidence : professionalEvidence).slice(0, 2); + const selectedMarketEvidence = marketEvidence + .filter((item) => !selectedCompanyEvidence.some((candidate) => candidate.source.id === item.source.id)) + .slice(0, 2); + const professionalIds = professionalEvidence.map((item) => String(item.source.id)); + const companyIds = selectedCompanyEvidence.map((item) => String(item.source.id)); + const companyPoints = selectedCompanyEvidence.map((item) => item.point); + const publicEvidence = uniqueEvidence(publicSources + .map((source) => ({ + source, + point: safeDeterministicDossierPoint(concisePublicPoint(source)), + })) + .filter((item) => ( + item.point + && !isLowValuePublicDossierSource(item.source, item.point) + && isPublicCitationRelevantToCompany(item.source, item.point, company) + )) + .sort((a, b) => ( + publicDossierEvidenceScore(b.source, b.point, company) + - publicDossierEvidenceScore(a.source, a.point, company) + ))); + const publicIds = publicEvidence.map((item) => String(item.source.id)); + const allIds = [...new Set([...professionalIds, ...publicIds])]; + const professionalRiskEvidence = professionalEvidence + .filter((item) => ( + item.point + && !dossierBusinessEntityRecord(item.source) + && ( + DOSSIER_RISK_TERMS.test(`${item.source.label || ""} ${item.source.purpose || ""} ${item.source.query || ""}`) + || DOSSIER_RISK_TERMS.test(item.point) + ) + )); + const publicRiskEvidence = publicEvidence.filter((item) => ( + isPublicRiskEvidenceForCompany(item.source, item.point, company) + )); + const selectedRiskEvidence = ( + professionalRiskEvidence.length + ? professionalRiskEvidence + : publicRiskEvidence + ).slice(0, 2); + const selectedRiskSourceIds = new Set( + selectedRiskEvidence.map((item) => String(item.source.id)), + ); + const publicBusinessEvidence = publicEvidence + .filter((item) => !selectedRiskSourceIds.has(String(item.source.id))) + .filter((item) => DOSSIER_ACTION_TERMS.test(`${item.point} ${item.source.label || ""}`)) + .slice(0, 1); + const selectedBusinessEvidence = selectedMarketEvidence.length + ? selectedMarketEvidence + : publicBusinessEvidence; + const selectedBusinessSourceIds = new Set( + selectedBusinessEvidence.map((item) => String(item.source.id)), + ); + const recentEvidence = publicEvidence + .filter((item) => !selectedRiskSourceIds.has(String(item.source.id))) + .filter((item) => !selectedBusinessSourceIds.has(String(item.source.id))) + .filter((item) => isRecentPublicDossierCitation(item.source, item.point, company)) + .slice(0, 3); + const riskIds = selectedRiskEvidence.map((item) => String(item.source.id)); + const businessPoints = selectedBusinessEvidence.map((item) => item.point); + const recentPoints = recentEvidence.map((item) => item.point); + const evidencePoints = [ + ...companyPoints, + ...businessPoints, + ...recentPoints, + ...selectedRiskEvidence.map((item) => item.point), + ]; + const themes = dossierSalesThemes(evidencePoints, company); + const themeText = themes.join("、"); + const professionalFallback = professionalEvidence.slice(0, 2).map((item) => item.point); + const companyFactText = (companyPoints.length ? companyPoints : professionalFallback).join(";"); + const companyText = companyFactText + ? ( + companyFactText.length >= 24 + ? companyFactText + : `${companyFactText}。该主体的业务定位集中于${themeText}相关产品与服务。` + ) + : `${company.name}的专业数据记录已完成主体匹配,业务跟进可从${themeText}展开。`; + const businessTextValue = businessPoints.length + ? `${businessPoints.join(";")}。上述业务动作指向${themeText}相关的经营与技术方向,销售团队可据此确认当前产品线、项目节奏和采购责任部门。` + : publicEvidence.length + ? `近期公开业务信息主要涉及${themeText}。经营跟进应进一步确认对应业务部门、实施阶段、合作对象和采购责任链。` + : `专业数据所示业务范围集中在${themeText}。经营跟进应围绕当前产品线、重点项目、交付安排和采购组织核实实际变化。`; + const timelineEvidence = publicEvidence.find((item) => ( + !selectedRiskSourceIds.has(String(item.source.id)) + && isRecentPublicDossierCitation(item.source, item.point, company) + )) || publicEvidence[0]; + const timelineDate = String(timelineEvidence?.source?.published_at || "").slice(0, 10); + const timelinePrefix = timelineDate ? `截至${timelineDate},` : "根据近期公开信息,"; + const recentText = recentPoints.length + ? recentPoints.join(";") + : `${timelinePrefix}${company.name}的公开业务动向主要涉及${themeText}。后续应持续跟踪相关事项的正式公告、项目落地、合作方和采购进展。`; + const riskText = selectedRiskEvidence.length + ? `${selectedRiskEvidence.map((item) => item.point).join(";")}。商务推进应进一步确认相关事项对准入、合同责任、供应保障和交付排期的影响边界。` + : `结合已核验的主体信息和近期公开事项,商务推进应把供应商准入、数据合规、合同责任、供应保障和交付排期作为前置核验项,避免在责任边界未确认前作出方案或时间承诺。`; + const opportunityText = `基于现有专业数据和近期公开事项,可优先验证${themeText}相关的采购、技术协同或项目交付场景。首轮沟通应确认牵头部门、预算窗口和决策链,再判断线索优先级;这属于销售机会判断,不代表对方已经形成采购意向。`; + const actionText = [ + `1. 围绕${themeText}确认牵头业务部门、采购负责人和最终决策角色。`, + "2. 针对近期公开事项逐项核验项目阶段、时间表、采购范围和预算来源。", + `3. 准备与${themeText}匹配的产品方案、客户案例、交付边界和验收指标。`, + "4. 在进入商务报价前确认供应商准入、数据合规、合同责任和实施风险。", + ].join("\n"); + const fallbackIds = allIds.length ? allIds : [...allowedIds]; + const companySectionIds = (companyIds.length ? companyIds : professionalIds.length ? professionalIds : fallbackIds).slice(0, 3); + const businessSectionIds = ( + selectedBusinessEvidence.length + ? [ + ...selectedBusinessEvidence.map((item) => String(item.source.id)), + ...(selectedMarketEvidence.length ? [] : companySectionIds), + ] + : publicEvidence.length + ? [String(publicEvidence[0].source.id), ...companySectionIds] + : companySectionIds + ).filter((id, index, values) => id && values.indexOf(id) === index).slice(0, 4); + const recentSectionIds = ( + recentEvidence.length + ? recentEvidence.map((item) => String(item.source.id)) + : timelineEvidence + ? [String(timelineEvidence.source.id)] + : publicIds.length + ? publicIds + : fallbackIds + ).slice(0, 3); + const riskSectionIds = [ + ...riskIds, + ...companySectionIds, + ].filter((id, index, values) => id && values.indexOf(id) === index).slice(0, 4); + const decisionSectionIds = [ + ...(professionalIds.length ? professionalIds : companySectionIds), + ...recentSectionIds, + ].filter((id, index, values) => id && values.indexOf(id) === index).slice(0, 4); + const report = [ + { + text: `企业与业务概览:${companyText}`, + citation_ids: companySectionIds, + }, + { + text: `经营与业务动态:${businessTextValue}`, + citation_ids: businessSectionIds, + }, + { + text: `近期公开动态:${recentText}`, + citation_ids: recentSectionIds, + }, + { + text: `风险与关注事项:${riskText}`, + citation_ids: riskSectionIds, + }, + { + text: `销售机会判断:${opportunityText}`, + citation_ids: decisionSectionIds, + }, + { + text: `建议行动:${actionText}`, + citation_ids: decisionSectionIds, + }, + ]; + return report.map((item, index) => ({ + text: cleanDossierBodyText(item.text, DOSSIER_SECTION_TITLES[index], item.text, 1400), + citation_ids: item.citation_ids.filter((id) => allowedIds.has(id)), + })); + } + + fixedDossierBody(company, citations, preferredBody = []) { + const professionalIds = citations.filter((item) => item.source_kind === "专业数据集").map((item) => item.id); + const webIds = citations.filter((item) => item.source_kind === "联网搜索").map((item) => item.id); + const internalIds = citations.filter((item) => item.source_kind === "内部资料").map((item) => item.id); + const allIds = citations.map((item) => item.id); + const professionalSources = citations.filter((item) => item.source_kind === "专业数据集"); + const webSources = citations.filter((item) => item.source_kind === "联网搜索"); + const firstProfessional = professionalSources[0]; + const webPoints = webSources + .slice(0, 3) + .map((source) => concisePublicPoint(source)) + .filter(Boolean); + const professionalPoints = professionalSources + .slice(0, 3) + .map((source) => conciseProfessionalPoint(source)) + .filter((point) => point && !isLowValueProfessionalPoint(point)); + const preferredCompanyText = preferredBody.find((item) => /^企业情况:/.test(item.text))?.text; + const preferredCompanyIds = firstJsonArray(preferredBody.find((item) => /^企业情况:/.test(item.text))?.citation_ids) + .filter((id) => professionalIds.includes(id) || webIds.includes(id)); + const companyText = firstProfessional + ? (preferredCompanyText && !isWeakCompanySituationText(preferredCompanyText) + ? preferredCompanyText + : `企业情况:专业数据库显示:${professionalPoints.join(";") || `${company.name} 的可引用企业信息`}。`) + : `企业情况:本次专业数据库未返回可引用结果,当前档案不输出工商核验结论。`; + const preferredLatestText = preferredBody.find((item) => /^近期动态:/.test(item.text))?.text; + const preferredLatestIds = firstJsonArray(preferredBody.find((item) => /^近期动态:/.test(item.text))?.citation_ids) + .filter((id) => professionalIds.includes(id) || webIds.includes(id)); + const latestText = (preferredLatestText && !isOverlongLatestText(preferredLatestText) ? preferredLatestText : "") + || (webPoints.length + ? `近期动态:联网搜索返回 ${webPoints.length} 条可引用公开来源,主要提到:${webPoints.join(";")}。` + : "近期动态:联网搜索暂未返回可引用的新公告、新闻或招投标摘要。"); + const judgmentText = professionalIds.length + ? (preferredBody.find((item) => /^销售判断:/.test(item.text))?.text + || `销售判断:专业数据库可用于核验企业主体事实,联网搜索补充近期公开动态;当前信息适合作为下一轮销售沟通前的背景材料。`) + : `销售判断:本次只能依据联网搜索判断公开动态,缺少专业数据库的工商/风险核验,销售推进判断应保持谨慎。`; + const nextText = preferredBody.find((item) => /^下一步建议:/.test(item.text))?.text + || (professionalIds.length + ? `下一步建议:结合专业数据库核验结果和公开动态,继续确认预算窗口、采购节奏、供应商准入和数据合规要求。` + : `下一步建议:优先补齐专业数据库权限,再围绕预算窗口、采购节奏、供应商准入和数据合规要求继续确认。`); + const preferredJudgmentIds = firstJsonArray(preferredBody.find((item) => /^销售判断:/.test(item.text))?.citation_ids) + .filter((id) => allIds.includes(id)); + const preferredNextIds = firstJsonArray(preferredBody.find((item) => /^下一步建议:/.test(item.text))?.citation_ids) + .filter((id) => allIds.includes(id)); + return [ + { + text: companyText.startsWith("企业情况:") ? companyText : `企业情况:${companyText}`, + citation_ids: preferredCompanyIds.length ? preferredCompanyIds : professionalIds.slice(0, 2), + }, + { + text: latestText.startsWith("近期动态:") ? latestText : `近期动态:${latestText}`, + citation_ids: preferredLatestIds.length + ? preferredLatestIds + : webIds.length ? webIds.slice(0, 3) : professionalIds.slice(0, 1), + }, + { + text: judgmentText.startsWith("销售判断:") ? judgmentText : `销售判断:${judgmentText}`, + citation_ids: preferredJudgmentIds.length + ? preferredJudgmentIds + : [...new Set([...professionalIds.slice(0, 2), ...webIds.slice(0, 3), ...internalIds.slice(0, 2)])].slice(0, 5), + }, + { + text: nextText.startsWith("下一步建议:") ? nextText : `下一步建议:${nextText}`, + citation_ids: preferredNextIds.length + ? preferredNextIds + : [...new Set([...internalIds.slice(0, 2), ...webIds.slice(0, 2), ...professionalIds.slice(-1)])].slice(0, 5), + }, + ].map((item) => ({ + text: cleanEvidenceSummary(item.text, item.text, 520), + citation_ids: item.citation_ids.filter((id) => allIds.includes(id)), + })); + } + + fixedPublicDossierBody(dossier, body, company = {}, validationCitations = null) { + const citations = Array.isArray(validationCitations) + ? validationCitations + : firstJsonArray(dossier.citations); + if (body.length !== DOSSIER_SECTION_TITLES.length) return []; + const normalizedBody = body.slice(0, DOSSIER_SECTION_TITLES.length).map((item, index) => { + const title = DOSSIER_SECTION_TITLES[index]; + const sourceSegments = firstJsonArray(item.segments); + const segments = (sourceSegments.length + ? sourceSegments + : [{ + text: stripDossierSectionTitle(item.text), + citation_ids: firstJsonArray(item.citation_ids), + }]) + .map((segment) => ({ + text: ensureDossierLinePunctuation( + normalizeImportedText(normalizeSalesText(segment.text)) + .replace(/\s+/g, " ") + .trim(), + ), + citation_ids: [...new Set(firstJsonArray(segment.citation_ids).map(String))], + })) + .filter((segment) => segment.text && segment.citation_ids.length); + return { + ...item, + text: normalizeDossierSectionText(item.text, title), + citation_ids: [...new Set(firstJsonArray(item.citation_ids).map(String))], + segments, + }; + }); + const reportReady = DOSSIER_SECTION_TITLES.every((title, index) => ( + normalizedBody[index]?.text?.startsWith(`${title}:`) + && normalizedBody[index]?.citation_ids?.length + && normalizedBody[index]?.segments?.length + && normalizedBody[index].segments.every((segment) => segment.citation_ids.length) + )); + if (!reportReady) return []; + if (normalizedBody.some((item) => hasTechnicalErrorText(item.text))) return []; + if (dossierSectionContentErrors(normalizedBody).length) return []; + if (dossierSectionSemanticErrors(normalizedBody, citations, company).length) return []; + if (dossierSectionEvidenceGroundingErrors(normalizedBody, citations).length) return []; + return normalizedBody; + } + + progressFromDossier(company, dossier) { + const text = [dossier.summary, dossier.memory_summary].join(" "); + let label = "需求确认中"; + if (/预算|排期/.test(text)) label = "需求确认中"; + if (/初步|公开资料|缺少内部/.test(text)) label = "初步接触"; + if (/暂无|不足/.test(text)) label = "暂无有效信号"; + return { + label, + summary: conciseProgressSummary(label, dossier.memory_summary || dossier.summary), + evidence: "依据:最近档案和引用来源", + updated_at: nowIso(), + }; + } + + async storeDossierMemory(company, dossier, providerRunId = "") { + if (!this.openVikingProvider?.isRunEnabled?.()) { + await this.skipProviderStep(providerRunId, { + provider: "openviking", + operation: "store_dossier_memory", + input_summary: `写入 ${company.name} 的档案摘要`, + output_summary: "OpenViking 写入未启用。", + error: { code: "provider_disabled", message: "OpenViking writes are not enabled." }, + }); + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking writes are not enabled."); + } + return { status: "skipped", summary: "OpenViking 写入未启用。" }; + } + try { + const uri = this.openVikingProvider.salesDossierUri({ + workspaceId: this.workspaceId, + companyId: company.id, + dossierId: dossier.id, + }); + const content = [ + `# ${dossier.title}`, + "", + `企业:${company.name}`, + `档案 ID:${dossier.id}`, + `生成时间:${dossier.generated_at || dossier.created_at || nowIso()}`, + `摘要:${dossier.summary || ""}`, + `长期资料:${dossier.memory_summary || dossier.summary || ""}`, + ].join("\n"); + const result = await this.trackProviderStep(providerRunId, { + provider: "openviking", + operation: "store_dossier_memory", + input_summary: `写入 ${company.name} 的档案摘要`, + output_summary: "档案摘要已提交至 OpenViking。", + }, () => this.openVikingProvider.upsertTextResource({ + uri, + content, + mode: "create", + })); + const record = { + status: result.ok ? result.processing_status || "ready" : "failed", + raw_ref: result.raw_ref || null, + summary: result.ok + ? result.processing_status === "queued" + ? "最近档案结论已提交 OpenViking,正在异步建立索引。" + : "最近档案结论已写入 OpenViking 长期记忆。" + : `OpenViking 写入失败:${result.error?.code || "provider_error"}`, + }; + if (!result.ok && this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking dossier-memory write failed.", { + reason: result.error?.code || "provider_error", + }); + } + return record; + } catch (error) { + if (this.runtimePolicy.fail_closed) { + if (error instanceof HttpError) throw error; + throw providerUnavailable("openviking", "OpenViking dossier-memory write failed.", { + reason: error.message || "provider_error", + }); + } + return { + status: "failed", + raw_ref: null, + summary: `OpenViking 写入失败:${error.message || "provider_error"}`, + }; + } + } + + listMaterials(companyId) { + const company = this.requireCompany(companyId); + return (company.material_ids || []).map((id) => this.data.materials[id]).filter(Boolean).map((material) => ({ + id: material.id, + title: compactText(normalizeSalesText(material.title), 120), + summary: compactText(normalizeSalesText(material.summary || ""), 280), + source_type: compactText(material.source_type || "", 24), + source_url: publicSourceUrl(material.source_url), + source_id: material.source_id || null, + source_version: material.source_version || "", + content_hash: material.content_hash || null, + last_synced_at: material.last_synced_at || null, + updated_at: material.updated_at, + memory_status: material.openviking_status || (material.openviking_uri ? "indexed" : "pending"), + memory_ready: ["ready", "indexed"].includes(material.openviking_status || (material.openviking_uri ? "indexed" : "pending")), + })); + } + + listMaterialSyncSources(companyId) { + const company = this.requireCompany(companyId); + const materials = (company.material_ids || []).map((id) => this.data.materials[id]).filter(Boolean); + const grouped = new Map(); + for (const material of materials) { + if (!material.source_id) continue; + const items = grouped.get(material.source_id) || []; + items.push(material); + grouped.set(material.source_id, items); + } + + return [...grouped.entries()].map(([sourceId, sourceMaterials]) => { + const source = this.data.sync_sources?.[sourceId] || null; + const checkpoint = Object.values(this.data.sync_checkpoints || {}) + .filter((item) => item?.source_id === sourceId) + .sort((left, right) => String(right.updated_at || "").localeCompare(String(left.updated_at || "")))[0] || null; + const latestMaterial = [...sourceMaterials] + .sort((left, right) => String(right.updated_at || "").localeCompare(String(left.updated_at || "")))[0]; + const openVikingStatuses = sourceMaterials.reduce((counts, material) => { + const status = material.openviking_status || (material.openviking_uri ? "indexed" : "pending"); + counts[status] = (counts[status] || 0) + 1; + return counts; + }, {}); + return { + id: sourceId, + source_type: compactText(source?.source_type || latestMaterial?.source_type || "manual", 24), + external_id: compactText(source?.external_id || latestMaterial?.source_external_id || "", 240), + display_name: compactText(source?.display_name || latestMaterial?.title || "资料同步源", 120), + status: source?.status || "unmanaged", + material_count: sourceMaterials.length, + material_ids: sourceMaterials.map((material) => material.id), + last_synced_at: source?.last_synced_at || latestMaterial?.last_synced_at || null, + updated_at: source?.updated_at || latestMaterial?.updated_at || null, + checkpoint: checkpoint ? { + checkpoint_key: checkpoint.checkpoint_key || "latest", + checkpoint_value: checkpoint.checkpoint_value || "", + last_success_at: checkpoint.last_success_at || null, + error: checkpoint.error || null, + updated_at: checkpoint.updated_at || null, + } : null, + openviking_statuses: openVikingStatuses, + }; + }).sort((left, right) => String(right.updated_at || "").localeCompare(String(left.updated_at || ""))); + } + + resolveMaterialSyncContext(company, input = {}, { requireExisting = false } = {}) { + const requestedSourceId = compactText(input.source_id || input.sourceId || "", 240); + if (requestedSourceId) { + const material = (company.material_ids || []) + .map((id) => this.data.materials[id]) + .find((item) => item?.source_id === requestedSourceId) || null; + const source = this.data.sync_sources?.[requestedSourceId] || null; + if (!material || !source) { + throw new HttpError(404, "sync_source_not_found", "当前企业未找到对应的资料同步源。", { + source_id: requestedSourceId, + company_id: company.id, + }); + } + const checkpoint = Object.values(this.data.sync_checkpoints || {}) + .filter((item) => item?.source_id === requestedSourceId) + .sort((left, right) => String(right.updated_at || "").localeCompare(String(left.updated_at || "")))[0] || null; + return { + identity: { + source_id: requestedSourceId, + material_id: material.id, + checkpoint_key: checkpoint?.checkpoint_key || "latest", + }, + source, + checkpoint, + material, + }; + } + + const identity = buildMaterialSyncIdentity(company.id, input); + const source = this.data.sync_sources?.[identity.source_id] || null; + const checkpoint = this.data.sync_checkpoints?.[`${identity.source_id}:${identity.checkpoint_key}`] || null; + const material = this.data.materials?.[identity.material_id] + || (company.material_ids || []).map((id) => this.data.materials[id]).find((item) => item?.source_id === identity.source_id) + || null; + if (requireExisting && (!source || !material)) { + throw new HttpError(404, "sync_source_not_found", "当前企业未找到对应的资料同步源。", { + source_id: identity.source_id, + company_id: company.id, + }); + } + return { identity, source, checkpoint, material }; + } + + async restoreMaterialContent(material) { + if (!material) return null; + if (cleanMaterialText(material.text) || normalizeSourceItems(material.source_items).length) { + return material; + } + const uri = compactText(material.openviking_uri || material.openviking_ref || "", 1000); + if (!uri || typeof this.openVikingProvider?.readTextResource !== "function") { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "Existing material content cannot be restored from OpenViking.", { + material_id: material.id, + reason: uri ? "read_not_supported" : "missing_resource_uri", + }); + } + return material; + } + + const result = await this.openVikingProvider.readTextResource(uri); + if (!result.ok) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "Existing material content could not be read from OpenViking.", { + material_id: material.id, + reason: result.error?.code || "provider_error", + }); + } + return material; + } + + const snapshot = decodeMaterialSnapshot(result.content); + const restoredText = cleanMaterialText(snapshot?.text || legacyMaterialText(result.content)); + let restoredItems = normalizeSourceItems(snapshot?.source_items); + if (!restoredItems.length && restoredText) { + restoredItems = normalizeSourceItems([{ + id: `legacy-${String(material.content_hash || material.id || "material").slice(0, 40)}`, + occurred_at: material.occurred_at || null, + sender: "历史导入", + content: restoredText, + source_url: material.source_url || "", + }]); + } + if (!restoredText && !restoredItems.length && this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "The OpenViking material resource did not contain restorable content.", { + material_id: material.id, + reason: "invalid_material_resource", + }); + } + return { + ...material, + ...(snapshot || {}), + id: material.id, + company_id: material.company_id, + text: restoredText || cleanMaterialText(renderSourceItems(restoredItems)), + source_items: restoredItems, + openviking_uri: uri, + }; + } + + async importMaterial(companyId, body = {}) { + const company = this.requireCompany(companyId); + const title = compactText(body.title, 120); + if (!title) throw new HttpError(400, "bad_request", "资料标题不能为空。"); + const identity = buildMaterialSyncIdentity(company.id, { ...body, title }); + const existingMetadata = this.data.materials[identity.material_id] + || (company.material_ids || []) + .map((id) => this.data.materials[id]) + .find((item) => item?.source_id === identity.source_id) + || null; + const incomingItems = normalizeSourceItems(body.source_items || body.items); + const suppliedText = cleanMaterialText(body.raw_text || body.text || body.content); + const existing = existingMetadata + && incomingItems.length + && !cleanMaterialText(existingMetadata.text) + && !normalizeSourceItems(existingMetadata.source_items).length + ? await this.restoreMaterialContent(existingMetadata) + : existingMetadata; + const sourceItems = incomingItems.length + ? mergeSourceItems(existing?.source_items || [], incomingItems) + : existing?.source_items || []; + const rawText = cleanMaterialText( + sourceItems.length && (incomingItems.length || !suppliedText) + ? renderSourceItems(sourceItems) + : suppliedText || existing?.text, + ); + if (!rawText) throw new HttpError(400, "bad_request", "资料内容不能为空。"); + + const previousSource = this.data.sync_sources?.[identity.source_id] || null; + if (previousSource?.status === "paused" && !body.resume_source) { + throw new HttpError(409, "sync_source_paused", "该资料源已暂停,请明确恢复后再同步。", { + source_id: identity.source_id, + }); + } + + const job = await this.startJob({ + job_type: "sales_material_import", + entity_type: "target_enterprise", + entity_id: company.id, + max_attempts: 1, + request: { + title, + source_type: identity.source_type, + source_external_id: identity.external_id, + }, + }); + let run = null; + const now = nowIso(); + try { + run = await this.providerRuns.startRun({ + operation: "feishu_material_import", + entity_type: "target_enterprise", + entity_id: company.id, + job_id: job.id, + }); + const summary = compactText(body.summary || existing?.summary || rawText, 280) || this.inferMaterialSummary(title); + const candidate = { + id: existing?.id || identity.material_id, + company_id: company.id, + title, + source_type: identity.source_type, + source_url: compactText(identity.source_url, 500), + source_id: identity.source_id, + source_external_id: identity.external_id, + source_version: identity.source_version, + summary, + text: rawText, + source_items: sourceItems, + occurred_at: body.occurred_at || existing?.occurred_at || null, + last_synced_at: now, + created_at: existing?.created_at || now, + updated_at: existing?.updated_at || now, + openviking_uri: existing?.openviking_uri || "", + openviking_ref: existing?.openviking_ref || "", + openviking_status: existing?.openviking_status || "pending", + }; + candidate.content_hash = makeMaterialContentHash(candidate); + const contentChanged = !existing || existing.content_hash !== candidate.content_hash; + if (contentChanged && existing) candidate.updated_at = now; + const alreadyIndexed = ["ready", "indexed"].includes(existing?.openviking_status); + const action = !existing ? "created" : contentChanged ? "updated" : alreadyIndexed ? "unchanged" : "retried"; + + const sourceRecord = { + id: identity.source_id, + source_type: identity.source_type, + external_id: identity.external_id, + display_name: identity.display_name, + status: "active", + config: identity.config, + last_synced_at: now, + created_at: previousSource?.created_at || now, + updated_at: now, + }; + const checkpointId = `${identity.source_id}:${identity.checkpoint_key}`; + const previousCheckpoint = this.data.sync_checkpoints?.[checkpointId] || null; + const checkpoint = { + id: checkpointId, + source_id: identity.source_id, + checkpoint_key: identity.checkpoint_key, + checkpoint_value: identity.checkpoint_value, + content_hash: candidate.content_hash, + last_success_at: previousCheckpoint?.last_success_at || null, + error: null, + created_at: previousCheckpoint?.created_at || now, + updated_at: now, + }; + + await this.trackProviderStep(run.id, { + provider: "supabase", + operation: "persist_material_sync_state", + input_summary: `保存 ${company.name} 的资料源、同步游标和材料`, + output_summary: `同步状态已保存,处理结果为 ${action}。`, + }, async () => { + await this.persist(() => this.repository.persistSyncSource(sourceRecord)); + await this.persist(() => this.repository.persistSalesMaterial(candidate)); + await this.persist(() => this.repository.persistSyncCheckpoint(checkpoint)); + return { ok: true, provider: "supabase", provider_mode: this.persistence.enabled ? "real" : "disabled" }; + }); + + this.data.sync_sources = this.data.sync_sources || {}; + this.data.sync_checkpoints = this.data.sync_checkpoints || {}; + this.data.sync_sources[sourceRecord.id] = sourceRecord; + this.data.sync_checkpoints[checkpoint.id] = checkpoint; + this.data.materials[candidate.id] = candidate; + company.material_ids = [candidate.id, ...(company.material_ids || []).filter((id) => id !== candidate.id)]; + + let record; + if (action === "unchanged") { + await this.skipProviderStep(run.id, { + provider: "openviking", + operation: "upsert_material_resource", + input_summary: `检查 ${company.name} 的资料内容指纹`, + output_summary: "内容指纹未变化,未重复写入 OpenViking。", + }); + record = { + ok: true, + material_id: candidate.id, + title: candidate.title, + status: candidate.openviking_status, + raw_ref: candidate.openviking_ref || candidate.openviking_uri || null, + uri: candidate.openviking_uri || "", + summary: "内容未变化,已跳过重复写入。", + created_at: now, + }; + } else { + record = await this.trackProviderStep(run.id, { + provider: "openviking", + operation: "upsert_material_resource", + input_summary: `在 ${company.name} 的独立目录写入资料 ${candidate.title}`, + output_summary: "资料已写入当前企业的 OpenViking 目录。", + }, () => this.writeMaterialToOpenViking(company, candidate, { + mode: existing?.openviking_uri ? "replace" : "create", + })); + } + + candidate.openviking_status = record.status; + candidate.openviking_uri = record.uri || candidate.openviking_uri || ""; + candidate.openviking_ref = record.raw_ref || candidate.openviking_ref || ""; + sourceRecord.status = record.status === "failed" ? "error" : "active"; + checkpoint.last_success_at = record.status === "failed" ? previousCheckpoint?.last_success_at || null : now; + checkpoint.error = record.status === "failed" + ? { code: record.error?.code || "openviking_write_failed", message: record.summary } + : null; + + await this.persist(() => this.repository.persistSyncSource(sourceRecord)); + await this.persist(() => this.repository.persistSalesMaterial(candidate)); + await this.persist(() => this.repository.persistSyncCheckpoint(checkpoint)); + if (record.status !== "skipped" && record.uri) { + await this.persist(() => this.repository.persistSalesOpenVikingRef({ + company_id: company.id, + related_type: "material", + related_id: candidate.id, + ref_kind: "resource_import", + uri: record.uri, + summary: record.summary, + created_at: record.created_at, + payload_json: { source_id: candidate.source_id, content_hash: candidate.content_hash, record }, + })); + } + this.data.sync_sources[sourceRecord.id] = sourceRecord; + this.data.sync_checkpoints[checkpoint.id] = checkpoint; + this.data.materials[candidate.id] = candidate; + + await this.providerRuns.completeRun(run.id, { result_ref: `material:${candidate.id}:${action}` }); + await this.completeJob(job.id, { + result_ref: `material:${candidate.id}:${action}`, + result: { action, material_id: candidate.id }, + }); + return { + action, + source: clone(sourceRecord), + checkpoint: clone(checkpoint), + material: { + id: candidate.id, + title: candidate.title, + summary: candidate.summary, + source_id: candidate.source_id, + source_version: candidate.source_version, + content_hash: candidate.content_hash, + last_synced_at: candidate.last_synced_at, + updated_at: candidate.updated_at, + openviking_status: candidate.openviking_status, + }, + openviking_record: { + material_id: record.material_id, + title: record.title, + status: record.status, + summary: record.summary, + created_at: record.created_at, + }, + provider_run_id: run.id, + job_id: job.id, + materials: this.listMaterials(company.id), + }; + } catch (error) { + if (run) { + try { + await this.providerRuns.failRun(run.id, { + code: error.code || "material_import_failed", + message: error.message || "Material import failed.", + category: error.category || "workflow", + retryable: error.retryable, + }); + } catch (persistenceError) { + if (this.runtimePolicy.fail_closed) throw persistenceError; + } + } + await this.failJob(job.id, error); + throw error; + } + } + + getMaterialSyncState(companyId, input = {}) { + const company = this.requireCompany(companyId); + const { identity, source, checkpoint, material } = this.resolveMaterialSyncContext(company, input); + return { + source_id: identity.source_id, + source: source ? clone(source) : null, + checkpoint: checkpoint ? clone(checkpoint) : null, + material: material ? { + id: material.id, + content_hash: material.content_hash || null, + source_version: material.source_version || "", + last_synced_at: material.last_synced_at || null, + openviking_status: material.openviking_status || "pending", + } : null, + }; + } + + async updateMaterialSyncSource(companyId, body = {}) { + const company = this.requireCompany(companyId); + const action = String(body.action || "").trim().toLowerCase(); + if (!['pause', 'resume', 'delete'].includes(action)) { + throw new HttpError(400, "bad_request", "action 必须是 pause、resume 或 delete。"); + } + const { identity, source } = this.resolveMaterialSyncContext(company, body, { requireExisting: true }); + + const now = nowIso(); + if (action !== "delete") { + const updatedSource = { + ...source, + status: action === "pause" ? "paused" : "active", + updated_at: now, + }; + await this.persist(() => this.repository.persistSyncSource(updatedSource)); + this.data.sync_sources[identity.source_id] = updatedSource; + return { + action, + source: clone(updatedSource), + affected_material_ids: [], + warnings: [], + }; + } + + const job = await this.startJob({ + job_type: "sales_material_source_delete", + entity_type: "sync_source", + entity_id: identity.source_id, + max_attempts: 1, + request: { company_id: company.id, source_id: identity.source_id }, + }); + let run = null; + try { + run = await this.providerRuns.startRun({ + operation: "material_sync_source_delete", + entity_type: "sync_source", + entity_id: identity.source_id, + job_id: job.id, + }); + const materials = (company.material_ids || []) + .map((id) => this.data.materials[id]) + .filter((material) => material?.source_id === identity.source_id); + const warnings = []; + for (const material of materials) { + if (material.openviking_uri) { + if (this.openVikingProvider?.isRunEnabled?.() && typeof this.openVikingProvider.removeResource === "function") { + const removal = await this.trackProviderStep(run.id, { + provider: "openviking", + operation: "remove_material_resource", + input_summary: `删除资料资源 ${material.openviking_uri}`, + output_summary: "OpenViking 资料资源已删除。", + }, () => this.openVikingProvider.removeResource(material.openviking_uri)); + if (!removal.ok) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking material deletion failed.", { + reason: removal.error?.code || "provider_error", + }); + } + warnings.push(`OpenViking 资源删除失败:${material.openviking_uri}`); + } + } else { + await this.skipProviderStep(run.id, { + provider: "openviking", + operation: "remove_material_resource", + input_summary: `删除资料资源 ${material.openviking_uri}`, + output_summary: "OpenViking 删除能力未启用。", + error: { code: "provider_disabled", message: "OpenViking resource removal is not enabled." }, + }); + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking material deletion is not enabled."); + } + warnings.push(`OpenViking 未启用,资源可能仍需人工清理:${material.openviking_uri}`); + } + } + await this.persist(() => this.repository.softDeleteSalesMaterial(material.id, now)); + delete this.data.materials[material.id]; + company.material_ids = (company.material_ids || []).filter((id) => id !== material.id); + } + + const deletedSource = { + ...source, + status: "deleted", + updated_at: now, + }; + await this.persist(() => this.repository.persistSyncSource(deletedSource)); + this.data.sync_sources[identity.source_id] = deletedSource; + await this.providerRuns.completeRun(run.id, { result_ref: `sync-source:${identity.source_id}:deleted` }); + await this.completeJob(job.id, { + result_ref: `sync-source:${identity.source_id}:deleted`, + result: { source_id: identity.source_id, deleted_material_count: materials.length }, + }); + return { + action, + source: clone(deletedSource), + affected_material_ids: materials.map((material) => material.id), + warnings, + provider_run_id: run.id, + job_id: job.id, + }; + } catch (error) { + if (run) { + try { + await this.providerRuns.failRun(run.id, { + code: error.code || "sync_source_delete_failed", + message: error.message || "Sync source deletion failed.", + category: error.category || "workflow", + retryable: error.retryable, + }); + } catch (persistenceError) { + if (this.runtimePolicy.fail_closed) throw persistenceError; + } + } + await this.failJob(job.id, error); + throw error; + } + } + + async syncMaterialsToOpenViking(companyId, options = {}) { + const company = this.requireCompany(companyId); + const materials = (company.material_ids || []).map((id) => this.data.materials[id]).filter(Boolean); + const reportProgress = typeof options.report_progress === "function" + ? options.report_progress + : async () => {}; + if (!materials.length) { + if (options.claimed_job) { + this.data.jobs[options.claimed_job.id] = clone(options.claimed_job); + await this.completeJob(options.claimed_job.id, { + result_ref: `material-sync:${company.id}:skipped`, + result: { status: "skipped", material_count: 0, failed_count: 0 }, + }); + } + return { + status: "skipped", + summary: "当前企业还没有可导入的历史资料。", + records: [], + }; + } + if (!this.openVikingProvider?.isRunEnabled?.()) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking writes are not enabled."); + } + return { + status: "skipped", + summary: "OpenViking 写入未启用。", + records: materials.map((material) => ({ + material_id: material.id, + title: material.title, + status: "skipped", + })), + }; + } + + const job = options.claimed_job + ? await this.activateClaimedJob(options.claimed_job, "sales_material_openviking_sync") + : await this.startJob({ + job_type: "sales_material_openviking_sync", + entity_type: "target_enterprise", + entity_id: company.id, + max_attempts: 3, + request: { material_count: materials.length }, + }); + let run = null; + try { + await reportProgress("syncing_materials", 8); + run = await this.providerRuns.startRun({ + operation: "material_openviking_sync", + entity_type: "target_enterprise", + entity_id: company.id, + job_id: job.id, + }); + const records = []; + for (const [index, material] of materials.entries()) { + await this.assertJobActive(job.id); + await reportProgress("syncing_materials", 10 + Math.floor((index / materials.length) * 75)); + const hasLocalContent = Boolean( + cleanMaterialText(material.text) + || normalizeSourceItems(material.source_items).length, + ); + let record; + if (!hasLocalContent && material.openviking_uri && ["ready", "indexed"].includes(material.openviking_status)) { + await this.skipProviderStep(run.id, { + provider: "openviking", + operation: "upsert_material_resource", + input_summary: `检查 ${company.name} 的资料 ${material.title}`, + output_summary: "正文已由 OpenViking 保存,无需从 Supabase 重复读取或覆盖。", + }); + record = { + ok: true, + material_id: material.id, + title: material.title, + status: material.openviking_status, + raw_ref: material.openviking_ref || material.openviking_uri, + uri: material.openviking_uri, + summary: "资料正文已存在于 OpenViking。", + created_at: nowIso(), + }; + } else if (!hasLocalContent) { + throw providerUnavailable("openviking", "Material metadata exists but its OpenViking content is unavailable.", { + material_id: material.id, + reason: "missing_material_content", + }); + } else { + record = await this.trackProviderStep(run.id, { + provider: "openviking", + operation: "upsert_material_resource", + input_summary: `在 ${company.name} 的独立目录写入资料 ${material.title}`, + output_summary: "资料已写入当前企业的 OpenViking 目录。", + }, () => this.writeMaterialToOpenViking(company, material)); + } + material.openviking_status = record.status; + material.openviking_ref = record.raw_ref || material.openviking_uri || ""; + material.openviking_uri = record.uri || material.openviking_uri || ""; + records.push(record); + await this.trackProviderStep(run.id, { + provider: "supabase", + operation: "persist_material_memory_ref", + input_summary: `保存资料 ${material.title} 的记忆索引状态`, + output_summary: "资料记忆索引状态已保存。", + }, async () => { + await this.persist(() => this.repository.persistSalesMaterial(material)); + if (record.uri) { + await this.persist(() => this.repository.persistSalesOpenVikingRef({ + company_id: company.id, + related_type: "material", + related_id: material.id, + ref_kind: "memory_import", + uri: record.uri, + summary: record.summary, + payload_json: { + material_id: material.id, + source_id: material.source_id || null, + content_hash: material.content_hash || null, + status: record.status, + }, + })); + } + return { ok: true, provider: "supabase", provider_mode: this.persistence.enabled ? "real" : "disabled" }; + }); + } + + const failed = records.filter((record) => record.status === "failed").length; + const status = failed ? "partial" : "ready"; + const summary = failed + ? `${records.length - failed}/${records.length} 条历史资料已写入 OpenViking。` + : `${records.length} 条历史资料已写入 OpenViking。`; + await reportProgress("persisting_result", 94); + await this.providerRuns.completeRun(run.id, { result_ref: `material-sync:${company.id}:${status}` }); + await this.completeJob(job.id, { + result_ref: `material-sync:${company.id}:${status}`, + result: { status, material_count: records.length, failed_count: failed }, + }); + return { + status, + summary, + records: records.map((record) => ({ + material_id: record.material_id, + title: record.title, + status: record.status, + summary: record.summary, + created_at: record.created_at, + })), + provider_run_id: run.id, + job_id: job.id, + }; + } catch (error) { + if (run) { + try { + if (error.code === "job_cancelled") { + await this.providerRuns.cancelRun(run.id, { summary: "资料记忆同步任务已由用户取消。" }); + } else { + await this.providerRuns.failRun(run.id, { + code: error.code || "material_openviking_sync_failed", + message: error.message || "Material memory sync failed.", + category: error.category || "workflow", + retryable: error.retryable, + }); + } + } catch (persistenceError) { + if (this.runtimePolicy.fail_closed) throw persistenceError; + } + } + await this.failJob(job.id, error); + throw error; + } + } + + async writeMaterialToOpenViking(company, material, options = {}) { + if (!this.openVikingProvider?.isRunEnabled?.()) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking writes are not enabled."); + } + return { + material_id: material.id, + title: material.title, + status: "skipped", + raw_ref: null, + uri: material.openviking_uri || "", + summary: "OpenViking 写入未启用。", + created_at: nowIso(), + }; + } + let result; + try { + const content = this.buildMaterialMemory(company, material); + if (typeof this.openVikingProvider.upsertTextResource === "function") { + result = await this.openVikingProvider.upsertTextResource({ + uri: this.openVikingProvider.salesMaterialUri({ + workspaceId: this.workspaceId, + companyId: company.id, + sourceId: material.source_id || material.id, + }), + content, + mode: options.mode || (material.openviking_uri ? "replace" : "create"), + }); + } else { + result = await this.openVikingProvider.storeMemory([{ role: "user", content }]); + } + } catch (error) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking material write failed.", { + reason: error.message || "provider_error", + }); + } + result = { ok: false, error: { code: error.message || "provider_error" }, raw_ref: null }; + } + if (!result.ok && this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking material write failed.", { + reason: result.error?.code || "provider_error", + }); + } + return { + ok: result.ok, + material_id: material.id, + title: material.title, + status: result.ok ? "ready" : "failed", + raw_ref: result.raw_ref || null, + uri: result.uri || result.raw_ref || material.openviking_uri || "", + summary: result.ok + ? "历史资料已写入 OpenViking 长期记忆。" + : `OpenViking 写入失败:${result.error?.code || "provider_error"}`, + created_at: nowIso(), + error: result.error || null, + }; + } + + buildMaterialMemory(company, material) { + return [ + "销售历史资料需要作为长期记忆保存。", + `企业:${company.name}`, + `资料标题:${material.title}`, + `资料来源:${material.source_type || "Codex 整理的飞书沟通、会议纪要或云文档"}`, + material.source_url ? `来源链接:${material.source_url}` : "", + material.occurred_at || material.updated_at ? `资料时间:${material.occurred_at || material.updated_at}` : "", + material.openviking_uri ? `原始资源 URI:${material.openviking_uri}` : "", + `资料摘要:${material.summary || this.inferMaterialSummary(material.title)}`, + material.text ? `资料正文:${material.text}` : "", + "使用边界:后续资料问答可以引用该资料;最近档案不得引用该资料,最近档案只能使用专业数据集和豆包搜索。", + encodeMaterialSnapshot(material), + ].filter(Boolean).join("\n"); + } + + inferMaterialSummary(title) { + const text = String(title || ""); + if (/会议纪要/.test(text)) return "会议资料中通常包含客户关注点、预算排期、部署要求和下一步行动,需要在销售跟进中优先召回。"; + if (/方案|讨论/.test(text)) return "方案讨论资料通常包含客户需求、技术约束和供应商准入要求,需要用于判断当前推进状态。"; + if (/沟通|摘录/.test(text)) return "历史沟通摘录用于补充客户背景、已确认事项和资料缺口。"; + return "该历史资料用于补充销售跟进中的长期上下文。"; + } + + qaView(company, messages = []) { + const hasMaterials = (company.material_ids || []) + .map((id) => this.data.materials[id]) + .filter(Boolean) + .some(isFeishuMaterial); + return { + messages: this.compatibleQaMessages(company, messages) + .map((message) => this.publicQaMessage(message)), + note: hasMaterials + ? "仅根据当前企业档案和用户导入的飞书资料回答。" + : "当前企业暂无飞书资料;问答仅根据当前企业档案回答。", + }; + } + + cachedQa(companyId) { + const company = this.requireCompany(companyId); + return this.qaView(company, this.data.qa_messages[companyId] || []); + } + + async loadQaSessionState(company, options = {}) { + const fallbackMessages = this.compatibleQaMessages( + company, + this.data.qa_messages[company.id] || [], + ); + if ( + !this.openVikingProvider?.isConfigured?.() + || typeof this.openVikingProvider?.getSessionContext !== "function" + ) { + if (options.failOnUnavailable && this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking session retrieval is not configured."); + } + return { + ok: true, + provider: "openviking", + provider_mode: "ephemeral", + session_id: this.openVikingSessionId(company), + messages: fallbackMessages, + latest_archive_overview: "", + summary: "OpenViking 会话读取未配置,当前仅使用进程内会话。", + }; + } + + const sessionId = this.openVikingSessionId(company); + const result = await this.openVikingProvider.getSessionContext(sessionId, { tokenBudget: 6000 }); + if (!result.ok && openVikingNotFound(result)) { + this.data.qa_messages[company.id] = []; + return { + ok: true, + provider: "openviking", + provider_mode: "real", + session_id: sessionId, + messages: [], + latest_archive_overview: "", + summary: "当前企业尚未建立 OpenViking 问答会话。", + }; + } + if (!result.ok) { + if (options.failOnUnavailable && this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking session retrieval failed.", { + reason: result.error?.code || "provider_error", + }); + } + return { + ok: true, + provider: "openviking", + provider_mode: "ephemeral", + session_id: sessionId, + messages: fallbackMessages, + latest_archive_overview: "", + summary: "OpenViking 会话暂不可读,保留当前进程内会话。", + }; + } + + const messages = firstJsonArray(result.messages) + .map((message, index) => decodeQaSessionMessage(message, index)) + .filter((message) => message.text); + this.data.qa_messages[company.id] = messages; + return { + ok: true, + provider: "openviking", + provider_mode: "real", + session_id: result.session_id || sessionId, + messages, + latest_archive_overview: compactText(result.latest_archive_overview || "", 3000), + raw_ref: result.raw_ref || `openviking:session:${sessionId}:context`, + summary: `已从 OpenViking 恢复 ${messages.length} 条近期会话。`, + }; + } + + async getQa(companyId) { + const company = this.requireCompany(companyId); + const session = await this.loadQaSessionState(company); + return this.qaView(company, session.messages); + } + + isAllowedQaCitation(company, citation) { + const sourceKind = compactText(citation?.source_kind || "", 80); + if (sourceKind === "企业档案") return true; + if (!/内部资料|飞书|云文档|会议纪要|会话/.test(sourceKind)) return false; + + const uri = compactText(citation?.uri || "", 1000); + if (uri.includes("/materials/")) return true; + + const label = compactText(citation?.label || "", 240); + const allowedMaterialIdentities = (company.material_ids || []) + .map((id) => this.data.materials[id]) + .filter(Boolean) + .filter(isFeishuMaterial) + .flatMap((material) => [ + compactText(material.title || "", 240), + compactText(material.openviking_uri || material.openviking_ref || "", 1000), + compactText(material.source_url || "", 1000), + ]) + .filter(Boolean); + return allowedMaterialIdentities.includes(label) || allowedMaterialIdentities.includes(uri); + } + + isCompatibleQaAnswer(company, message) { + if (message?.role !== "assistant") return true; + if (hasLegacyGenericQaCitations(message)) return false; + const citations = firstJsonArray(message.citations); + if (!citations.length) return true; + return citations.every((citation) => this.isAllowedQaCitation(company, citation)); + } + + compatibleQaMessages(company, messages = []) { + const source = firstJsonArray(messages); + const compatible = []; + for (let index = 0; index < source.length; index += 1) { + const message = source[index]; + if (message?.role === "user" && source[index + 1]?.role === "assistant") { + const answer = source[index + 1]; + if (this.isCompatibleQaAnswer(company, answer)) compatible.push(message, answer); + index += 1; + continue; + } + if (this.isCompatibleQaAnswer(company, message)) compatible.push(message); + } + return compatible; + } + + publicQaMessage(message) { + const displayText = message.role === "assistant" ? sanitizeQaDisplayText : normalizeSalesText; + const displaySources = mergeQaDisplayCitations(message); + return { + id: message.id, + role: message.role, + text: displayText(message.text), + paragraphs: displaySources.paragraphs.map((paragraph) => ({ + text: displayText(paragraph.text), + citation_ids: firstJsonArray(paragraph.citation_ids).map(String), + })), + citation_ids: displaySources.citation_ids, + citations: displaySources.citations.map((citation) => publicCitationView(citation)), + insufficient: Boolean(message.insufficient), + created_at: message.created_at || null, + }; + } + + async askQuestion(companyId, body = {}, options = {}) { + const company = this.requireCompany(companyId); + const question = String(body.question || "").trim(); + if (!question) throw new HttpError(400, "bad_request", "问题不能为空。"); + const job = await this.startJob({ + job_type: "sales_qa", + entity_type: "target_enterprise", + entity_id: company.id, + max_attempts: 3, + request: { question }, + retry_job_id: options.retry_job_id || "", + }); + let run = null; + + try { + run = await this.providerRuns.startRun({ + operation: "sales_qa", + entity_type: "target_enterprise", + entity_id: company.id, + job_id: job.id, + }); + const sessionState = await this.trackProviderStep(run.id, { + provider: "openviking", + operation: "restore_qa_session", + input_summary: `恢复 ${company.name} 的近期问答和长期会话摘要`, + output_summary: "已从 OpenViking 恢复企业问答上下文。", + }, () => this.loadQaSessionState(company, { failOnUnavailable: true })); + const messages = [...this.compatibleQaMessages(company, sessionState.messages)]; + const conversationHistory = qaConversationHistory(messages); + const userMessage = { + id: makeId("qa_user"), + role: "user", + text: question, + created_at: nowIso(), + }; + userMessage.provider_run_id = run.id; + const retrieval = await this.trackProviderStep(run.id, { + provider: "openviking", + operation: "retrieve_qa_context", + input_summary: `仅在 ${company.name} 的飞书资料目录中执行多查询检索并读取命中原文`, + }, async () => { + const queries = qaRetrievalQueries(company, question, conversationHistory); + const queryResults = []; + for (const query of queries) { + queryResults.push({ + query, + contexts: await this.searchOpenViking(company, query), + }); + } + const matchedContexts = fuseQaRetrievalContexts(queryResults, { + maxContexts: 10, + maxPerMaterial: 2, + }); + const contexts = await this.hydrateOpenVikingContexts(company, matchedContexts); + return { + ok: true, + provider: "openviking", + provider_mode: this.openVikingProvider?.isConfigured?.() ? "real" : "fallback", + contexts, + query_plan: queries, + retrieval_trace: matchedContexts.map((context) => ({ + material_id: context.material_id, + uri: context.uri, + query_hits: context.query_hits, + best_rank: context.best_rank, + fusion_score: context.fusion_score, + })), + summary: `已执行 ${queries.length} 个检索查询,经融合排序后读取 ${contexts.length} 份企业范围内资料。`, + }; + }); + await this.assertJobActive(job.id); + const dossier = (company.dossier_ids || []) + .map((id) => this.data.dossiers[id]) + .filter(Boolean) + .sort((a, b) => Number(b.version_no || 1) - Number(a.version_no || 1) + || String(b.created_at || "").localeCompare(String(a.created_at || "")))[0] || null; + const evidenceResult = await this.trackProviderStep(run.id, { + provider: "rule", + operation: "build_qa_evidence", + input_summary: `对 ${company.name} 当前档案与命中资料分块、重排并执行可回答性判断`, + }, async () => { + const evidence = buildQaEvidence({ + dossier, + contexts: retrieval.contexts, + question, + conversationHistory, + maxItems: 12, + }); + const answerability = assessQaAnswerability(question, evidence, conversationHistory); + return { + ok: true, + provider: "rule", + provider_mode: "local", + evidence, + answerability, + summary: `已建立 ${evidence.length} 个可引用证据片段;可回答性=${answerability.supported ? "通过" : "不足"}。`, + }; + }); + const answer = await this.generateQaAnswer( + company, + question, + dossier, + retrieval.contexts, + evidenceResult.evidence, + run.id, + conversationHistory, + sessionState.latest_archive_overview, + evidenceResult.answerability, + ); + await this.assertJobActive(job.id); + answer.provider_run_id = run.id; + + const captured = await this.trackProviderStep(run.id, { + provider: "openviking", + operation: "capture_qa_session", + input_summary: `把 ${company.name} 的本轮问答写入企业会话`, + output_summary: "问答会话已提交给 OpenViking。", + }, () => this.captureQaSession(company, userMessage, answer, retrieval.contexts)); + await this.assertJobActive(job.id); + messages.push(userMessage, answer); + this.data.qa_messages[companyId] = messages; + + const assistantRounds = messages.filter((message) => message.role === "assistant").length; + let commitStatus = "not_due"; + if ( + captured?.ok + && this.qaAutoCommitEvery > 0 + && assistantRounds > 0 + && assistantRounds % this.qaAutoCommitEvery === 0 + ) { + const committed = await this.trackProviderStep(run.id, { + provider: "openviking", + operation: "commit_qa_long_term_memory", + input_summary: `从 ${company.name} 的问答会话提炼长期记忆并保留最近对话`, + output_summary: "已提交 OpenViking 长期记忆提炼。", + }, () => this.openVikingProvider.commitSession(captured.session_id, { + keepRecentCount: this.qaKeepRecentMessages, + })); + commitStatus = committed?.ok ? "submitted" : "failed"; + } + + if (this.persistence.enabled && this.repository) { + await this.trackProviderStep(run.id, { + provider: "supabase", + operation: "persist_qa_session_metadata", + input_summary: `保存 ${company.name} 的 OpenViking 会话索引和业务状态`, + output_summary: "仅保存了会话 URI、消息计数和同步状态。", + }, async () => { + await this.persist(() => this.repository.persistSalesCompany(company)); + await this.persist(() => this.repository.persistSalesOpenVikingRef({ + company_id: company.id, + related_type: "qa_session", + related_id: captured?.session_id || this.openVikingSessionId(company), + ref_kind: "session", + uri: captured?.raw_ref || `openviking:session:${captured?.session_id || this.openVikingSessionId(company)}`, + summary: "企业资料问答会话由 OpenViking 保存。", + payload_json: { + session_id: captured?.session_id || this.openVikingSessionId(company), + message_count: messages.length, + last_message_at: answer.created_at, + commit_status: commitStatus, + }, + })); + return { ok: true, provider: "supabase", provider_mode: "real" }; + }); + } else { + await this.skipProviderStep(run.id, { + provider: "supabase", + operation: "persist_qa_session_metadata", + input_summary: `保存 ${company.name} 的会话索引`, + output_summary: "当前配置未启用持久化仓库。", + error: { code: "repository_disabled", message: "Persistent repository is not enabled." }, + }); + } + await this.assertJobActive(job.id); + await this.providerRuns.completeRun(run.id, { result_ref: `qa_message:${answer.id}` }); + await this.completeJob(job.id, { + result_ref: `qa_message:${answer.id}`, + result: { message_id: answer.id, insufficient: answer.insufficient }, + }); + return { + message: this.publicQaMessage(answer), + messages: this.compatibleQaMessages(company, messages) + .map((message) => this.publicQaMessage(message)), + provider_run_id: run.id, + job_id: job.id, + }; + } catch (error) { + if (run) { + try { + if (error.code === "job_cancelled") { + await this.providerRuns.cancelRun(run.id, { summary: "资料问答任务已由用户取消。" }); + } else { + await this.providerRuns.failRun(run.id, { + code: error.code || "qa_failed", + message: error.message || "Question answering failed.", + category: error.category || "workflow", + retryable: error.retryable, + details: { + validation_errors: safeValidationErrors( + error.details?.validation_errors || error.validation_errors, + ), + }, + }); + } + } catch (persistenceError) { + if (this.runtimePolicy.fail_closed) throw persistenceError; + } + } + await this.failJob(job.id, error); + throw error; + } + } + + async generateQaAnswer( + company, + question, + dossier, + contexts, + evidence = null, + providerRunId = "", + conversationHistory = [], + conversationMemory = "", + answerability = null, + ) { + const allowedEvidence = Array.isArray(evidence) + ? evidence + : buildQaEvidence({ dossier, contexts, question, conversationHistory }); + const safeConversationHistory = qaConversationHistory(conversationHistory); + const support = answerability + || assessQaAnswerability(question, allowedEvidence, safeConversationHistory); + const enumerationRequirements = buildQaEnumerationRequirements(question, allowedEvidence); + if (!support.evidence_count) { + const text = "现有企业档案和已导入飞书资料中,没有检索到足以可靠回答该问题的相关依据。请补充对应会话或云文档,或先更新企业档案后再提问。"; + return { + id: makeId("qa_assistant"), + role: "assistant", + text, + paragraphs: [{ text, citation_ids: [] }], + citation_ids: [], + citations: [], + insufficient: true, + created_at: nowIso(), + }; + } + if (this.modelProvider?.isRunEnabled?.()) { + try { + const qaSystem = [ + "你是销售资料问答助手。只输出 JSON,不要输出 Markdown。", + "只能基于 evidence 中的企业档案和用户导入的飞书资料回答。", + "企业档案是已由专业数据集和豆包搜索生成并完成引用校验的当前报告;飞书资料来自用户有权访问并主动导入的会话、云文档或会议纪要。", + "conversation_history 仅用于理解代词、承接追问和避免重复;不得把其中未被 evidence 支撑的陈述当成事实。", + "conversation_memory 是 OpenViking 从更早会话中提炼的长期摘要,只能用于保持对话连续性,不能单独作为事实证据。", + "不能自由联网,不能补编资料。资料不足时明确说不足。", + "retrieval_plan 说明问题类型和检索支持度;先直接回答问题,再给依据或下一步,不要介绍系统如何检索、调用了什么能力或资料条数。", + "严格围绕用户明确要求的对象和分项作答;不得自行增加“补充”“延伸信息”“其他说明”等未被提问的旁支内容。只有资料不足会影响结论时,才说明缺口或下一步。", + "当 retrieval_plan.answerability.supported=false 时,只有 evidence 原文明确包含答案才能回答;否则 insufficient 必须为 true,并简洁说明缺少哪类资料。", + "evidence.label 是资料的正式展示标题,询问标题或来源时必须逐字使用 label,不得根据正文另拟标题。", + "不得输出 evidence.uri、内部路径、资源 ID、公司内部 ID 或其他技术实现细节。", + "复杂问题拆成 2 至 5 个简短段落,每个 paragraphs[] 只表达一个主题。第一段必须直接给结论,后续段落再写依据、风险或建议。", + "回答必须针对问题中的对象、时间、需求或动作;禁止输出“可进一步关注”“建议持续跟踪”“资料可用于核验”等没有新增信息的套话。", + "如果 enumeration_requirements 非空,说明证据中存在与问题最相关的明确枚举表。必须逐项覆盖其中每个 label,不得合并、概括或遗漏,也不得增加表中没有的项目。", + "需要层级时,可让段落分别以“结论:”“依据:”“风险:”“建议:”或“下一步:”开头;简单事实问题使用 1 至 2 段,不机械套用全部标签。", + "如果多个证据对同一事实表述不一致,必须指出差异;不得自行选取一个版本。", + "每个非资料不足段落都必须提供 citation_ids,ID 必须逐字来自 evidence。", + "完整回答正文控制在 900 个中文字符以内,优先保证 JSON 完整闭合。", + ]; + const qaPayload = { + question, + conversation_history: safeConversationHistory, + conversation_memory: compactText(conversationMemory, 3000), + company: { name: company.name, industry: company.industry }, + retrieval_plan: { + ...analyzeQaQuestion(question, safeConversationHistory), + answerability: support, + }, + enumeration_requirements: enumerationRequirements, + evidence: allowedEvidence, + output_schema: { + paragraphs: [{ text: "回答段落", citation_ids: ["evidence_id"] }], + insufficient: false, + }, + }; + const callQaModel = ({ + operation, + maxTokens, + jsonRetry = false, + jsonRepairContent = "", + validationFeedback = [], + }) => this.modelProvider.callJson({ + operation, + maxTokens, + system: [ + ...qaSystem, + ...(jsonRetry + ? [ + "上一轮响应因 JSON 未完整闭合而无法解析。本轮必须返回完整 JSON。", + "最多输出 4 个段落,每段不超过 180 个中文字符;不得省略 citation_ids 和 insufficient。", + ] + : []), + ...(jsonRepairContent + ? [ + "你正在修复上一轮模型生成的无效 JSON。只修复 JSON 语法、闭合和转义问题,不得新增、删除或改写回答事实。", + "必须保留原回答段落、citation_ids 和 insufficient;引用仍须来自 evidence[].id。", + "只输出修复后的完整 JSON,不得解释修复过程。", + ] + : []), + ...(validationFeedback.length + ? [ + "上一轮回答未通过结构与引用校验。本轮必须根据 validation_feedback 逐项修正后重新输出完整 JSON。", + "每个非资料不足段落都必须给出 citation_ids,并且只能逐字复制 evidence[].id;不得使用来源序号、标题或自行编造的 ID。", + "如果 validation_feedback 指出遗漏枚举项,必须按 enumeration_requirements 逐项补齐。", + ] + : []), + ].join("\n"), + payload: { + ...qaPayload, + ...(jsonRepairContent ? { invalid_json_content: jsonRepairContent } : {}), + ...(validationFeedback.length ? { validation_feedback: validationFeedback } : {}), + }, + }); + let result = await this.trackProviderStep(providerRunId, { + provider: "model", + operation: "answer_sales_question", + input_summary: `基于 ${allowedEvidence.length} 条允许引用证据和 ${safeConversationHistory.length} 条对话上下文回答 ${company.name} 的资料问题`, + output_summary: "模型已返回结构化逐段回答。", + }, () => callQaModel({ + operation: "sales_qa", + maxTokens: 1600, + })); + if (!result.ok && result.error?.code === "invalid_json") { + const invalidContent = String(result.invalid_content || "").trim(); + if (invalidContent) { + result = await this.trackProviderStep(providerRunId, { + provider: "model", + operation: "repair_sales_question_json", + input_summary: `修复 ${company.name} 首次问答响应的 JSON 语法`, + output_summary: "模型已修复并返回完整结构化回答。", + }, () => callQaModel({ + operation: "sales_qa_json_repair", + maxTokens: 2200, + jsonRepairContent: invalidContent, + })); + } + } + if (!result.ok && result.error?.code === "invalid_json") { + result = await this.trackProviderStep(providerRunId, { + provider: "model", + operation: "retry_sales_question", + input_summary: `首次回答 JSON 未完整闭合,使用更高输出预算重试 ${company.name} 的资料问题`, + output_summary: "模型重试后已返回完整结构化回答。", + }, () => callQaModel({ + operation: "sales_qa_retry", + maxTokens: 2200, + jsonRetry: true, + })); + } + let validated = result.ok + ? validateQaModelAnswer(result.parsed, allowedEvidence, { enumerationRequirements, question }) + : null; + const validationErrors = validated?.errors || []; + if (result.ok && validationErrors.length) { + result = await this.trackProviderStep(providerRunId, { + provider: "model", + operation: "retry_invalid_qa_answer", + input_summary: `首次回答未通过结构或引用校验,重试 ${company.name} 的资料问题`, + output_summary: "模型重试后已返回修正引用与结构的回答。", + }, () => callQaModel({ + operation: "sales_qa_quality_retry", + maxTokens: 2200, + validationFeedback: validationErrors, + })); + validated = result.ok + ? validateQaModelAnswer(result.parsed, allowedEvidence, { enumerationRequirements, question }) + : null; + } + if (result.ok) { + if (validated.errors.length) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("model", "The model returned an answer with invalid or missing citations.", { + validation_errors: validated.errors, + }); + } + } else { + return { + id: makeId("qa_assistant"), + role: "assistant", + text: compactText(validated.text, 1800), + paragraphs: validated.paragraphs, + citation_ids: validated.citation_ids, + citations: validated.citations, + insufficient: validated.insufficient, + created_at: nowIso(), + }; + } + } else if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("model", "The model provider did not return a valid answer.", { + reason: result.error?.code || "provider_error", + }); + } + } catch (error) { + if (this.runtimePolicy.fail_closed) { + if (error instanceof HttpError) throw error; + throw providerUnavailable("model", "Question answering failed.", { + reason: error.message || "provider_error", + }); + } + } + } + + if (!this.modelProvider?.isRunEnabled?.()) { + await this.skipProviderStep(providerRunId, { + provider: "model", + operation: "answer_sales_question", + input_summary: `回答 ${company.name} 的资料问题`, + output_summary: "模型 Provider 未启用。", + error: { code: "provider_disabled", message: "Model provider is not enabled." }, + }); + } + + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("model", "The model provider did not return an answer."); + } + + const hasMaterials = (company.material_ids || []).length > 0; + const fallbackText = dossier + ? hasMaterials + ? `基于当前档案和历史资料,${company.name} 当前重点线索是:${dossier.memory_summary || dossier.summary}` + : `基于当前最新档案,${company.name} 当前重点线索是:${dossier.memory_summary || dossier.summary}` + : hasMaterials + ? `当前资料不足,只能确认 ${company.name} 已在目标企业池中,尚需生成最新档案。` + : `当前企业为新加入目标企业,暂无历史资料;请先生成最新档案后再围绕当前进展提问。`; + const fallbackCitationIds = dossier + ? [...new Set(firstJsonArray(dossier.body).flatMap((paragraph) => firstJsonArray(paragraph.citation_ids)))] + .filter((id) => allowedEvidence.some((item) => String(item.id) === String(id))) + .slice(0, 4) + : []; + const fallbackCitations = fallbackCitationIds + .map((id) => allowedEvidence.find((item) => String(item.id) === String(id))) + .filter(Boolean); + return { + id: makeId("qa_assistant"), + role: "assistant", + text: fallbackText, + paragraphs: [{ text: fallbackText, citation_ids: fallbackCitationIds }], + citation_ids: fallbackCitationIds, + citations: fallbackCitations, + insufficient: !dossier, + created_at: nowIso(), + }; + } + + async captureQaSession(company, userMessage, assistantMessage, contexts) { + if (!this.openVikingProvider?.isRunEnabled?.()) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking session capture is not enabled."); + } + return null; + } + const preferredSessionId = this.openVikingSessionId(company); + try { + const result = await this.openVikingProvider.addSessionMessages(preferredSessionId, [ + { role: "user", content: encodeQaSessionMessage(userMessage) }, + { role: "assistant", content: encodeQaSessionMessage(assistantMessage) }, + ]); + const sessionId = result.session_id || preferredSessionId; + if (result.ok && sessionId && company.qa_session_id !== sessionId) { + company.qa_session_id = sessionId; + company.updated_at = nowIso(); + if (this.persistence.enabled && this.repository) { + await this.persist(() => this.repository.persistSalesCompany(company)); + } + } + if (result.ok && contexts?.length) { + await this.openVikingProvider.recordSessionUsed(sessionId, contexts.map((item) => item.uri).filter(Boolean)); + } + if (!result.ok && this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking session capture failed.", { + reason: result.error?.code || "provider_error", + }); + } + return { ...result, session_id: sessionId }; + } catch (error) { + if (this.runtimePolicy.fail_closed) { + if (error instanceof HttpError) throw error; + throw providerUnavailable("openviking", "OpenViking session capture failed.", { + reason: error.message || "provider_error", + }); + } + return { + ok: false, + error: { code: error.message || "provider_error" }, + }; + } + } + + async commitQaMemory(companyId) { + const company = this.requireCompany(companyId); + if (!this.openVikingProvider?.isRunEnabled?.()) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking session commit is not enabled."); + } + return { status: "skipped", summary: "OpenViking 写入未启用。" }; + } + const sessionId = this.openVikingSessionId(company); + const job = await this.startJob({ + job_type: "sales_qa_memory_commit", + entity_type: "target_enterprise", + entity_id: company.id, + max_attempts: 1, + request: { session_id: sessionId }, + }); + let run = null; + try { + run = await this.providerRuns.startRun({ + operation: "qa_memory_commit", + entity_type: "target_enterprise", + entity_id: company.id, + job_id: job.id, + }); + const result = await this.trackProviderStep(run.id, { + provider: "openviking", + operation: "commit_session_memory", + input_summary: `提交 ${company.name} 的资料问答会话`, + output_summary: "问答会话已提交至 OpenViking。", + }, () => this.openVikingProvider.commitSession(sessionId)); + if (!result.ok && this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking session commit failed.", { + reason: result.error?.code || "provider_error", + }); + } + const record = { + status: result.ok ? "ready" : "failed", + raw_ref: result.raw_ref || null, + summary: result.ok ? "资料问答会话已提交,OpenViking 将异步抽取长期记忆。" : `OpenViking session commit 失败:${result.error?.code || "provider_error"}`, + }; + await this.trackProviderStep(run.id, { + provider: "supabase", + operation: "persist_qa_memory_ref", + input_summary: `保存 ${company.name} 的会话记忆提交状态`, + output_summary: "会话记忆提交状态已保存。", + }, async () => { + await this.persist(() => this.repository.persistSalesOpenVikingRef({ + company_id: company.id, + related_type: "qa_session", + related_id: sessionId, + ref_kind: "session_commit", + uri: record.raw_ref || "", + summary: record.summary, + payload_json: record, + })); + return { ok: true, provider: "supabase", provider_mode: this.persistence.enabled ? "real" : "disabled" }; + }); + await this.providerRuns.completeRun(run.id, { result_ref: `qa-memory:${company.id}:${record.status}` }); + await this.completeJob(job.id, { + result_ref: `qa-memory:${company.id}:${record.status}`, + result: { status: record.status }, + }); + return { + status: record.status, + summary: record.summary, + provider_run_id: run.id, + job_id: job.id, + }; + } catch (error) { + if (run) { + try { + await this.providerRuns.failRun(run.id, { + code: error.code || "qa_memory_commit_failed", + message: error.message || "QA memory commit failed.", + category: error.category || "workflow", + retryable: error.retryable, + }); + } catch (persistenceError) { + if (this.runtimePolicy.fail_closed) throw persistenceError; + } + } + await this.failJob(job.id, error); + throw error; + } + } + + openVikingSessionId(company) { + if (company.qa_session_id) return company.qa_session_id; + if (typeof this.openVikingProvider?.salesSessionId === "function") { + return this.openVikingProvider.salesSessionId({ + workspaceId: this.workspaceId, + companyId: company.id, + }); + } + return company.qa_session_id || `sales-${company.id}`; + } +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/sync/materialSync.js b/demohouse/sales-intelligence-workbench/backend/src/sync/materialSync.js new file mode 100644 index 00000000..bace8845 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/sync/materialSync.js @@ -0,0 +1,209 @@ +import { createHash } from "node:crypto"; + +const SOURCE_TYPE_ALIASES = new Map([ + ["feishu_doc", "feishu_doc"], + ["feishu_document", "feishu_doc"], + ["飞书云文档", "feishu_doc"], + ["feishu_p2p", "feishu_p2p"], + ["飞书单聊", "feishu_p2p"], + ["feishu_chat", "feishu_chat"], + ["飞书群聊", "feishu_chat"], + ["飞书会话", "feishu_chat"], + ["feishu_search", "feishu_search"], + ["飞书消息搜索", "feishu_search"], + ["manual", "manual"], + ["手工导入", "manual"], +]); +const MATERIAL_SNAPSHOT_PATTERN = //; + +function normalizedText(value) { + return String(value || "") + .normalize("NFKC") + .replace(/\r\n?/g, "\n") + .replace(/[\t ]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function digest(value) { + return createHash("sha256").update(String(value || ""), "utf8").digest("hex"); +} + +function canonicalUrl(value) { + const raw = String(value || "").trim(); + if (!/^https?:\/\//i.test(raw)) return raw; + try { + const url = new URL(raw); + url.hash = ""; + url.search = ""; + return url.toString().replace(/\/$/, ""); + } catch { + return raw; + } +} + +function feishuDocumentToken(value) { + const raw = String(value || "").trim(); + const match = raw.match(/\/(?:wiki|docx)\/([^/?#]+)/i); + return match?.[1] || raw; +} + +function safeSourceConfig(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const result = {}; + for (const [key, item] of Object.entries(value)) { + if (/secret|token|api.?key|authorization|cookie|password|credential/i.test(key)) continue; + if (["string", "number", "boolean"].includes(typeof item) || item === null) result[key] = item; + } + return result; +} + +export function normalizeMaterialSourceType(value) { + const normalized = String(value || "").trim().toLowerCase(); + return SOURCE_TYPE_ALIASES.get(normalized) + || SOURCE_TYPE_ALIASES.get(String(value || "").trim()) + || normalized.replace(/[^a-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "") + || "manual"; +} + +export function normalizeExternalId(sourceType, value) { + const type = normalizeMaterialSourceType(sourceType); + const raw = String(value || "").trim(); + if (type === "feishu_doc") return feishuDocumentToken(raw); + if (/^https?:\/\//i.test(raw)) return canonicalUrl(raw); + return raw; +} + +export function makeSyncSourceId(sourceType, externalId) { + const type = normalizeMaterialSourceType(sourceType); + const external = normalizeExternalId(type, externalId); + if (!external) throw new Error("external_id is required to build a stable sync source id."); + return `sync_${digest(`${type}\n${external}`).slice(0, 32)}`; +} + +export function makeMaterialId(companyId, sourceId) { + const company = String(companyId || "").trim(); + const source = String(sourceId || "").trim(); + if (!company || !source) throw new Error("company_id and source_id are required to build a material id."); + return `mat_${digest(`${company}\n${source}`).slice(0, 32)}`; +} + +export function normalizeSourceItems(items = []) { + return (Array.isArray(items) ? items : []) + .map((item) => { + const content = normalizedText(item?.content || item?.text); + const occurredAt = String(item?.occurred_at || item?.create_time || "").trim(); + const sender = normalizedText(item?.sender || item?.sender_name); + const sourceUrl = canonicalUrl(item?.source_url || item?.message_app_link); + const fallbackIdentity = `${occurredAt}\n${sender}\n${content}\n${sourceUrl}`; + const id = String(item?.id || item?.message_id || `item_${digest(fallbackIdentity).slice(0, 24)}`).trim(); + return { + id, + occurred_at: occurredAt || null, + sender, + content, + source_url: sourceUrl, + deleted: Boolean(item?.deleted), + }; + }) + .filter((item) => item.id && (item.content || item.deleted)); +} + +export function mergeSourceItems(existingItems = [], incomingItems = []) { + const merged = new Map(normalizeSourceItems(existingItems).map((item) => [item.id, item])); + for (const item of normalizeSourceItems(incomingItems)) { + if (item.deleted) merged.delete(item.id); + else merged.set(item.id, item); + } + return [...merged.values()].sort((a, b) => { + const timeOrder = String(a.occurred_at || "").localeCompare(String(b.occurred_at || "")); + return timeOrder || a.id.localeCompare(b.id); + }); +} + +export function renderSourceItems(items = []) { + return normalizeSourceItems(items) + .filter((item) => !item.deleted) + .map((item) => [ + `[${item.occurred_at || "时间未知"}] ${item.sender || "未知发送者"}:${item.content}`, + item.source_url ? `消息链接:${item.source_url}` : "", + ].filter(Boolean).join("\n")) + .join("\n\n"); +} + +export function encodeMaterialSnapshot(input = {}) { + const snapshot = { + title: normalizedText(input.title), + source_type: normalizeMaterialSourceType(input.source_type), + source_url: canonicalUrl(input.source_url), + source_id: String(input.source_id || "").trim(), + source_external_id: String(input.source_external_id || "").trim(), + source_version: String(input.source_version || "").trim(), + summary: normalizedText(input.summary), + text: normalizedText(input.text || input.raw_text || input.content), + source_items: normalizeSourceItems(input.source_items || input.items), + occurred_at: String(input.occurred_at || "").trim() || null, + }; + return ``; +} + +export function decodeMaterialSnapshot(content) { + const text = String(content || ""); + const encoded = text.match(MATERIAL_SNAPSHOT_PATTERN)?.[1]; + if (!encoded) return null; + try { + const parsed = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + return { + title: normalizedText(parsed.title), + source_type: normalizeMaterialSourceType(parsed.source_type), + source_url: canonicalUrl(parsed.source_url), + source_id: String(parsed.source_id || "").trim(), + source_external_id: String(parsed.source_external_id || "").trim(), + source_version: String(parsed.source_version || "").trim(), + summary: normalizedText(parsed.summary), + text: normalizedText(parsed.text), + source_items: normalizeSourceItems(parsed.source_items), + occurred_at: String(parsed.occurred_at || "").trim() || null, + }; + } catch { + return null; + } +} + +export function makeMaterialContentHash(input = {}) { + const canonical = { + title: normalizedText(input.title), + source_url: canonicalUrl(input.source_url), + summary: normalizedText(input.summary), + text: normalizedText(input.text || input.raw_text || input.content), + occurred_at: String(input.occurred_at || "").trim() || null, + source_items: normalizeSourceItems(input.source_items || input.items), + }; + return digest(JSON.stringify(canonical)); +} + +export function buildMaterialSyncIdentity(companyId, body = {}) { + const source = body.source && typeof body.source === "object" ? body.source : {}; + const sourceType = normalizeMaterialSourceType(source.type || body.source_type); + const title = normalizedText(body.title); + const sourceUrl = canonicalUrl(source.url || body.source_url || body.url); + const suppliedExternalId = source.external_id || body.external_id || sourceUrl; + const externalId = normalizeExternalId( + sourceType, + suppliedExternalId || `manual:${digest(title || normalizedText(body.raw_text || body.text)).slice(0, 24)}`, + ); + const sourceId = makeSyncSourceId(sourceType, externalId); + return { + source_id: sourceId, + material_id: makeMaterialId(companyId, sourceId), + source_type: sourceType, + external_id: externalId, + display_name: normalizedText(source.display_name || title || externalId).slice(0, 160), + source_url: sourceUrl, + checkpoint_key: normalizedText(source.checkpoint_key || body.checkpoint_key || "latest").slice(0, 120), + checkpoint_value: normalizedText(source.checkpoint_value || body.checkpoint_value).slice(0, 500), + source_version: normalizedText(source.version || body.source_version || source.checkpoint_value || body.checkpoint_value).slice(0, 200), + config: safeSourceConfig(source.config), + }; +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/utils/http.js b/demohouse/sales-intelligence-workbench/backend/src/utils/http.js new file mode 100644 index 00000000..251fac77 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/utils/http.js @@ -0,0 +1,132 @@ +import { makeRequestId } from "./ids.js"; + +export class HttpError extends Error { + constructor(status, code, message, details = {}) { + super(message); + this.status = status; + this.code = code; + this.details = details; + } +} + +export function parseAllowedOrigins(value = "") { + return String(value || "") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); +} + +export function isOriginAllowed(req, allowedOrigins = []) { + const origin = String(req?.headers?.origin || "").trim(); + if (!origin) return true; + try { + const originUrl = new URL(origin); + const requestHost = String(req?.headers?.host || "").trim().toLowerCase(); + if (requestHost && originUrl.host.toLowerCase() === requestHost) return true; + } catch { + return false; + } + return allowedOrigins.includes(origin); +} + +export function withCors(req, res, allowedOrigins = []) { + const origin = String(req?.headers?.origin || "").trim(); + if (!origin || !allowedOrigins.includes(origin)) return; + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Vary", "Origin"); + res.setHeader("Access-Control-Allow-Credentials", "true"); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization,X-CSRF-Token"); + res.setHeader("Access-Control-Max-Age", "600"); +} + +export function withSecurityHeaders(res, options = {}) { + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); + res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()"); + res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + res.setHeader("Content-Security-Policy", "default-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"); + if (options.api) res.setHeader("Cache-Control", "no-store"); +} + +export function sendJson(res, status, payload, headers = {}) { + for (const [name, value] of Object.entries(headers)) res.setHeader(name, value); + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(payload)); +} + +export function ok(res, data, meta = {}) { + sendJson(res, 200, { + data, + meta: { + request_id: meta.request_id || makeRequestId(), + ...meta, + }, + }); +} + +export function created(res, data, meta = {}) { + sendJson(res, 201, { + data, + meta: { + request_id: meta.request_id || makeRequestId(), + ...meta, + }, + }); +} + +export function accepted(res, data, meta = {}) { + sendJson(res, 202, { + data, + meta: { + request_id: meta.request_id || makeRequestId(), + ...meta, + }, + }); +} + +export function fail(res, error, requestId = makeRequestId()) { + const status = error instanceof HttpError ? error.status : 500; + const code = error instanceof HttpError ? error.code : "internal_error"; + const message = error instanceof HttpError ? error.message : "Unexpected server error."; + const details = error instanceof HttpError ? error.details : {}; + sendJson(res, status, { + error: { + code, + message, + details, + }, + meta: { + request_id: requestId, + }, + }); +} + +export async function readJson(req, options = {}) { + const maxBytes = Math.max(1024, Number(options.maxBytes) || 1024 * 1024); + const declaredLength = Number(req.headers?.["content-length"] || 0); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new HttpError(413, "payload_too_large", `Request body exceeds the ${maxBytes}-byte limit.`); + } + const chunks = []; + let totalBytes = 0; + for await (const chunk of req) { + totalBytes += chunk.length; + if (totalBytes > maxBytes) { + throw new HttpError(413, "payload_too_large", `Request body exceeds the ${maxBytes}-byte limit.`); + } + chunks.push(chunk); + } + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw.trim()) return {}; + try { + return JSON.parse(raw); + } catch { + throw new HttpError(400, "bad_request", "Request body must be valid JSON."); + } +} + +export function parseUrl(req) { + return new URL(req.url, "http://localhost"); +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/utils/ids.js b/demohouse/sales-intelligence-workbench/backend/src/utils/ids.js new file mode 100644 index 00000000..825afc17 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/utils/ids.js @@ -0,0 +1,12 @@ +let requestCounter = 0; +let entityCounter = 0; + +export function makeRequestId() { + requestCounter += 1; + return `req_${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}_${String(requestCounter).padStart(6, "0")}`; +} + +export function makeId(prefix) { + entityCounter += 1; + return `${prefix}_${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}_${String(entityCounter).padStart(6, "0")}`; +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/utils/time.js b/demohouse/sales-intelligence-workbench/backend/src/utils/time.js new file mode 100644 index 00000000..a8323663 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/utils/time.js @@ -0,0 +1,14 @@ +export function nowIso() { + return new Date().toISOString(); +} + +export function nowLabel() { + return "刚刚"; +} + +export function isoFromLocal(value) { + if (!value || value === "尚未运行" || value === "刚刚") return null; + const normalized = String(value).replace(" ", "T"); + const date = new Date(`${normalized}:00+08:00`); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} diff --git a/demohouse/sales-intelligence-workbench/backend/src/worker.js b/demohouse/sales-intelligence-workbench/backend/src/worker.js new file mode 100644 index 00000000..8787d2ac --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/worker.js @@ -0,0 +1,24 @@ +import { createRuntimeContext } from "./app.js"; +import { JobWorker } from "./workers/jobWorker.js"; + +const context = createRuntimeContext(); +const enabled = ["1", "true", "yes", "on"].includes( + String(context.env.value("ASYNC_JOBS_ENABLED", "true")).toLowerCase(), +); + +if (!enabled) { + console.log("sales-job-worker disabled by ASYNC_JOBS_ENABLED"); + process.exit(0); +} + +const worker = new JobWorker({ + repository: context.salesRepository, + salesService: context.salesService, + env: context.env, +}); + +const stop = () => worker.stop(); +process.on("SIGTERM", stop); +process.on("SIGINT", stop); + +await worker.run(); diff --git a/demohouse/sales-intelligence-workbench/backend/src/workers/jobWorker.js b/demohouse/sales-intelligence-workbench/backend/src/workers/jobWorker.js new file mode 100644 index 00000000..ca292196 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/src/workers/jobWorker.js @@ -0,0 +1,176 @@ +import os from "node:os"; + +const SUPPORTED_JOB_TYPES = Object.freeze([ + "sales_dossier_generation", + "sales_material_openviking_sync", +]); + +function positiveInteger(value, fallback, minimum = 1) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= minimum ? parsed : fallback; +} + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function safeError(error) { + return { + code: String(error?.code || "worker_execution_failed").slice(0, 120), + message: String(error?.message || "后台任务执行失败。").slice(0, 500), + category: String(error?.category || "workflow").slice(0, 80), + retryable: Boolean(error?.retryable || Number(error?.status || 0) >= 500), + }; +} + +function shouldRetryClaim(error) { + return [ + "paid_workflow_concurrency_exceeded", + "usage_guard_unavailable", + "provider_timeout", + "supabase_unavailable", + ].includes(String(error?.code || "")) || Boolean(error?.retryable); +} + +function retryDelaySeconds(error, attemptCount, random = Math.random) { + if (String(error?.code || "") === "paid_workflow_concurrency_exceeded") return 30; + const attempt = Math.max(1, Number(attemptCount || 1)); + const base = Math.min(60, 5 * (2 ** Math.max(0, attempt - 1))); + const jitter = Math.floor(base * 0.25 * Math.max(0, Math.min(1, Number(random()) || 0))); + return base + jitter; +} + +export class JobWorker { + constructor(options = {}) { + this.repository = options.repository; + this.salesService = options.salesService; + this.env = options.env; + this.workerId = options.workerId + || this.env?.value?.("JOB_WORKER_ID", "") + || `${os.hostname()}:${process.pid}`; + this.pollMs = positiveInteger(this.env?.value?.("JOB_WORKER_POLL_MS", "1000"), 1000, 100); + this.leaseSeconds = positiveInteger(this.env?.value?.("JOB_WORKER_LEASE_SECONDS", "600"), 600, 60); + this.heartbeatMs = Math.max(5_000, Math.min(30_000, Math.floor((this.leaseSeconds * 1000) / 3))); + this.jobTypes = options.jobTypes || SUPPORTED_JOB_TYPES; + this.logger = options.logger || console; + this.random = options.random || Math.random; + this.stopped = false; + } + + async assertReady() { + if (!this.repository || typeof this.repository.claimNextJob !== "function") { + throw new Error("Persistent asynchronous job queue is not configured."); + } + if (!this.salesService || typeof this.salesService.executeQueuedJob !== "function") { + throw new Error("Sales job executor is not configured."); + } + await this.salesService.assertRuntimeReady(); + } + + async runOnce() { + const job = await this.repository.claimNextJob(this.workerId, this.jobTypes, this.leaseSeconds); + if (!job) return { claimed: false }; + + let stage = job.stage || "starting"; + let progress = Number(job.progress || 1); + let heartbeatFailure = null; + let heartbeatBusy = false; + const heartbeat = async (nextStage = stage, nextProgress = progress) => { + if (heartbeatFailure) throw heartbeatFailure; + stage = nextStage; + progress = nextProgress; + const updated = await this.repository.heartbeatJob( + job.id, + this.workerId, + stage, + progress, + this.leaseSeconds, + ); + stage = updated.stage || stage; + progress = Number(updated.progress ?? progress); + return updated; + }; + const saveCheckpoint = async (checkpointPatch = {}, options = {}) => { + if (typeof this.repository.saveJobCheckpoint !== "function") { + throw new Error("Persistent job checkpoints are not configured."); + } + stage = options.stage || stage; + progress = Number(options.progress ?? progress); + const updated = await this.repository.saveJobCheckpoint( + job.id, + this.workerId, + checkpointPatch, + { + stage, + progress, + detail: options.detail || {}, + lease_seconds: this.leaseSeconds, + }, + ); + stage = updated.stage || stage; + progress = Number(updated.progress ?? progress); + job.checkpoint = updated.checkpoint || job.checkpoint || {}; + job.progress_detail = updated.progress_detail || job.progress_detail || {}; + return updated; + }; + const heartbeatTimer = setInterval(() => { + if (heartbeatBusy || heartbeatFailure) return; + heartbeatBusy = true; + heartbeat().catch((error) => { + heartbeatFailure = error; + }).finally(() => { + heartbeatBusy = false; + }); + }, this.heartbeatMs); + heartbeatTimer.unref?.(); + + try { + await heartbeat("starting", 2); + const result = await this.salesService.executeQueuedJob(job, { + worker_id: this.workerId, + report_progress: heartbeat, + save_checkpoint: saveCheckpoint, + }); + if (heartbeatFailure) throw heartbeatFailure; + return { claimed: true, job_id: job.id, status: "succeeded", result }; + } catch (error) { + const latest = await this.repository.getJob(job.id).catch(() => null); + let finalStatus = latest?.status || "failed"; + if (!["succeeded", "failed", "cancelled"].includes(latest?.status)) { + const released = await this.repository.releaseJobClaim(job.id, this.workerId, safeError(error), { + retry: shouldRetryClaim(error), + delay_seconds: retryDelaySeconds(error, latest?.attempt_count || job.attempt_count, this.random), + }); + finalStatus = released?.status || finalStatus; + } + return { + claimed: true, + job_id: job.id, + status: finalStatus, + error: safeError(error), + }; + } finally { + clearInterval(heartbeatTimer); + } + } + + async run() { + await this.assertReady(); + this.logger.info?.(`sales-job-worker ready (${this.workerId})`); + while (!this.stopped) { + try { + const result = await this.runOnce(); + if (!result.claimed) await sleep(this.pollMs); + } catch (error) { + this.logger.error?.(`sales-job-worker poll failed: ${String(error?.code || error?.message || "unknown_error")}`); + await sleep(this.pollMs); + } + } + } + + stop() { + this.stopped = true; + } +} + +export { SUPPORTED_JOB_TYPES }; diff --git a/demohouse/sales-intelligence-workbench/backend/tests/adminStatusService.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/adminStatusService.test.mjs new file mode 100644 index 00000000..2649842b --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/adminStatusService.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { AdminStatusService } from "../src/services/adminStatusService.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +const strictRuntimePolicy = Object.freeze({ + fail_closed: true, + http_auth_enabled: true, +}); + +test("admin status reports only safe deployment, backup and live-doctor metadata", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "sales-admin-status-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const backupDir = path.join(root, "backups"); + const packageDir = path.join(backupDir, "supabase-test"); + await fs.mkdir(packageDir, { recursive: true }); + await fs.writeFile(path.join(packageDir, "manifest.json"), JSON.stringify({ + format_version: 1, + backup_id: "backup-safe-1", + created_at: "2026-07-22T01:00:00.000Z", + row_counts: { companies: 2, dossiers: 3 }, + files: [{ path: "data.json", sha256: "a".repeat(64) }], + })); + const doctorFile = path.join(root, "doctor-live.json"); + await fs.writeFile(doctorFile, JSON.stringify({ + checked_at: new Date().toISOString(), + ok: false, + backend: { + runtime_ready: false, + blockers: ["web search failed"], + checks: { + model: { called: true, ok: true, provider_mode: "real" }, + web_search: { called: true, ok: false, provider_mode: "real", error: { code: "10500", message: "private detail" } }, + }, + }, + })); + + const service = new AdminStatusService({ + env: envReader({ + HOST: "127.0.0.1", + PORT: "8787", + APP_WORKSPACE_SLUG: "default", + APP_WORKSPACE_NAME: "Sales Workbench", + SALES_WORKBENCH_BACKUP_DIR: backupDir, + SALES_WORKBENCH_LIVE_DOCTOR_FILE: doctorFile, + AGENT_PLAN_API_KEY: "must-not-appear", + }), + runtimePolicy: strictRuntimePolicy, + getProviderStatus: () => ({ + repository: { active: "supabase" }, + providers: [{ id: "model", label: "Model", status: "configured", safe_config: { run_enabled: true } }], + }), + }); + + const status = await service.getStatus(); + assert.equal(status.read_only, true); + assert.equal(status.deployment.loopback_only, true); + assert.equal(status.deployment.http_auth_enabled, true); + assert.equal(status.backup.latest.backup_id, "backup-safe-1"); + assert.equal(status.backup.latest.row_count, 5); + assert.equal(status.backup.latest.checksums_declared, true); + assert.equal(status.live_doctor.status, "failed"); + assert.equal(status.live_doctor.checks[1].error_code, "10500"); + assert.doesNotMatch(JSON.stringify(status), /must-not-appear|private detail/); +}); + +test("admin status handles installations without a backup or doctor state path", async () => { + const service = new AdminStatusService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + getProviderStatus: () => ({ providers: [], repository: { active: "memory" } }), + }); + + const status = await service.getStatus(); + assert.equal(status.backup.configured, false); + assert.equal(status.backup.status, "unavailable"); + assert.equal(status.live_doctor.status, "unavailable"); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/agentPlanKey.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/agentPlanKey.test.mjs new file mode 100644 index 00000000..9d3ba8d1 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/agentPlanKey.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { DataProProvider } from "../src/providers/dataProProvider.js"; +import { ModelProvider } from "../src/providers/modelProvider.js"; +import { OpenVikingProvider } from "../src/providers/openVikingProvider.js"; +import { WebSearchProvider } from "../src/providers/webSearchProvider.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + number(name, fallback = 0) { + const value = Number(Object.hasOwn(values, name) ? values[name] : fallback); + return Number.isFinite(value) ? value : fallback; + }, + }; +} + +test("one Agent Plan key configures model, DataPro and web search", () => { + const env = envReader({ + AGENT_PLAN_API_KEY: "shared-agent-plan-key", + OPENVIKING_BASE_URL: "https://openviking.example.test", + OPENVIKING_CLI: "/definitely/not/an/openviking-cli", + }); + + assert.equal(new ModelProvider({ env }).apiKey, "shared-agent-plan-key"); + assert.equal(new DataProProvider({ env }).apiKey, "shared-agent-plan-key"); + assert.equal(new WebSearchProvider({ env }).apiKey, "shared-agent-plan-key"); + + const openViking = new OpenVikingProvider({ env, cliConfig: {} }); + assert.equal(openViking.apiKey, ""); + assert.equal(openViking.isConfigured(), false); +}); + +test("capability-specific keys remain optional overrides", () => { + const env = envReader({ + AGENT_PLAN_API_KEY: "shared-agent-plan-key", + MODEL_API_KEY: "model-override", + DATAPRO_API_KEY: "datapro-override", + WEB_SEARCH_API_KEY: "search-override", + OPENVIKING_API_KEY: "openviking-override", + }); + + assert.equal(new ModelProvider({ env }).apiKey, "model-override"); + assert.equal(new DataProProvider({ env }).apiKey, "datapro-override"); + assert.equal(new WebSearchProvider({ env }).apiKey, "search-override"); + assert.equal( + new OpenVikingProvider({ env, cliConfig: {} }).apiKey, + "openviking-override", + ); +}); + +test("OpenViking does not report a missing CLI command as configured", () => { + const provider = new OpenVikingProvider({ + env: envReader({ OPENVIKING_CLI: "/definitely/not/an/openviking-cli" }), + cliConfig: {}, + }); + + assert.equal(provider.isConfigured(), false); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/asyncJobWorker.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/asyncJobWorker.test.mjs new file mode 100644 index 00000000..e8212ea0 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/asyncJobWorker.test.mjs @@ -0,0 +1,498 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SalesService } from "../src/services/salesService.js"; +import { JobWorker } from "../src/workers/jobWorker.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +function salesState() { + return { + goals: [{ id: "goal-1", name: "测试目标", company_ids: ["company-1"] }], + companies: { + "company-1": { + id: "company-1", + name: "测试科技有限公司", + dossier_ids: [], + material_ids: [], + qa_session_id: "sales-company-1", + }, + }, + dossiers: {}, + materials: {}, + qa_messages: {}, + sync_sources: {}, + sync_checkpoints: {}, + jobs: {}, + }; +} + +const strictRuntimePolicy = Object.freeze({ + fail_closed: true, +}); + +const permissiveTestPolicy = Object.freeze({ + fail_closed: false, +}); + +test("enqueueing a dossier persists a queued job without reserving paid capacity", async () => { + const calls = []; + const repository = { + async getSalesState() { + return salesState(); + }, + async enqueueJob(job) { + calls.push({ operation: "enqueue", job }); + return job; + }, + }; + const paidWorkflowGuard = { + async reserve() { + calls.push({ operation: "reserve" }); + throw new Error("paid capacity must not be reserved while enqueueing"); + }, + }; + const service = new SalesService({ + env: envReader({ ASYNC_JOBS_ENABLED: "true", APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + repository, + paidWorkflowGuard, + }); + + await service.assertRuntimeReady(); + const job = await service.enqueueDossier("company-1", { idempotency_key: "request-1" }, { + created_by: "11111111-1111-4111-8111-111111111111", + }); + + assert.equal(job.status, "queued"); + assert.equal(job.stage_label, "等待执行"); + assert.equal(job.progress, 0); + assert.equal(calls.filter((call) => call.operation === "enqueue").length, 1); + assert.equal(calls.filter((call) => call.operation === "reserve").length, 0); + assert.equal(Object.hasOwn(job, "request"), false); + assert.equal(Object.hasOwn(job, "created_by"), false); + assert.equal(Object.hasOwn(job, "reservation_id"), false); +}); + +test("enqueueing reports a queue failure instead of returning a local-only queued job", async () => { + const repository = { + async getSalesState() { + return salesState(); + }, + async enqueueJob() { + throw new Error("rpc unavailable"); + }, + }; + const service = new SalesService({ + env: envReader({ ASYNC_JOBS_ENABLED: "true", APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + repository, + }); + + await service.assertRuntimeReady(); + await assert.rejects( + service.enqueueDossier("company-1"), + (error) => error?.status === 503 && error?.code === "job_queue_unavailable", + ); + assert.deepEqual(service.data.jobs, {}); +}); + +test("public job progress exposes only a compact user-facing detail", () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: permissiveTestPolicy, + }); + const job = service.publicJob({ + id: "job-progress", + job_type: "sales_dossier_generation", + status: "running", + stage: "collecting_professional", + progress: 34, + progress_detail: { + message: "正在核验专业资料 2/4", + current: 2, + total: 4, + provider: "datapro", + query: "private query", + worker_id: "worker-private", + }, + attempt_count: 1, + max_attempts: 3, + }); + + assert.deepEqual(job.stage_detail, { + message: "正在核验专业资料 2/4", + current: 2, + total: 4, + }); + assert.equal(Object.hasOwn(job.stage_detail, "provider"), false); + assert.equal(Object.hasOwn(job.stage_detail, "query"), false); + assert.equal(Object.hasOwn(job.stage_detail, "worker_id"), false); +}); + +test("API service can refresh dossier data written by a separate worker process", async () => { + let persisted = salesState(); + let reads = 0; + const repository = { + async getSalesState() { + reads += 1; + return structuredClone(persisted); + }, + }; + const service = new SalesService({ + env: envReader({ ASYNC_JOBS_ENABLED: "true", APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + repository, + }); + + await service.assertRuntimeReady(); + assert.deepEqual(service.listDossiers("company-1"), []); + + persisted = salesState(); + persisted.companies["company-1"].dossier_ids = ["dossier-worker-1"]; + persisted.dossiers["dossier-worker-1"] = { + id: "dossier-worker-1", + company_id: "company-1", + title: "测试科技有限公司企业档案", + summary: "后台 Worker 已生成并持久化最新企业档案。", + body: [ + { text: "企业与业务概览:测试科技有限公司面向企业客户提供软件与知识库产品。", citation_ids: ["p1"] }, + { text: "经营与业务动态:专业数据反映该企业持续推进内容检索与协作管理能力。", citation_ids: ["p2"] }, + { text: "近期公开动态:测试科技有限公司于2026年7月发布知识库产品升级公告。", citation_ids: ["w1", "w2"] }, + { text: "风险与关注事项:项目推进需在商务报价前确认数据权限、合同责任和交付排期。", citation_ids: ["p1", "w2"] }, + { text: "销售机会判断:产品升级形成试点窗口,但不代表企业已经形成采购意向。", citation_ids: ["p2", "w1"] }, + { text: "建议行动:1. 联系产品负责人核验范围。\n2. 确认数据权限边界。\n3. 准备试点方案。", citation_ids: ["p1", "w2"] }, + ], + citations: [ + { + id: "p1", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "测试科技有限公司经营企业软件与知识库产品。", + independence_key: "datapro-business", + }, + { + id: "p2", + label: "金融数据库", + source_kind: "专业数据集", + summary: "测试科技有限公司持续推进内容检索与协作管理业务。", + independence_key: "datapro-market", + }, + { + id: "w1", + label: "测试科技有限公司发布知识库产品升级公告", + source_kind: "联网搜索", + summary: "测试科技有限公司于2026年7月发布知识库产品升级公告。", + url: "https://news.test/company-update", + independence_key: "news.test", + }, + { + id: "w2", + label: "测试科技有限公司披露产品交付安排", + source_kind: "联网搜索", + summary: "测试科技有限公司披露知识库产品的分阶段交付安排。", + url: "https://official.test/company-delivery", + independence_key: "official.test", + }, + ], + version_no: 1, + change_status: "initial", + data_as_of: "2026-07-24T00:00:00.000Z", + generated_at: "2026-07-24T06:00:00.000Z", + created_at: "2026-07-24T06:00:00.000Z", + }; + + await service.refreshPersistedState({ force: true }); + + assert.equal(reads, 2); + assert.equal(service.listDossiers("company-1")[0].id, "dossier-worker-1"); + assert.equal(service.dossierDetail("dossier-worker-1").version_no, 1); +}); + +test("worker claims one job, reports progress and executes it once", async () => { + const calls = []; + let claimed = false; + let current = null; + const repository = { + async claimNextJob(workerId, jobTypes, leaseSeconds) { + calls.push({ operation: "claim", workerId, jobTypes, leaseSeconds }); + if (claimed) return null; + claimed = true; + current = { + id: "job-1", + job_type: "sales_dossier_generation", + entity_id: "company-1", + status: "running", + stage: "starting", + progress: 1, + worker_id: workerId, + }; + return current; + }, + async heartbeatJob(jobId, workerId, stage, progress) { + calls.push({ operation: "heartbeat", jobId, workerId, stage, progress }); + current = { ...current, stage, progress }; + return current; + }, + async getJob() { + return current; + }, + async releaseJobClaim() { + calls.push({ operation: "release" }); + }, + }; + const salesService = { + async assertRuntimeReady() {}, + async executeQueuedJob(job, options) { + calls.push({ operation: "execute", job }); + await options.report_progress("generating_dossier", 70); + current = { ...current, status: "succeeded", stage: "succeeded", progress: 100 }; + return { action: "created" }; + }, + }; + const worker = new JobWorker({ + repository, + salesService, + env: envReader({ JOB_WORKER_POLL_MS: "100", JOB_WORKER_LEASE_SECONDS: "600" }), + workerId: "worker-test", + logger: { info() {}, error() {} }, + }); + + await worker.assertReady(); + const result = await worker.runOnce(); + + assert.equal(result.status, "succeeded"); + assert.equal(calls.filter((call) => call.operation === "execute").length, 1); + assert.ok(calls.some((call) => call.operation === "heartbeat" && call.stage === "generating_dossier")); + assert.equal(calls.some((call) => call.operation === "release"), false); +}); + +test("worker requeues an unreserved task after a retryable claim failure", async () => { + const calls = []; + const job = { + id: "job-retry", + job_type: "sales_dossier_generation", + entity_id: "company-1", + status: "running", + stage: "starting", + progress: 1, + worker_id: "worker-test", + }; + const repository = { + async claimNextJob() { + return job; + }, + async heartbeatJob(jobId, workerId, stage, progress) { + return { ...job, id: jobId, worker_id: workerId, stage, progress }; + }, + async getJob() { + return job; + }, + async releaseJobClaim(jobId, workerId, error, options) { + calls.push({ jobId, workerId, error, options }); + return { ...job, status: "queued" }; + }, + }; + const salesService = { + async assertRuntimeReady() {}, + async executeQueuedJob() { + const error = new Error("capacity reached"); + error.code = "paid_workflow_concurrency_exceeded"; + throw error; + }, + }; + const worker = new JobWorker({ + repository, + salesService, + env: envReader(), + workerId: "worker-test", + logger: { info() {}, error() {} }, + }); + + const result = await worker.runOnce(); + assert.equal(result.status, "queued"); + assert.equal(calls.length, 1); + assert.equal(calls[0].options.retry, true); + assert.equal(calls[0].options.delay_seconds, 30); +}); + +test("worker persists a durable checkpoint before requeueing a retryable paid stage", async () => { + const calls = []; + let current = { + id: "job-checkpoint", + job_type: "sales_dossier_generation", + entity_id: "company-1", + status: "running", + stage: "collecting_professional", + progress: 18, + worker_id: "worker-test", + attempt_count: 1, + max_attempts: 3, + is_paid: true, + reservation_id: "reservation-test", + checkpoint: {}, + }; + const repository = { + async claimNextJob() { + return current; + }, + async heartbeatJob(jobId, workerId, stage, progress) { + current = { ...current, id: jobId, worker_id: workerId, stage, progress }; + calls.push({ operation: "heartbeat", stage, progress }); + return current; + }, + async saveJobCheckpoint(jobId, workerId, checkpoint, options) { + current = { + ...current, + id: jobId, + worker_id: workerId, + checkpoint: { ...current.checkpoint, ...checkpoint }, + stage: options.stage, + progress: options.progress, + progress_detail: options.detail, + }; + calls.push({ operation: "checkpoint", checkpoint, options }); + return current; + }, + async getJob() { + return current; + }, + async releaseJobClaim(jobId, workerId, error, options) { + calls.push({ operation: "release", jobId, workerId, error, options }); + current = { + ...current, + status: "queued", + stage: "retry_wait", + scheduled_at: "2026-07-30T12:00:05.000Z", + }; + return current; + }, + }; + const salesService = { + async assertRuntimeReady() {}, + async executeQueuedJob(_job, options) { + await options.save_checkpoint( + { + dossier: { + schema_version: 1, + company_id: "company-1", + evidence_collection: { + completed_query_keys: ["datapro:business"], + }, + }, + }, + { + stage: "collecting_professional", + progress: 24, + detail: { current: 1, total: 2, message: "正在核验专业资料 1/2" }, + }, + ); + const error = new Error("temporary upstream failure"); + error.code = "provider_timeout"; + error.category = "timeout"; + error.retryable = true; + throw error; + }, + }; + const worker = new JobWorker({ + repository, + salesService, + env: envReader(), + workerId: "worker-test", + logger: { info() {}, error() {} }, + random: () => 0, + }); + + const result = await worker.runOnce(); + + assert.equal(result.status, "queued"); + assert.deepEqual(current.checkpoint.dossier.evidence_collection.completed_query_keys, [ + "datapro:business", + ]); + assert.deepEqual(current.progress_detail, { + current: 1, + total: 2, + message: "正在核验专业资料 1/2", + }); + assert.deepEqual( + calls.filter((call) => call.operation === "checkpoint") + .map((call) => call.options.stage), + ["collecting_professional"], + ); + const released = calls.find((call) => call.operation === "release"); + assert.equal(released.options.retry, true); + assert.equal(released.options.delay_seconds, 5); + assert.equal(released.error.category, "timeout"); +}); + +test("running cancellation keeps the lease until the worker reaches a safe checkpoint", async () => { + const calls = []; + let current = { + id: "job-cancel", + job_type: "sales_dossier_generation", + entity_id: "company-1", + status: "running", + stage: "generating_dossier", + progress: 70, + worker_id: "worker-test", + is_paid: true, + reservation_id: "reservation-test", + }; + const initial = salesState(); + initial.jobs[current.id] = current; + const repository = { + async getSalesState() { + return initial; + }, + async getJob() { + return current; + }, + async requestJobCancellation() { + calls.push("request"); + current = { + ...current, + stage: "cancelling", + cancel_requested_at: "2026-07-23T12:00:00.000Z", + }; + return current; + }, + async acknowledgeJobCancellation(jobId, workerId) { + calls.push({ operation: "acknowledge", jobId, workerId }); + current = { + ...current, + status: "cancelled", + stage: "cancelled", + worker_id: null, + lease_expires_at: null, + }; + return current; + }, + }; + const service = new SalesService({ + env: envReader({ ASYNC_JOBS_ENABLED: "true", APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + repository, + }); + + await service.assertRuntimeReady(); + const requested = await service.cancelJob(current.id); + assert.equal(requested.status, "running"); + assert.equal(requested.stage, "cancelling"); + assert.equal(requested.worker_id, "worker-test"); + + await assert.rejects( + () => service.assertJobActive(current.id), + (error) => error.code === "job_cancelled", + ); + assert.equal(current.status, "cancelled"); + assert.deepEqual(calls, [ + "request", + { operation: "acknowledge", jobId: "job-cancel", workerId: "worker-test" }, + ]); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/asyncQueueMigration.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/asyncQueueMigration.test.mjs new file mode 100644 index 00000000..b24739fc --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/asyncQueueMigration.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const backendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const rootDir = path.resolve(backendDir, ".."); +const queueMigration = await fs.readFile( + path.join(rootDir, "supabase", "migrations", "202607230002_async_job_queue.sql"), + "utf8", +); +const cancellationMigration = await fs.readFile( + path.join(rootDir, "supabase", "migrations", "202607230003_safe_job_cancellation.sql"), + "utf8", +); +const terminalRunMigration = await fs.readFile( + path.join(rootDir, "supabase", "migrations", "202607290001_reconcile_terminal_job_provider_runs.sql"), + "utf8", +); +const durableCheckpointMigration = await fs.readFile( + path.join(rootDir, "supabase", "migrations", "202607300001_durable_job_checkpoints.sql"), + "utf8", +); +const smoke = await fs.readFile( + path.join(rootDir, "supabase", "tests", "202607230003_async_job_queue_smoke.sql"), + "utf8", +); + +test("asynchronous queue migration keeps claiming and paid execution atomic", () => { + assert.match(queueMigration, /for update skip locked/); + assert.match(queueMigration, /create or replace function public\.enqueue_sales_job/); + assert.match(queueMigration, /create or replace function public\.claim_sales_job/); + assert.match(queueMigration, /create or replace function public\.release_sales_job_claim/); + assert.match(queueMigration, /and not v_has_reservation/); + assert.match(queueMigration, /where j\.workspace_id = p_workspace_id[\s\S]*?and j\.status = 'running'/); +}); + +test("safe cancellation is delivered as a forward-only migration", () => { + assert.match(cancellationMigration, /values \('202607230003'/); + assert.match(cancellationMigration, /add column if not exists cancel_requested_at/); + assert.match(cancellationMigration, /create or replace function public\.heartbeat_sales_job/); + assert.match(cancellationMigration, /create or replace function public\.request_cancel_sales_job/); + assert.match(cancellationMigration, /create or replace function public\.acknowledge_cancel_sales_job/); + assert.match(cancellationMigration, /stage = 'cancelling'/); + assert.match(cancellationMigration, /create or replace function public\.retry_sales_job/); + assert.match(cancellationMigration, /create or replace function public\.finish_paid_workflow/); + assert.match(cancellationMigration, /to service_role/); + assert.match(cancellationMigration, /revoke all[\s\S]*?from public, anon, authenticated/); +}); + +test("terminal jobs close orphaned provider runs and active steps", () => { + assert.match(terminalRunMigration, /create or replace function public\.reconcile_terminal_job_provider_runs/); + assert.match(terminalRunMigration, /after update of status, error_json on public\.jobs/); + assert.match(terminalRunMigration, /update public\.provider_run_steps/); + assert.match(terminalRunMigration, /update public\.provider_runs/); + assert.match(terminalRunMigration, /and r\.status = 'running'/); + assert.match(terminalRunMigration, /values \('202607290001'/); + assert.match(terminalRunMigration, /Reconcile runs that were orphaned before this trigger was installed/); +}); + +test("durable job checkpoints preserve completed work and allow bounded paid-stage retries", () => { + assert.match(durableCheckpointMigration, /add column if not exists checkpoint_json jsonb/i); + assert.match(durableCheckpointMigration, /add column if not exists progress_detail_json jsonb/i); + assert.match(durableCheckpointMigration, /create or replace function public\.checkpoint_sales_job/i); + assert.match(durableCheckpointMigration, /checkpoint_json = j\.checkpoint_json \|\| v_patch/i); + assert.match(durableCheckpointMigration, /stage = case when v_should_retry then 'retry_wait' else 'failed' end/i); + assert.match(durableCheckpointMigration, /v_job\.attempt_count < v_job\.max_attempts/i); + assert.doesNotMatch( + durableCheckpointMigration, + /v_should_retry[\s\S]{0,120}not v_has_reservation/i, + ); + assert.match(durableCheckpointMigration, /release_reason'[\s\S]{0,180}'retryable_worker_failure'/i); + assert.match(durableCheckpointMigration, /values \('202607300001'/); +}); + +test("queue smoke covers safe retry, heartbeat, reservation and rollback", () => { + assert.match(smoke, /^begin;/m); + assert.match(smoke, /enqueue_sales_job/); + assert.match(smoke, /claim_sales_job/); + assert.match(smoke, /heartbeat_sales_job/); + assert.match(smoke, /release_sales_job_claim/); + assert.match(smoke, /request_cancel_sales_job/); + assert.match(smoke, /acknowledge_cancel_sales_job/); + assert.match(smoke, /reserve_paid_workflow/); + assert.match(smoke, /finish_paid_workflow/); + assert.match(smoke, /^rollback;/m); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/authService.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/authService.test.mjs new file mode 100644 index 00000000..6c839202 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/authService.test.mjs @@ -0,0 +1,254 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { AuthService } from "../src/security/authService.js"; + +const workspaceId = "54768bef-53aa-47d0-a9e3-bbca4593cf58"; +const userId = "11111111-2222-4333-8444-555555555555"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + number(name, fallback) { + const value = Number(this.value(name, fallback)); + return Number.isFinite(value) ? value : fallback; + }, + }; +} + +function responseRecorder() { + return { + headers: {}, + setHeader(name, value) { + this.headers[String(name).toLowerCase()] = value; + }, + }; +} + +function dataProviderFixture(role = null) { + const state = { + profiles: role ? [{ id: userId, display_name: "测试用户" }] : [], + members: role ? [{ workspace_id: workspaceId, user_id: userId, role }] : [], + workspaceUpdates: [], + }; + return { + state, + isConfigured: () => true, + async select(table) { + if (table === "app_workspace_members") return structuredClone(state.members); + if (table === "app_users") return structuredClone(state.profiles); + return []; + }, + async upsert(table, rows) { + if (table === "app_users") state.profiles = structuredClone(rows); + if (table === "app_workspace_members") state.members = structuredClone(rows); + return structuredClone(rows); + }, + async update(table, values, filters) { + state.workspaceUpdates.push({ table, values, filters }); + return []; + }, + }; +} + +function authFetchFixture() { + const calls = []; + return { + calls, + async fetch(url, options) { + const parsed = new URL(url); + calls.push({ pathname: parsed.pathname, search: parsed.search, method: options.method, body: options.body }); + if (parsed.pathname.endsWith("/admin/users") && options.method === "POST") { + return new Response(JSON.stringify({ id: userId, email: "owner@example.com" }), { status: 200 }); + } + if (parsed.pathname.endsWith(`/admin/users/${userId}`) && options.method === "GET") { + return new Response(JSON.stringify({ id: userId, email: "owner@example.com" }), { status: 200 }); + } + if (parsed.pathname.endsWith(`/admin/users/${userId}`) && options.method === "PUT") { + return new Response(JSON.stringify({ id: userId, email: "owner@example.com" }), { status: 200 }); + } + if (parsed.pathname.endsWith("/token") && parsed.searchParams.get("grant_type") === "password") { + return new Response(JSON.stringify({ + access_token: "access-token", + refresh_token: "refresh-token", + expires_in: 3600, + }), { status: 200 }); + } + if (parsed.pathname.endsWith("/token") && parsed.searchParams.get("grant_type") === "refresh_token") { + return new Response(JSON.stringify({ + access_token: "refreshed-access-token", + refresh_token: "rotated-refresh-token", + expires_in: 7200, + }), { status: 200 }); + } + if (parsed.pathname.endsWith("/user")) { + return new Response(JSON.stringify({ + id: userId, + email: "owner@example.com", + user_metadata: { display_name: "测试用户" }, + }), { status: 200 }); + } + return new Response(JSON.stringify({ message: "unexpected" }), { status: 500 }); + }, + }; +} + +function createService(provider, fetchFixture) { + return new AuthService({ + env: envReader({ + SUPABASE_API_URL: "https://supabase.example.test/rest/v1", + SUPABASE_SERVICE_ROLE_KEY: "service-role-secret", + APP_WORKSPACE_ID: workspaceId, + HTTP_AUTH_ENABLED: "true", + AUTH_BOOTSTRAP_ENABLED: "true", + }), + dataProvider: provider, + fetchImpl: fetchFixture.fetch, + }); +} + +test("first-run setup creates one confirmed local administrator without exposing email", async () => { + const provider = dataProviderFixture(); + const authFetch = authFetchFixture(); + const service = createService(provider, authFetch); + const response = responseRecorder(); + + const result = await service.bootstrap({ + username: "测试用户", + password: "a-secure-password", + }, response); + + assert.equal(result.authenticated, true); + assert.equal(result.user.username, "测试用户"); + assert.equal(Object.hasOwn(result.user, "email"), false); + assert.equal(Object.hasOwn(result.user, "role"), false); + assert.deepEqual(provider.state.members, [{ workspace_id: workspaceId, user_id: userId, role: "owner" }]); + assert.equal(provider.state.workspaceUpdates[0].values.created_by, userId); + const createBody = JSON.parse(authFetch.calls.find((call) => call.pathname.endsWith("/admin/users"))?.body || "{}"); + assert.equal(createBody.email_confirm, true); + assert.match(createBody.email, /^owner-[a-f0-9]{24}@sales-workbench\.invalid$/); + assert.equal(createBody.user_metadata.username, "测试用户"); + assert.equal(response.headers["set-cookie"].length, 3); + assert.match(response.headers["set-cookie"][0], /siw_access=.*HttpOnly.*SameSite=Strict/); + assert.match(response.headers["set-cookie"][1], /siw_refresh=.*Max-Age=31536000.*HttpOnly.*SameSite=Strict/); + assert.doesNotMatch(response.headers["set-cookie"].join(" | "), /service-role-secret/); +}); + +test("a valid long-lived cookie restores login after the short-lived access cookie expires", async () => { + const provider = dataProviderFixture("owner"); + const authFetch = authFetchFixture(); + const service = createService(provider, authFetch); + const response = responseRecorder(); + + const auth = await service.authenticateRequest({ + headers: { + cookie: "siw_refresh=refresh-token; siw_csrf=csrf-token", + }, + }, response); + + assert.equal(auth.source, "cookie"); + assert.equal(auth.principal.username, "测试用户"); + assert.ok(authFetch.calls.some( + (call) => call.pathname.endsWith("/token") + && call.search.includes("grant_type=refresh_token"), + )); + assert.match(response.headers["set-cookie"][0], /siw_access=refreshed-access-token/); + assert.match(response.headers["set-cookie"][1], /siw_refresh=rotated-refresh-token.*Max-Age=31536000/); +}); + +test("an expired Supabase JWT is reported as an expired session so clients can refresh", async () => { + const provider = dataProviderFixture("owner"); + const service = createService(provider, { + async fetch() { + return new Response(JSON.stringify({ + error_code: "bad_jwt", + msg: "invalid JWT: token is expired", + }), { status: 403 }); + }, + }); + + await assert.rejects( + () => service.authenticateRequest({ + headers: { authorization: "Bearer expired-access-token" }, + }, responseRecorder()), + (error) => error.status === 401 && error.code === "invalid_credentials", + ); +}); + +test("username login keeps authorization internal and supports the existing account binding", async () => { + const provider = dataProviderFixture("viewer"); + const authFetch = authFetchFixture(); + const service = createService(provider, authFetch); + const result = await service.login({ + username: "测试用户", + password: "a-secure-password", + }, responseRecorder()); + + assert.equal(result.user.username, "测试用户"); + assert.equal(Object.hasOwn(result.user, "role"), false); + const session = await service.passwordSession("测试用户", "a-secure-password"); + service.requireRole({ principal: session.principal }, "viewer"); + assert.throws( + () => service.requireRole({ principal: session.principal }, "member"), + (error) => error.status === 403 && error.code === "insufficient_role", + ); +}); + +test("legacy email credentials remain compatible without exposing email in the public session", async () => { + const provider = dataProviderFixture("owner"); + const authFetch = authFetchFixture(); + const service = createService(provider, authFetch); + const result = await service.login({ + email: "owner@example.com", + password: "a-secure-password", + }, responseRecorder()); + + assert.equal(result.authenticated, true); + assert.equal(result.user.username, "测试用户"); + assert.equal(Object.hasOwn(result.user, "email"), false); + assert.equal(Object.hasOwn(result.user, "role"), false); + assert.equal( + authFetch.calls.some((call) => call.pathname.endsWith(`/admin/users/${userId}`) && call.method === "GET"), + false, + ); +}); + +test("cookie-authenticated mutations require a matching CSRF token", () => { + const provider = dataProviderFixture("member"); + const service = createService(provider, authFetchFixture()); + const auth = { source: "cookie", principal: { id: userId, role: "member" } }; + + assert.throws( + () => service.assertCsrf({ headers: { cookie: "siw_csrf=expected", "x-csrf-token": "wrong" } }, auth), + (error) => error.status === 403 && error.code === "csrf_failed", + ); + assert.doesNotThrow(() => service.assertCsrf({ + headers: { cookie: "siw_csrf=expected", "x-csrf-token": "expected" }, + }, auth)); + assert.doesNotThrow(() => service.assertCsrf({ headers: {} }, { ...auth, source: "bearer" })); +}); + +test("CLI login and refresh return only user-scoped bearer sessions", async () => { + const provider = dataProviderFixture("member"); + const authFetch = authFetchFixture(); + const service = createService(provider, authFetch); + + const loggedIn = await service.cliLogin({ + username: "测试用户", + password: "a-secure-password", + }); + assert.equal(loggedIn.token_type, "bearer"); + assert.equal(loggedIn.access_token, "access-token"); + assert.equal(loggedIn.refresh_token, "refresh-token"); + assert.equal(loggedIn.user.username, "测试用户"); + assert.equal(Object.hasOwn(loggedIn.user, "role"), false); + assert.equal(Object.hasOwn(loggedIn.user, "email"), false); + assert.equal(Object.hasOwn(loggedIn, "service_role_key"), false); + + const refreshed = await service.cliRefresh({ refresh_token: loggedIn.refresh_token }); + assert.equal(refreshed.access_token, "refreshed-access-token"); + assert.equal(refreshed.refresh_token, "rotated-refresh-token"); + assert.equal(refreshed.expires_in, 7200); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/businessChainVerifier.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/businessChainVerifier.test.mjs new file mode 100644 index 00000000..cc293e5c --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/businessChainVerifier.test.mjs @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + assertDossierPersistenceBoundary, + assertProviderRun, + collectPrivatePaths, + parseArgs, + pollJob, + selectCandidate, + usageSummary, + validateDossier, + validateQa, +} from "../scripts/verify-business-chain.mjs"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); + +test("legacy real-chain script cannot be mistaken for real runtime evidence", () => { + const source = fs.readFileSync(path.join(testDir, "..", "scripts", "real-chain-check.mjs"), "utf8"); + assert.doesNotMatch(source, /createMockProviders|MemoryRepository|DemoService/); + assert.match(source, /旧脚本已停用/); +}); + +test("business verifier requires explicit live confirmation, enterprise identity, and a QA question", () => { + assert.throws( + () => parseArgs(["--enterprise-id", "company-1", "--question", "当前重点?"]), + /--confirm-live/, + ); + assert.throws( + () => parseArgs(["--enterprise-id", "company-1", "--confirm-live"]), + /--question/, + ); + const parsed = parseArgs([ + "--enterprise-id", "company-1", + "--question", "当前重点?", + "--confirm-live", + ]); + assert.equal(parsed.enterpriseId, "company-1"); + assert.equal(parsed.confirmLive, true); +}); + +test("company selection never picks an ambiguous first result", () => { + const candidates = [ + { id: "one", name: "示例科技有限公司", identity_status: "verified" }, + { id: "two", name: "示例科技(北京)有限公司", identity_status: "verified" }, + ]; + assert.equal( + selectCandidate(candidates, { companyQuery: "示例科技有限公司", candidateId: "" }).id, + "one", + ); + assert.throws( + () => selectCandidate(candidates, { companyQuery: "示例科技", candidateId: "" }), + /无法唯一确定企业主体/, + ); + assert.throws( + () => selectCandidate([{ id: "draft", name: "待核验", identity_status: "unverified" }], { + companyQuery: "待核验", + candidateId: "", + }), + /未通过专业数据集主体核验/, + ); +}); + +test("dossier and QA acceptance require scoped citations and reject internal fields", () => { + const dossier = { + id: "dossier-1", + company_id: "company-1", + citations: [ + { id: "1", source_kind: "专业数据集", label: "企业工商数据库" }, + { id: "2", source_kind: "联网搜索", label: "企业官网公告" }, + ], + body: [ + { text: "企业情况:已核验。", citation_ids: ["1"] }, + { text: "近期动态:有公开公告。", citation_ids: ["2"] }, + ], + }; + const dossierChecks = validateDossier(dossier, "company-1"); + assert.equal(dossierChecks.citationCount, 2); + assert.throws( + () => validateDossier({ ...dossier, raw_ref: "internal" }, "company-1"), + /暴露了内部字段/, + ); + assert.throws( + () => validateDossier({ ...dossier, body: [{ text: "没有引用", citation_ids: [] }] }, "company-1"), + /缺少引用/, + ); + + const qaChecks = validateQa({ + message: { + id: "qa-1", + role: "assistant", + insufficient: false, + citations: [{ id: "1", label: "最近档案" }], + paragraphs: [{ text: "可核验回答。", citation_ids: ["1"] }], + }, + }); + assert.equal(qaChecks.citationCount, 1); +}); + +test("provider evidence requires each expected real provider to succeed", () => { + const run = { + id: "run-1", + status: "succeeded", + steps: [ + { provider: "datapro", status: "succeeded" }, + { provider: "web_search", status: "succeeded" }, + ], + }; + assert.doesNotThrow(() => assertProviderRun(run, ["datapro", "web_search"], "企业搜索")); + assert.throws( + () => assertProviderRun({ + ...run, + status: "succeeded_with_issues", + steps: [{ provider: "datapro", status: "succeeded" }, { provider: "web_search", status: "failed" }], + }, ["datapro", "web_search"], "企业搜索"), + /未成功:web_search/, + ); +}); + +test("dossier acceptance enforces Supabase persistence without duplicating the report in OpenViking", () => { + assert.doesNotThrow(() => assertDossierPersistenceBoundary({ + steps: [{ + provider: "openviking", + operation: "store_dossier_memory", + status: "skipped", + output_summary: "档案属于结构化业务记录,由 Supabase 保存,不重复写入 OpenViking。", + }], + })); + assert.throws( + () => assertDossierPersistenceBoundary({ + steps: [{ + provider: "openviking", + operation: "store_dossier_memory", + status: "succeeded", + output_summary: "已重复保存。", + }], + }), + /未遵守 Supabase 持久化/, + ); +}); + +test("job polling returns a succeeded job and usage aggregation uses recorded attempts", async () => { + const jobs = [ + { id: "job-1", status: "running", stage: "generating", progress: 50 }, + { id: "job-1", status: "succeeded", stage: "succeeded", progress: 100, result: { dossier_id: "d-1" } }, + ]; + const job = await pollJob({ timeoutMs: 1000, pollMs: 250 }, "job-1", async () => jobs.shift()); + assert.equal(job.result.dossier_id, "d-1"); + + const usage = usageSummary([{ + steps: [ + { provider: "model", attempts: 1, usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 } }, + { provider: "web_search", attempts: 2, usage: null }, + ], + }]); + assert.deepEqual(usage.model, { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 }); + assert.equal(usage.provider_attempts.web_search, 2); +}); + +test("recursive public response scan catches nested secret-bearing keys", () => { + assert.deepEqual(collectPrivatePaths({ safe: { access_token: "hidden" } }), ["$.safe.access_token"]); + assert.deepEqual(collectPrivatePaths({ safe: [{ label: "ok" }] }), []); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/claimGrounding.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/claimGrounding.test.mjs new file mode 100644 index 00000000..a8a4ad36 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/claimGrounding.test.mjs @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + deriveEvidenceDataAsOf, + evidenceSpanErrors, + extractGroundingOrganizations, + groundedTextErrors, +} from "../src/evidence/claimGrounding.js"; + +const procurementSummary = [ + "大模型提示词攻击防护软件产品采购结果信息公开。", + "入选供应商:北京火山引擎科技有限公司。", + "采购价格(元):630,088。", + "财务会计部采购部 2026年7月15日。", +].join(" "); + +test("claim grounding accepts dates and amounts that appear in the cited evidence", () => { + assert.deepEqual(groundedTextErrors({ + text: "2026年7月15日,北京火山引擎科技有限公司入选提示词攻击防护软件采购项目,采购价格为630,088元。", + evidenceTexts: [procurementSummary], + path: "近期公开动态第 1 段", + requireEventFamily: true, + }), []); + assert.deepEqual(groundedTextErrors({ + text: "测试科技有限公司成立于2020年5月11日。", + evidenceTexts: ["公司名称:测试科技有限公司;成立日期:2020-05-11T08:00:00。"], + path: "企业与业务概览第 1 条", + }), []); +}); + +test("claim grounding rejects a different dated event and an unsupported named entity", () => { + const errors = groundedTextErrors({ + text: "2026年7月16日,华夏银行发布AIBOX项目成交候选公示。", + evidenceTexts: [procurementSummary], + path: "近期公开动态第 1 段", + requireEventFamily: true, + }); + + assert.ok(errors.some((item) => item.includes("日期 2026-07-16"))); + assert.ok(errors.some((item) => item.includes("实体 AIBOX"))); + assert.ok(errors.some((item) => item.includes("机构名称“华夏银行”"))); +}); + +test("claim grounding names the unsupported event wording so a revision can repair it", () => { + const errors = groundedTextErrors({ + text: "北京火山引擎科技有限公司已完成该软件项目交付。", + evidenceTexts: [procurementSummary], + path: "近期公开动态第 1 条", + requireEventFamily: true, + }); + + assert.ok(errors.some((item) => item.includes("事件表述“交付”"))); +}); + +test("claim grounding does not treat words inside the verified legal name as a new event", () => { + assert.deepEqual(groundedTextErrors({ + text: "博世(中国)投资有限公司在中国开展汽车技术相关业务。", + evidenceTexts: ["该企业在中国开展汽车技术相关业务。"], + path: "企业与业务概览第 1 条", + requireEventFamily: true, + ignoredEntityNames: ["博世(中国)投资有限公司"], + }), []); +}); + +test("organization grounding ignores predicate fragments before a group suffix", () => { + assert.deepEqual( + extractGroundingOrganizations("相关业务可能受集团统一政策影响。"), + [], + ); + assert.deepEqual(groundedTextErrors({ + text: "相关业务可能受集团统一政策影响。", + evidenceTexts: ["相关业务受到统一政策影响。"], + path: "风险与关注事项第 1 条", + }), []); + assert.deepEqual( + extractGroundingOrganizations("博世集团持续推进相关业务。"), + ["博世集团"], + ); +}); + +test("evidence spans must be continuous verbatim excerpts from the selected citation", () => { + assert.deepEqual(evidenceSpanErrors({ + citation_id: "source_1", + quote: "采购价格(元):630,088。", + }, { + id: "source_1", + summary: procurementSummary, + }), []); + assert.ok(evidenceSpanErrors({ + citation_id: "source_1", + quote: "华夏银行发布成交候选公示。", + }, { + id: "source_1", + summary: procurementSummary, + }).some((item) => item.includes("连续原文"))); +}); + +test("data-as-of uses cited public event dates when provider metadata is stale", () => { + const value = deriveEvidenceDataAsOf([{ + source_kind: "联网搜索", + published_at: "2026-06-10T16:00:00.000Z", + summary: procurementSummary, + }], "2026-07-29T10:00:00.000Z"); + + assert.equal(value, "2026-07-15T00:00:00.000Z"); +}); + +test("grounding ignores company identifiers unless the report changes them", () => { + const evidence = "统一社会信用代码:913100007109203974;注册地址:上海市长宁区福泉北路333号1幢6楼。"; + assert.deepEqual(groundedTextErrors({ + text: "该公司的统一社会信用代码为913100007109203974,注册地址为上海市长宁区福泉北路333号1幢6楼。", + evidenceTexts: [evidence], + path: "企业与业务概览第 1 段", + }), []); + assert.ok(groundedTextErrors({ + text: "该公司的统一社会信用代码为913100007109203975。", + evidenceTexts: [evidence], + path: "企业与业务概览第 1 段", + }).some((item) => item.includes("数值"))); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/dataProQueryPlanner.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/dataProQueryPlanner.test.mjs new file mode 100644 index 00000000..40dfb800 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/dataProQueryPlanner.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { DataProProvider } from "../src/providers/dataProProvider.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + number(name, fallback = 0) { + const value = Object.hasOwn(values, name) ? Number(values[name]) : fallback; + return Number.isFinite(value) ? value : fallback; + }, + }; +} + +test("DataPro allows slower professional queries while preserving an explicit override", () => { + assert.equal(new DataProProvider({ env: envReader() }).timeoutMs, 45_000); + assert.equal( + new DataProProvider({ env: envReader({ DATAPRO_TIMEOUT_MS: "30000" }) }).timeoutMs, + 30_000, + ); +}); + +test("dossier query planner selects business, risk, and industry datasets through the same MCP", () => { + const provider = new DataProProvider({ env: envReader({ DATAPRO_MAX_SOURCES: "4" }) }); + const queries = provider.planDossierQueries({ + name: "示例汽车股份有限公司", + industry: "新能源汽车整车制造", + unified_social_credit_code: "91110000123456789X", + business_scope: "新能源汽车研发、生产与销售", + registered_capital: "10000万元", + }); + + assert.deepEqual(queries.map((item) => item.label), [ + "企业工商数据库", + "企业风险数据库", + "汽车销量数据库", + "金融数据库", + ]); + assert.equal(queries.every((item) => item.query.includes("示例汽车股份有限公司")), true); +}); + +test("dossier query planner prioritizes business identity when it has not been verified", () => { + const provider = new DataProProvider({ env: envReader({ DATAPRO_MAX_SOURCES: "2" }) }); + const queries = provider.planDossierQueries({ + name: "示例科技有限公司", + industry: "企业软件", + }); + + assert.deepEqual(queries.map((item) => item.label), [ + "企业工商数据库", + "企业风险数据库", + ]); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/dossierAgent.realFailureRegression.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/dossierAgent.realFailureRegression.test.mjs new file mode 100644 index 00000000..9370ce68 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/dossierAgent.realFailureRegression.test.mjs @@ -0,0 +1,360 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DossierAgent } from "../src/agents/dossierAgent.js"; +import { + evidenceSpanErrors, + groundedTextErrors, +} from "../src/evidence/claimGrounding.js"; + +const SECTION_KEYS = [ + "company_overview", + "business_dynamics", + "recent_public_updates", + "risk_attention", + "sales_opportunity", + "recommended_actions", +]; + +const PROCUREMENT_SUMMARY = [ + "虚构软件产品采购结果信息公开。", + "入选供应商:云穹矩阵科技有限公司。", + "采购价格(元):630,088。", + "采购部 2026年7月15日。", +].join(" "); + +function fixture() { + const citations = SECTION_KEYS.map((key, index) => ({ + id: `citation_${key}`, + source_kind: key === "recent_public_updates" ? "联网搜索" : "专业数据集", + summary: index === 0 + ? PROCUREMENT_SUMMARY + : `云穹矩阵科技有限公司为${key}提供可引用的完整业务事实。`, + quality_tier: 1, + independence_key: `source:${key}`, + })); + const evidenceAtoms = SECTION_KEYS.map((key, index) => ({ + id: `E_${String(index + 1).padStart(20, "0")}`, + citation_id: citations[index].id, + quote: citations[index].summary, + section_candidates: [key], + entity_match: "verified", + score: 80, + source_kind: key === "recent_public_updates" ? "public" : "professional", + source_type: key === "recent_public_updates" ? "web" : "datapro", + title: `${key} evidence`, + reliability: "professional", + conflict_fields: [], + })); + return { + company: { + name: "云穹矩阵科技有限公司", + legal_name: "云穹矩阵科技有限公司", + industry: "企业软件", + location: "北京", + }, + citations, + evidenceAtoms, + evidenceCoverage: Object.fromEntries(evidenceAtoms.map((atom, index) => [ + SECTION_KEYS[index], + { status: "supported", atom_ids: [atom.id], reasons: [] }, + ])), + evidencePolicy: { fail_closed: true }, + evidenceConflicts: [], + sourceSelectionPolicy: {}, + instructions: ["只使用输入 Evidence Atom。"], + }; +} + +function validResponse(request) { + return { + sections: Object.fromEntries( + request.parameters.properties.sections.required.map((key) => [ + key, + { + text: key === "company_overview" + ? "云穹矩阵科技有限公司入选虚构软件产品采购项目。" + : `云穹矩阵科技有限公司为${key}提供可引用的完整业务事实。`, + evidence_ids: [request.payload.evidence_by_section[key].allowed_evidence[0].id], + }, + ]), + ), + }; +} + +function createAgent(factory) { + return new DossierAgent({ + maxCalls: 2, + callModel: factory, + validate: (answer) => ({ body: answer.body, errors: [] }), + }); +} + +test("regression 1a: paraphrased or fabricated quote remains invalid", () => { + assert.ok(evidenceSpanErrors({ + citation_id: "source_1", + quote: "采购价格630,088元", + }, { + id: "source_1", + summary: PROCUREMENT_SUMMARY, + }).some((error) => error.includes("连续原文"))); + assert.deepEqual(evidenceSpanErrors({ + citation_id: "source_1", + quote: "采购价格(元):630,088。", + }, { + id: "source_1", + summary: PROCUREMENT_SUMMARY, + }), []); +}); + +test("regression 1b: model-supplied quote fields cannot change the server-derived quote", async () => { + const input = fixture(); + const agent = createAgent(async (request) => { + const parsed = validResponse(request); + parsed.sections.company_overview.quote = "采购价格630,088元"; + parsed.sections.company_overview.citation_id = "fabricated"; + return { ok: true, parsed, raw_ref: "model:ignored-extra-fields" }; + }); + + const result = await agent.run(input); + const atom = input.evidenceAtoms[0]; + + assert.equal(result.ok, true); + assert.deepEqual(result.approved_plan.sections.company_overview.evidence_spans, [{ + evidence_id: atom.id, + citation_id: atom.citation_id, + quote: atom.quote, + }]); + assert.doesNotMatch( + JSON.stringify(result.approved_plan.sections.company_overview), + /采购价格630,088元|fabricated/, + ); +}); + +test("regression 2a: unsupported organization names remain rejected", () => { + const errors = groundedTextErrors({ + text: "远川样例银行与云穹矩阵科技有限公司存在未披露的关联安排。", + evidenceTexts: [PROCUREMENT_SUMMARY], + path: "风险与关注事项第 1 条", + requireEventFamily: true, + }); + assert.ok(errors.some((error) => error.includes("机构名称"))); +}); + +test("regression 2b: the Agent fails closed on an unsupported organization", async () => { + let calls = 0; + const agent = createAgent(async (request) => { + calls += 1; + const parsed = validResponse(request); + parsed.sections.risk_attention.text = "远川样例银行与云穹矩阵科技有限公司存在未披露的关联风险。"; + return { ok: true, parsed, raw_ref: `model:${calls}` }; + }); + + const result = await agent.run(fixture()); + + assert.equal(result.ok, false); + assert.equal(result.stage, "planning"); + assert.equal(calls, 2); + assert.ok(result.validation_errors.some((error) => error.includes("机构名称"))); +}); + +test("regression 3a: unsupported numbers in action text remain rejected", () => { + const errors = groundedTextErrors({ + text: "建议针对5000万元预算联系产品负责人。", + evidenceTexts: [PROCUREMENT_SUMMARY], + path: "建议行动第 1 条", + }); + assert.ok(errors.some((error) => error.includes("5000"))); +}); + +test("regression 3b: the Agent fails closed when an action fabricates numbers", async () => { + let calls = 0; + const agent = createAgent(async (request) => { + calls += 1; + const parsed = validResponse(request); + parsed.sections.recommended_actions.text = "销售人员应按5000万元预算准备交付方案。"; + return { ok: true, parsed, raw_ref: `model:${calls}` }; + }); + + const result = await agent.run(fixture()); + + assert.equal(result.ok, false); + assert.equal(result.stage, "planning"); + assert.equal(calls, 2); + assert.ok(result.validation_errors.some((error) => error.includes("5000"))); + assert.equal(result.submission, undefined); +}); + +test("regression 3c: a second localized repair removes an unsupported number without weakening validation", async () => { + const calls = []; + const agent = new DossierAgent({ + maxCalls: 3, + callModel: async (request) => { + calls.push(structuredClone(request)); + const parsed = validResponse(request); + if (calls.length < 3) { + parsed.sections.company_overview.text = "云穹矩阵科技有限公司入选1309项虚构软件产品采购项目。"; + } + return { ok: true, parsed, raw_ref: `model:${calls.length}` }; + }, + validate: (answer) => ({ body: answer.body, errors: [] }), + }); + + const result = await agent.run(fixture()); + + assert.equal(result.ok, true); + assert.equal(calls.length, 3); + assert.equal(calls[1].operation, "sales_dossier_agent_replan"); + assert.equal(calls[2].operation, "sales_dossier_agent_replan"); + assert.deepEqual(calls[1].payload.repair_section_keys, ["company_overview"]); + assert.deepEqual(calls[2].payload.repair_section_keys, ["company_overview"]); + assert.deepEqual(calls[1].payload.repair_directives[0].unsupported_numbers, ["1309"]); + assert.deepEqual(calls[2].payload.repair_directives[0].unsupported_numbers, ["1309"]); + assert.deepEqual(calls[1].payload.forbidden_grounding_values, ["1309"]); + assert.deepEqual(calls[2].payload.forbidden_grounding_values, ["1309"]); + assert.deepEqual(calls[1].payload.previous_plan.sections.company_overview, { + text: "", + evidence_ids: [], + }); + assert.deepEqual(calls[2].payload.previous_plan.sections.company_overview, { + text: "", + evidence_ids: [], + }); + assert.doesNotMatch(result.submission.body[0].text, /1309/u); +}); + +test("regression 3d: the server deterministically selects the supporting same-section Atom", async () => { + const input = fixture(); + const supportingCitation = { + id: "citation_company_overview_supporting", + source_kind: "专业数据集", + summary: "云穹矩阵科技有限公司产品包括矩阵知识库,并与客户开展合作。", + quality_tier: 1, + independence_key: "source:company-overview-supporting", + }; + const supportingAtom = { + id: "E_00000000000000000099", + citation_id: supportingCitation.id, + quote: supportingCitation.summary, + section_candidates: ["company_overview"], + entity_match: "verified", + score: 70, + source_kind: "professional", + source_type: "datapro", + title: "company overview supporting evidence", + reliability: "professional", + conflict_fields: [], + }; + input.citations.push(supportingCitation); + input.evidenceAtoms.push(supportingAtom); + input.evidenceCoverage.company_overview.atom_ids.push(supportingAtom.id); + const calls = []; + const agent = new DossierAgent({ + maxCalls: 1, + callModel: async (request) => { + calls.push(structuredClone(request)); + const parsed = validResponse(request); + parsed.sections.company_overview = { + text: supportingCitation.summary, + evidence_ids: [input.evidenceAtoms[0].id], + }; + return { ok: true, parsed, raw_ref: "model:wrong-evidence-id" }; + }, + validate: (answer) => ({ body: answer.body, errors: [] }), + }); + + const result = await agent.run(input); + + assert.equal(result.ok, true); + assert.equal(calls.length, 1); + assert.deepEqual( + result.approved_plan.sections.company_overview.evidence_ids, + [supportingAtom.id], + ); + assert.deepEqual( + result.approved_plan.sections.company_overview.citation_ids, + [supportingCitation.id], + ); +}); + +test("regression 3e: analytical risk checklists do not treat generic cooperation wording as an asserted event", async () => { + const calls = []; + const agent = new DossierAgent({ + maxCalls: 1, + callModel: async (request) => { + calls.push(structuredClone(request)); + const parsed = validResponse(request); + parsed.sections.risk_attention.text = "销售合作前应核验项目边界和责任范围。"; + return { ok: true, parsed, raw_ref: "model:analytical-event" }; + }, + validate: (answer) => ({ body: answer.body, errors: [] }), + }); + + const result = await agent.run(fixture()); + + assert.equal(result.ok, true); + assert.equal(calls.length, 1); + assert.match(result.submission.body[3].text, /销售合作前应核验项目边界和责任范围/u); +}); + +test("generic supplier roles in a risk checklist do not masquerade as unsupported procurement events", async () => { + const agent = createAgent(async (request) => { + const parsed = validResponse(request); + parsed.sections.risk_attention.text = "销售对接前应核验供应商准入要求、数据合规边界和交付责任。"; + return { ok: true, parsed, raw_ref: "model:generic-supplier-role" }; + }); + + const result = await agent.run(fixture()); + + assert.equal(result.ok, true); + assert.match(result.submission.body[3].text, /供应商准入要求/u); +}); + +test("factual risk statements still reject an unsupported completed event", async () => { + const agent = createAgent(async (request) => { + const parsed = validResponse(request); + parsed.sections.risk_attention.text = "云穹矩阵科技有限公司已完成该项目交付。"; + return { ok: true, parsed, raw_ref: "model:unsupported-risk-event" }; + }); + + const result = await agent.run(fixture()); + + assert.equal(result.ok, false); + assert.ok(result.validation_errors.some((error) => error.includes("事件表述“交付”"))); +}); + +test("detailed complete sections are not rejected by the former 260-character limit", async () => { + const detailedAction = "销售人员应联系产品负责人,依次确认知识库覆盖范围、数据权限边界、部署方式、接口责任、试点排期、验收标准、运维安排、采购主体、预算审批路径、合同责任、业务牵头部门、技术评审角色、信息安全要求、扩容触发条件、服务响应边界、故障升级路径、需求变更方式、交付依赖条件和最终决策链,再准备与已确认范围一致的试点方案。书面确认记录还应覆盖沟通节奏、双方负责人、需求变更规则、交付依赖条件、上线回退方案、故障升级路径和最终验收责任。最终复盘清单需要明确记录已经核验的事实、仍待确认的问题、下一次沟通的负责人、对应截止时间、预期交付物和书面确认方式。"; + assert.ok(detailedAction.length > 260); + const agent = createAgent(async (request) => { + const parsed = validResponse(request); + parsed.sections.recommended_actions.text = detailedAction; + return { ok: true, parsed, raw_ref: "model:detailed-action" }; + }); + + const result = await agent.run(fixture()); + + assert.equal(result.ok, true); + assert.match(result.submission.body[5].text, /最终验收责任/u); +}); + +test("regression combined: invalid IDs, unsupported organizations and numbers all remain visible", async () => { + let calls = 0; + const agent = createAgent(async (request) => { + calls += 1; + const parsed = validResponse(request); + parsed.sections.company_overview.evidence_ids = ["E_invalid"]; + parsed.sections.risk_attention.text = "远川样例银行与云穹矩阵科技有限公司存在关联风险。"; + parsed.sections.recommended_actions.text = "销售人员应按5000万元预算准备方案。"; + return { ok: true, parsed, raw_ref: `model:${calls}` }; + }); + + const result = await agent.run(fixture()); + const errors = result.validation_errors.join("\n"); + + assert.equal(result.ok, false); + assert.equal(calls, 2); + assert.match(errors, /无效 Evidence ID/); + assert.match(errors, /机构名称/); + assert.match(errors, /5000/); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/dossierAgent.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/dossierAgent.test.mjs new file mode 100644 index 00000000..df73d2f3 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/dossierAgent.test.mjs @@ -0,0 +1,491 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildDossierAgentContext, + buildDossierSourceUsageRequirements, + compileDossierFromPlan, + DossierAgent, + dossierSourceUsageErrors, +} from "../src/agents/dossierAgent.js"; + +const SECTION_KEYS = [ + "company_overview", + "business_dynamics", + "recent_public_updates", + "risk_attention", + "sales_opportunity", + "recommended_actions", +]; + +function atomInput() { + const citations = SECTION_KEYS.map((key, index) => ({ + id: `citation_${key}`, + source_kind: key === "recent_public_updates" ? "联网搜索" : "专业数据集", + summary: `测试科技有限公司为${key}提供可引用的完整业务事实。`, + quality_tier: 1, + independence_key: `source:${key}`, + entity_match: "verified", + })); + const evidenceAtoms = SECTION_KEYS.map((key, index) => ({ + id: `E_${String(index + 1).padStart(20, "0")}`, + citation_id: citations[index].id, + quote: citations[index].summary, + section_candidates: [key], + entity_match: "verified", + score: 80, + source_kind: key === "recent_public_updates" ? "public" : "professional", + source_type: key === "recent_public_updates" ? "web" : "datapro", + title: `${key} evidence`, + reliability: "professional", + conflict_fields: [], + })); + const evidenceCoverage = Object.fromEntries(evidenceAtoms.map((atom, index) => [ + SECTION_KEYS[index], + { status: "supported", atom_ids: [atom.id], reasons: [] }, + ])); + return { + company: { + name: "测试科技有限公司", + legal_name: "测试科技有限公司", + industry: "企业软件", + location: "北京", + }, + citations, + evidenceAtoms, + evidenceCoverage, + evidencePolicy: { fail_closed: true }, + evidenceConflicts: [], + sourceSelectionPolicy: {}, + instructions: ["只使用输入 Evidence Atom。"], + }; +} + +function responseFor(request, suffix = "") { + return { + sections: Object.fromEntries( + request.parameters.properties.sections.required.map((key) => [ + key, + { + text: `测试科技有限公司为${key}提供可引用的完整业务事实${suffix}。`, + evidence_ids: [request.payload.evidence_by_section[key].allowed_evidence[0].id], + }, + ]), + ), + }; +} + +test("dossier Agent context keeps citation and Atom projections bounded by selected sources", () => { + const citations = [ + ...Array.from({ length: 10 }, (_, index) => ({ + id: `professional_${index}`, + source_kind: "专业数据集", + label: index === 0 ? "企业工商数据库" : "金融数据库", + summary: `专业证据 ${index} ${"业务事实".repeat(500)}`, + quality_tier: index < 3 ? 1 : 2, + freshness: "current", + })), + ...Array.from({ length: 10 }, (_, index) => ({ + id: `public_${index}`, + source_kind: "联网搜索", + label: `公开来源 ${index}`, + summary: `公开事件 ${index} ${"项目进展".repeat(500)}`, + published_at: `2026-07-${String(20 - index).padStart(2, "0")}T00:00:00.000Z`, + quality_tier: 2, + freshness: "current", + })), + ]; + const evidenceAtoms = citations.map((citation, index) => ({ + id: `E_${String(index + 1).padStart(20, "0")}`, + citation_id: citation.id, + quote: citation.summary.slice(0, 80), + section_candidates: [SECTION_KEYS[index % SECTION_KEYS.length]], + entity_match: "verified", + score: 100 - index, + })); + const context = buildDossierAgentContext({ + citations, + evidenceAtoms, + sourceSelectionPolicy: { + business_database_ids: ["professional_0"], + professional_dataset_ids: citations.slice(0, 10).map((item) => item.id), + web_search_ids: citations.slice(10).map((item) => item.id), + }, + }); + + assert.equal(context.citations.length, 10); + assert.ok(context.metrics.professional_count >= 1); + assert.ok(context.metrics.public_count >= 1); + assert.equal( + context.metrics.professional_count + context.metrics.public_count, + context.citations.length, + ); + assert.ok(context.metrics.serialized_chars < 10_000); + assert.ok(context.metrics.selected_atom_count <= 10); + assert.ok(Object.values(context.evidenceBySection).every((items) => items.length <= 6)); +}); + +test("context cap preserves low-ranked sources that are indispensable to a section", () => { + const citations = [ + ...Array.from({ length: 10 }, (_, index) => ({ + id: `general_${index}`, + source_kind: "专业数据集", + label: "企业工商数据库", + summary: `测试科技有限公司经营企业软件业务,记录序号 ${100 + index}。`, + quality_tier: 1, + freshness: "current", + })), + { + id: "risk_low_rank", + source_kind: "专业数据集", + label: "企业风险数据库", + summary: "测试科技有限公司披露项目交付周期延长,需要核验实施排期。", + quality_tier: 4, + }, + { + id: "recent_low_rank", + source_kind: "联网搜索", + label: "产品升级公告", + summary: "2026年7月30日,测试科技有限公司披露产品升级进展。", + published_at: "2026-07-30T00:00:00.000Z", + quality_tier: 4, + }, + ]; + const evidenceAtoms = citations.map((citation, index) => ({ + id: `E_cap_${String(index + 1).padStart(14, "0")}`, + citation_id: citation.id, + quote: citation.summary, + section_candidates: citation.id === "risk_low_rank" + ? ["risk_attention"] + : citation.id === "recent_low_rank" + ? ["recent_public_updates"] + : ["company_overview", "business_dynamics", "sales_opportunity", "recommended_actions"], + entity_match: "verified", + score: citation.id.startsWith("general_") ? 100 - index : 10, + source_kind: citation.source_kind === "联网搜索" ? "public" : "professional", + })); + + const context = buildDossierAgentContext({ citations, evidenceAtoms }); + + assert.equal(context.citations.length, 10); + assert.ok(context.citations.some((citation) => citation.id === "risk_low_rank")); + assert.ok(context.citations.some((citation) => citation.id === "recent_low_rank")); + assert.equal(context.evidenceBySection.risk_attention[0].citation_id, "risk_low_rank"); + assert.equal(context.evidenceBySection.recent_public_updates[0].citation_id, "recent_low_rank"); +}); + +test("chapter candidates are restricted to the same qualified source policy used by final validation", () => { + const citations = [ + { + id: "business_verified", + source_kind: "专业数据集", + label: "企业工商数据库", + summary: "测试科技有限公司成立于2020年5月11日。", + quality_tier: 1, + entity_match: "verified", + }, + { + id: "risk_verified", + source_kind: "专业数据集", + label: "企业风险数据库", + summary: "测试科技有限公司披露一条需核验的诉讼记录。", + quality_tier: 1, + entity_match: "verified", + }, + { + id: "recent_verified", + source_kind: "联网搜索", + label: "官方项目公告", + summary: "2026年7月30日,测试科技有限公司公告中标人信息。", + quality_tier: 1, + published_at: "2026-07-30T00:00:00.000Z", + entity_match: "verified", + }, + { + id: "recent_marketing", + source_kind: "联网搜索", + label: "品牌营销页", + summary: "测试科技有限公司提供领先的全栈解决方案。", + quality_tier: 2, + entity_match: "verified", + }, + ]; + const evidenceAtoms = citations.map((citation, index) => ({ + id: `E_policy_${String(index + 1).padStart(12, "0")}`, + citation_id: citation.id, + quote: citation.summary, + section_candidates: citation.id.startsWith("recent_") + ? ["recent_public_updates"] + : citation.id === "risk_verified" + ? ["risk_attention"] + : ["company_overview"], + entity_match: "verified", + score: citation.id === "recent_marketing" ? 100 : 80, + source_kind: citation.source_kind === "联网搜索" ? "public" : "professional", + })); + const evidenceCoverage = { + company_overview: { status: "supported", atom_ids: [evidenceAtoms[0].id], reasons: [] }, + recent_public_updates: { + status: "supported", + atom_ids: [evidenceAtoms[2].id, evidenceAtoms[3].id], + reasons: [], + }, + risk_attention: { status: "supported", atom_ids: [evidenceAtoms[1].id], reasons: [] }, + }; + + const context = buildDossierAgentContext({ + citations, + evidenceAtoms, + evidenceCoverage, + sourceSelectionPolicy: { + business_database_ids: ["business_verified"], + risk_database_ids: ["risk_verified"], + web_search_ids: ["recent_verified"], + }, + }); + + assert.deepEqual( + context.evidenceBySection.company_overview.map((atom) => atom.citation_id), + ["business_verified"], + ); + assert.deepEqual( + context.evidenceBySection.risk_attention.map((atom) => atom.citation_id), + ["risk_verified"], + ); + assert.deepEqual( + context.evidenceBySection.recent_public_updates.map((atom) => atom.citation_id), + ["recent_verified"], + ); +}); + +test("single-source critical financial figures are excluded before planning", () => { + const citations = [ + { + id: "recent_single_profit", + source_kind: "联网搜索", + label: "公开网页", + summary: "2026年7月30日,测试科技有限公司公布净利润680亿元。", + quality_tier: 3, + entity_match: "verified", + independence_key: "public:single-profit", + }, + { + id: "recent_regular_event", + source_kind: "联网搜索", + label: "项目公告", + summary: "2026年7月29日,测试科技有限公司公告产品升级进展。", + quality_tier: 2, + entity_match: "verified", + independence_key: "public:regular-event", + }, + ]; + const evidenceAtoms = citations.map((citation, index) => ({ + id: `E_critical_${String(index + 1).padStart(10, "0")}`, + citation_id: citation.id, + quote: citation.summary, + section_candidates: ["recent_public_updates"], + entity_match: "verified", + score: index === 0 ? 100 : 80, + source_kind: "public", + })); + + const context = buildDossierAgentContext({ + citations, + evidenceAtoms, + evidenceCoverage: { + recent_public_updates: { + status: "supported", + atom_ids: evidenceAtoms.map((atom) => atom.id), + reasons: [], + }, + }, + sourceSelectionPolicy: { + web_search_ids: citations.map((citation) => citation.id), + }, + }); + + assert.deepEqual( + context.evidenceBySection.recent_public_updates.map((atom) => atom.citation_id), + ["recent_regular_event"], + ); + assert.equal(context.metrics.excluded_unsupported_critical_atom_count, 1); +}); + +test("single-source dated penalties are excluded from report and action candidates", () => { + const citations = [{ + id: "single_penalty", + source_kind: "联网搜索", + label: "企业信息聚合页", + summary: "2025-02-17行政处罚所涉工程施工安全管理要求需要核实。", + quality_tier: 3, + entity_match: "verified", + independence_key: "public:single-penalty", + }, { + id: "ordinary_scope", + source_kind: "专业数据集", + label: "企业工商数据库", + summary: "公司名称:测试科技有限公司;经营范围:工程安装和企业软件开发。", + quality_tier: 1, + entity_match: "verified", + independence_key: "professional:scope", + }]; + const evidenceAtoms = citations.map((citation, index) => ({ + id: `E_risk_${String(index + 1).padStart(14, "0")}`, + citation_id: citation.id, + quote: citation.summary, + section_candidates: ["risk_attention", "recommended_actions"], + entity_match: "verified", + score: 100 - index, + source_kind: citation.source_kind === "联网搜索" ? "public" : "professional", + })); + + const context = buildDossierAgentContext({ citations, evidenceAtoms }); + + assert.ok(Object.values(context.evidenceBySection).every((atoms) => ( + atoms.every((atom) => atom.citation_id !== "single_penalty") + ))); + assert.equal(context.metrics.excluded_unsupported_critical_atom_count, 1); +}); + +test("business records for a different legal entity are excluded from every chapter", () => { + const citations = [ + { + id: "target_business", + source_kind: "专业数据集", + label: "企业工商数据库", + summary: "公司名称:测试科技有限公司;经营范围:软件开发。", + quality_tier: 1, + entity_match: "verified", + }, + { + id: "similar_name_business", + source_kind: "专业数据集", + label: "企业工商数据库", + summary: "公司名称:山西测试科技有限公司;经营范围:网络建设。", + quality_tier: 1, + entity_match: "verified", + }, + ]; + const evidenceAtoms = citations.map((citation, index) => ({ + id: `E_entity_${String(index + 1).padStart(12, "0")}`, + citation_id: citation.id, + quote: citation.summary, + section_candidates: ["company_overview", "business_dynamics", "recommended_actions"], + entity_match: "verified", + score: 90 - index, + source_kind: "professional", + })); + + const context = buildDossierAgentContext({ + citations, + evidenceAtoms, + sourceSelectionPolicy: { + business_database_ids: ["target_business"], + excluded_entity_citation_ids: ["similar_name_business"], + }, + }); + + assert.ok(Object.values(context.evidenceBySection).every((atoms) => ( + atoms.every((atom) => atom.citation_id !== "similar_name_business") + ))); + assert.equal(context.metrics.excluded_unrelated_entity_citation_count, 1); +}); + +test("dossier source usage remains diagnostic with no global citation-count floor", () => { + const citations = [ + { id: "professional", source_kind: "专业数据集", independence_key: "datapro:business" }, + { id: "public_1", source_kind: "联网搜索", independence_key: "official.example" }, + { id: "public_2", source_kind: "联网搜索", independence_key: "media.example" }, + ]; + const requirements = buildDossierSourceUsageRequirements(citations); + + assert.equal(requirements.required_distinct_source_count, 0); + assert.deepEqual( + dossierSourceUsageErrors(["professional"], citations, requirements), + [], + ); +}); + +test("deterministic compiler keeps the fixed six-section order and derived citations", () => { + const plan = { + sections: Object.fromEntries(SECTION_KEYS.map((key, index) => [ + key, + { + id: `${key}_1`, + text: `测试科技有限公司为${key}提供可引用的完整业务事实。`, + evidence_ids: [`E_${String(index + 1).padStart(20, "0")}`], + citation_ids: [`citation_${key}`], + evidence_spans: [{ + evidence_id: `E_${String(index + 1).padStart(20, "0")}`, + citation_id: `citation_${key}`, + quote: `测试科技有限公司为${key}提供可引用的完整业务事实。`, + }], + }, + ])), + }; + const compiled = compileDossierFromPlan(plan); + + assert.deepEqual(compiled.errors, []); + assert.equal(compiled.submission.body.length, 6); + assert.deepEqual( + compiled.submission.body.map((section) => section.citation_ids[0]), + SECTION_KEYS.map((key) => `citation_${key}`), + ); +}); + +test("dossier Agent retries one incomplete response and never adds a fallback call", async () => { + let calls = 0; + const input = atomInput(); + const agent = new DossierAgent({ + maxCalls: 2, + callModel: async (request) => { + calls += 1; + if (calls === 1) { + return { + ok: false, + error: { code: "incomplete_response", retryable: true }, + raw_ref: "model:incomplete", + }; + } + return { + ok: true, + parsed: responseFor(request), + raw_ref: "model:complete", + }; + }, + validate: (answer) => ({ body: answer.body, errors: [] }), + }); + + const result = await agent.run(input); + + assert.equal(result.ok, true); + assert.equal(calls, 2); + assert.deepEqual( + result.submission.body.map((section) => section.citation_ids.length), + [1, 1, 1, 1, 1, 1], + ); +}); + +test("dossier Agent fails closed after two rejected complete submissions", async () => { + let calls = 0; + const input = atomInput(); + const agent = new DossierAgent({ + maxCalls: 2, + callModel: async (request) => { + calls += 1; + return { + ok: true, + parsed: responseFor(request), + raw_ref: `model:${calls}`, + }; + }, + validate: (answer) => ({ body: answer.body, errors: ["引用覆盖不足"] }), + }); + + const result = await agent.run(input); + + assert.equal(result.ok, false); + assert.equal(result.stage, "validation"); + assert.equal(calls, 2); + assert.equal(result.submission, undefined); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/dossierAgentEvidenceAtoms.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/dossierAgentEvidenceAtoms.test.mjs new file mode 100644 index 00000000..506f7eae --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/dossierAgentEvidenceAtoms.test.mjs @@ -0,0 +1,419 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildDossierPlanSchema, + DossierAgent, +} from "../src/agents/dossierAgent.js"; + +const SECTION_KEYS = [ + "company_overview", + "business_dynamics", + "recent_public_updates", + "risk_attention", + "sales_opportunity", + "recommended_actions", +]; + +const SECTION_TITLES = [ + "企业与业务概览", + "经营与业务动态", + "近期公开动态", + "风险与关注事项", + "销售机会判断", + "建议行动", +]; + +const SECTION_QUOTES = { + company_overview: "云穹矩阵科技有限公司经营企业软件与知识库产品。", + business_dynamics: "云穹矩阵科技有限公司发布知识库产品升级公告。", + recent_public_updates: "2026年7月30日,云穹矩阵科技有限公司披露产品升级进展。", + risk_attention: "云穹矩阵科技有限公司披露项目交付周期延长,需要核验实施排期。", + sales_opportunity: "知识库产品升级为企业协作检索场景形成销售沟通窗口。", + recommended_actions: "知识库产品升级范围和实施排期仍需由产品负责人核验。", +}; + +const SECTION_TEXT = { + company_overview: "云穹矩阵科技有限公司经营企业软件与知识库产品。", + business_dynamics: "云穹矩阵科技有限公司已发布知识库产品升级公告。", + recent_public_updates: "2026年7月30日,云穹矩阵科技有限公司披露产品升级进展。", + risk_attention: "云穹矩阵科技有限公司披露项目交付周期延长,需要核验实施排期。", + sales_opportunity: "知识库产品升级形成销售沟通窗口,但不代表企业已有采购意向。", + recommended_actions: "销售人员应联系产品负责人核验知识库产品升级范围和实施排期。", +}; + +function evidenceFixture() { + const citations = []; + const evidenceAtoms = []; + const evidenceCoverage = {}; + SECTION_KEYS.forEach((key, index) => { + const citationId = `citation_${key}`; + const atomId = `E_${String(index + 1).padStart(20, "0")}`; + const sourceKind = key === "recent_public_updates" ? "联网搜索" : "专业数据集"; + citations.push({ + id: citationId, + source_kind: sourceKind, + summary: SECTION_QUOTES[key], + quality_tier: 1, + independence_key: `independent:${key}`, + entity_match: "verified", + }); + evidenceAtoms.push({ + id: atomId, + citation_id: citationId, + source_hash: `${index + 1}`.repeat(64).slice(0, 64), + independence_hash: `${index + 7}`.repeat(64).slice(0, 64), + source_kind: sourceKind === "联网搜索" ? "public" : "professional", + source_type: sourceKind === "联网搜索" ? "web" : "datapro", + title: `${SECTION_TITLES[index]}证据`, + url: sourceKind === "联网搜索" ? `https://example.com/${key}` : null, + published_at: key === "recent_public_updates" + ? "2026-07-30T00:00:00.000Z" + : null, + source_updated_at: null, + source_text_field: "summary", + quote: SECTION_QUOTES[key], + quote_start: 0, + quote_end: SECTION_QUOTES[key].length, + normalized_text: SECTION_QUOTES[key], + entity_match: "verified", + entity_anchors: ["云穹矩阵科技有限公司"], + section_candidates: [key], + dates: key === "recent_public_updates" ? ["2026-07-30"] : [], + numbers: [], + organizations: ["云穹矩阵科技有限公司"], + event_families: [], + conflict_fields: [], + reliability: "professional", + score: 80, + }); + evidenceCoverage[key] = { + status: "supported", + atom_ids: [atomId], + reasons: [], + }; + }); + return { citations, evidenceAtoms, evidenceCoverage }; +} + +function parsedPlan(request, overrides = {}) { + return { + sections: Object.fromEntries( + request.parameters.properties.sections.required.map((key) => [ + key, + { + text: overrides[key]?.text || SECTION_TEXT[key], + evidence_ids: overrides[key]?.evidence_ids + || [request.payload.evidence_by_section[key].allowed_evidence[0].id], + }, + ]), + ), + }; +} + +function agentInput(overrides = {}) { + const fixture = evidenceFixture(); + return { + company: { + name: "云穹矩阵科技有限公司", + legal_name: "云穹矩阵科技有限公司", + industry: "企业软件", + location: "北京", + }, + citations: fixture.citations, + evidenceAtoms: fixture.evidenceAtoms, + evidenceCoverage: fixture.evidenceCoverage, + evidencePolicy: { fail_closed: true }, + evidenceConflicts: [], + sourceSelectionPolicy: {}, + instructions: ["只使用输入 Evidence Atom。"], + ...overrides, + }; +} + +function createAgent(callModel, validate = (answer) => ({ + body: answer.body, + errors: [], +})) { + return new DossierAgent({ + callModel, + validate, + maxCalls: 2, + }); +} + +test("dossier schema exposes only text and chapter-scoped evidence_ids", () => { + const allowed = Object.fromEntries(SECTION_KEYS.map((key, index) => [ + key, + [`E_${String(index + 1).padStart(20, "0")}`], + ])); + const schema = buildDossierPlanSchema(allowed); + + assert.deepEqual(schema.properties.sections.required, SECTION_KEYS); + for (const key of SECTION_KEYS) { + const section = schema.properties.sections.properties[key]; + assert.deepEqual(Object.keys(section.properties), ["text", "evidence_ids"]); + assert.deepEqual(section.required, ["text", "evidence_ids"]); + assert.deepEqual(section.properties.evidence_ids.items.enum, allowed[key]); + assert.equal(section.properties.quote, undefined); + assert.equal(section.properties.citation_id, undefined); + assert.equal(section.properties.evidence_spans, undefined); + } +}); + +test("server derives verbatim quotes citations and segments from evidence ids", async () => { + const calls = []; + const input = agentInput(); + const agent = createAgent(async (request) => { + calls.push(request); + return { + ok: true, + parsed: parsedPlan(request), + raw_ref: "model:atom-plan", + }; + }); + + const result = await agent.run(input); + + assert.equal(result.ok, true); + assert.equal(calls.length, 1); + assert.equal(result.submission.body.length, 6); + SECTION_KEYS.forEach((key, index) => { + const atom = input.evidenceAtoms.find((item) => item.section_candidates.includes(key)); + const section = result.approved_plan.sections[key]; + assert.deepEqual(section.evidence_ids, [atom.id]); + assert.deepEqual(section.citation_ids, [atom.citation_id]); + assert.deepEqual(section.evidence_spans, [{ + evidence_id: atom.id, + citation_id: atom.citation_id, + quote: atom.quote, + }]); + assert.deepEqual(result.submission.body[index].citation_ids, [atom.citation_id]); + assert.deepEqual( + result.submission.body[index].segments[0].citation_ids, + [atom.citation_id], + ); + }); + assert.doesNotMatch(JSON.stringify(calls[0].parameters), /quote|citation_id|url/iu); +}); + +test("alias-scoped factual evidence receives a deterministic public-information boundary", async () => { + const input = agentInput(); + const recentAtom = input.evidenceAtoms.find((atom) => ( + atom.section_candidates.includes("recent_public_updates") + )); + const recentCitation = input.citations.find((citation) => citation.id === recentAtom.citation_id); + recentAtom.entity_match = "alias_scoped"; + recentCitation.entity_match = "alias_scoped"; + const agent = createAgent(async (request) => ({ + ok: true, + parsed: parsedPlan(request), + raw_ref: "model:alias-boundary", + })); + + const result = await agent.run(input); + + assert.equal(result.ok, true); + assert.match( + result.approved_plan.sections.recent_public_updates.text, + /^公开信息显示,/u, + ); + assert.match(result.submission.body[2].text, /近期公开动态:公开信息显示,/u); +}); + +test("registered scope wording is deterministically neutralized in the overview", async () => { + const agent = createAgent(async (request) => ({ + ok: true, + parsed: parsedPlan(request, { + company_overview: { + text: "公司经营企业软件,并延伸至知识库产品。", + }, + }), + raw_ref: "model:neutral-scope", + })); + + const result = await agent.run(agentInput()); + + assert.equal(result.ok, true); + assert.equal( + result.approved_plan.sections.company_overview.text, + "公司经营企业软件,并包括知识库产品。", + ); +}); + +test("invalid evidence ids are rejected without deriving citations", async () => { + let calls = 0; + const agent = createAgent(async (request) => { + calls += 1; + const parsed = parsedPlan(request); + parsed.sections.company_overview.evidence_ids = ["E_not_allowed"]; + return { ok: true, parsed, raw_ref: `model:${calls}` }; + }); + + const result = await agent.run(agentInput()); + + assert.equal(result.ok, false); + assert.equal(result.stage, "planning"); + assert.equal(calls, 2); + assert.ok(result.validation_errors.some((error) => ( + error.includes("企业与业务概览") && error.includes("无效 Evidence ID") + ))); +}); + +test("an evidence id allowed for another chapter is rejected", async () => { + let calls = 0; + const input = agentInput(); + const businessAtom = input.evidenceAtoms.find((item) => ( + item.section_candidates.includes("business_dynamics") + )); + const agent = createAgent(async (request) => { + calls += 1; + const parsed = parsedPlan(request); + parsed.sections.risk_attention.evidence_ids = [businessAtom.id]; + return { ok: true, parsed, raw_ref: `model:${calls}` }; + }); + + const result = await agent.run(input); + + assert.equal(result.ok, false); + assert.equal(calls, 2); + assert.ok(result.validation_errors.some((error) => ( + error.includes("风险与关注事项") && error.includes("不属于本章节") + ))); +}); + +test("missing chapter-specific coverage uses grounded cross-section evidence", async () => { + let calls = 0; + const input = agentInput(); + input.evidenceCoverage.risk_attention = { + status: "missing", + atom_ids: [], + reasons: ["no_relevant_atoms"], + }; + input.evidenceAtoms = input.evidenceAtoms.filter((atom) => ( + !atom.section_candidates.includes("risk_attention") + )); + const agent = createAgent(async (request) => { + calls += 1; + const parsed = parsedPlan(request, { + risk_attention: { + text: "云穹矩阵科技有限公司已发布知识库产品升级公告,商务推进应核验实施范围。", + }, + }); + return { ok: true, parsed, raw_ref: "model:cross-section-grounding" }; + }); + + const result = await agent.run(input); + + assert.equal(result.ok, true); + assert.equal(result.stage, "complete"); + assert.equal(calls, 1); + const riskInput = result.approved_plan.sections.risk_attention; + assert.equal(riskInput.evidence_ids.length, 1); + assert.equal( + riskInput.evidence_ids[0], + input.evidenceAtoms.find((atom) => ( + atom.section_candidates.includes("business_dynamics") + )).id, + ); +}); + +test("the bounded repair call returns only the failed chapter", async () => { + const calls = []; + const agent = createAgent(async (request) => { + calls.push(request); + if (calls.length === 1) { + return { + ok: true, + parsed: parsedPlan(request, { + recent_public_updates: { + text: "2026年7月31日,云穹矩阵科技有限公司披露产品升级进展。", + }, + }), + raw_ref: "model:first", + }; + } + return { + ok: true, + parsed: parsedPlan(request), + raw_ref: "model:repair", + }; + }); + + const result = await agent.run(agentInput()); + + assert.equal(result.ok, true); + assert.equal(calls.length, 2); + assert.equal(calls[1].operation, "sales_dossier_agent_replan"); + assert.deepEqual( + calls[1].parameters.properties.sections.required, + ["recent_public_updates"], + ); + assert.deepEqual( + Object.keys(calls[1].parameters.properties.sections.properties), + ["recent_public_updates"], + ); + assert.equal( + result.approved_plan.sections.company_overview.text, + SECTION_TEXT.company_overview, + ); + assert.equal( + result.approved_plan.sections.recent_public_updates.text, + SECTION_TEXT.recent_public_updates, + ); +}); + +test("indexed final-validation errors are mapped back to the exact failed chapter", async () => { + const calls = []; + let validations = 0; + const agent = createAgent(async (request) => { + calls.push(request); + return { + ok: true, + parsed: parsedPlan(request), + raw_ref: `model:indexed-validation-${calls.length}`, + }; + }, (answer) => ({ + body: answer.body, + errors: validations++ === 0 + ? ["body[2].segments[0] 的净利润“680亿元”未获得双来源一致支持"] + : [], + })); + + const result = await agent.run(agentInput()); + + assert.equal(result.ok, true); + assert.equal(calls.length, 2); + assert.deepEqual( + calls[1].parameters.properties.sections.required, + ["recent_public_updates"], + ); + assert.match( + calls[1].payload.planning_errors[0], + /^近期公开动态第 1 条/u, + ); +}); + +test("two rejected semantic plans fail closed with six chapters and no fallback", async () => { + let calls = 0; + const agent = createAgent(async (request) => { + calls += 1; + return { + ok: true, + parsed: parsedPlan(request, { + recommended_actions: { + text: "销售人员应按5000万元预算准备交付方案。", + }, + }), + raw_ref: `model:${calls}`, + }; + }); + + const result = await agent.run(agentInput()); + + assert.equal(result.ok, false); + assert.equal(result.stage, "planning"); + assert.equal(calls, 2); + assert.equal(result.submission, undefined); + assert.ok(result.validation_errors.some((error) => error.includes("5000"))); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/dossierEvidenceCompiler.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/dossierEvidenceCompiler.test.mjs new file mode 100644 index 00000000..1eb90ad6 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/dossierEvidenceCompiler.test.mjs @@ -0,0 +1,623 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + compileDossierEvidenceAtoms, +} from "../src/evidence/dossierEvidenceCompiler.js"; + +const ENTITY = Object.freeze({ + id: "company_fictional_matrix", + canonical_name: "云岚矩阵科技有限公司", + normalized_name: "云岚矩阵科技有限公司", + aliases: ["云岚矩阵科技有限公司", "云岚矩阵", "云岚"], + strict_aliases: ["云岚矩阵科技有限公司", "云岚矩阵"], + contextual_aliases: ["云岚"], + identifiers: { + unified_social_credit_code: "91110000MA0FAKE001", + }, +}); + +const unixPath = (...parts) => ["", ...parts].join("/"); +const macosPrivatePath = unixPath("Users", "example", "private.env"); +const macosProviderPath = unixPath("Users", "example", "private", "provider.json"); +const macosIndependencePath = unixPath("Users", "fictional", "private-record.json"); +const linuxPrivatePath = unixPath("home", "example", "private.json"); +const windowsPrivatePath = ["C:", "Users", "example", "secret.env"].join("\\"); + +function source(overrides = {}) { + const id = overrides.id || "evidence_professional_business"; + return { + id, + source_key: overrides.source_key || `source:${id}`, + source_kind: "professional", + source_kind_label: "专业数据集", + label: "虚构企业工商记录", + summary: [ + "公司名称:云岚矩阵科技有限公司;", + "统一社会信用代码:91110000MA0FAKE001;", + "经营范围:企业软件技术服务。", + ].join(""), + excerpt: "", + url: "", + published_at: null, + source_updated_at: "2026-07-30T08:00:00.000Z", + entity_match: "verified", + source_quality: "professional", + quality_tier: 1, + official: true, + freshness: "current", + independence_key: `independence:${id}`, + conflict_fields: [], + provider: "datapro", + raw_ref: "", + ...overrides, + }; +} + +function evidencePack(items, overrides = {}) { + return { + entity: structuredClone(ENTITY), + items, + rejected: [], + conflicts: [], + policy: {}, + ...overrides, + }; +} + +function compile(items, overrides = {}) { + return compileDossierEvidenceAtoms({ + evidencePack: evidencePack(items, overrides), + }); +} + +function atomSourceText(atom, pack) { + const item = pack.items.find((candidate) => String(candidate.id) === atom.citation_id); + return String(item?.[atom.source_text_field] || ""); +} + +test("compiler is deterministic for repeated identical input", () => { + const pack = evidencePack([ + source(), + source({ + id: "evidence_public_update", + source_kind: "public", + source_kind_label: "联网搜索", + label: "云岚产品更新公告", + summary: "2026年7月28日,云岚矩阵科技有限公司发布企业软件产品更新。", + url: "https://news.example.com/matrix-update", + published_at: "2026-07-28T08:00:00.000Z", + source_quality: "traceable", + quality_tier: 2, + official: false, + }), + ]); + + assert.deepEqual( + compileDossierEvidenceAtoms({ evidencePack: pack }), + compileDossierEvidenceAtoms({ evidencePack: structuredClone(pack) }), + ); +}); + +test("source order does not change atom ids or stable output ordering", () => { + const firstSource = source(); + const secondSource = source({ + id: "evidence_public_procurement", + source_kind: "public", + source_kind_label: "联网搜索", + label: "采购结果公告", + summary: "2026年7月29日,云岚矩阵科技有限公司入选虚构软件采购项目。", + url: "https://notice.example.com/procurement", + published_at: "2026-07-29T08:00:00.000Z", + source_quality: "official", + quality_tier: 1, + official: true, + }); + + const forward = compile([firstSource, secondSource]); + const reversed = compile([secondSource, firstSource]); + + assert.deepEqual(forward, reversed); + assert.deepEqual( + forward.atoms.map((atom) => atom.id), + reversed.atoms.map((atom) => atom.id), + ); +}); + +test("every quote can be sliced verbatim from its recorded source field", () => { + const pack = evidencePack([source()]); + const result = compileDossierEvidenceAtoms({ evidencePack: pack }); + + assert.ok(result.atoms.length >= 3); + for (const atom of result.atoms) { + const sourceText = atomSourceText(atom, pack); + assert.equal( + sourceText.slice(atom.quote_start, atom.quote_end), + atom.quote, + atom.id, + ); + } +}); + +test("Chinese and English sentence punctuation creates natural atom boundaries", () => { + const item = source({ + summary: [ + "云岚矩阵科技有限公司完成软件版本更新。", + "客户是否进入下一轮验证?", + "项目团队确认测试通过!", + "后续将核验采购范围;", + "The fictional release remains traceable;", + ].join(""), + }); + const result = compile([item]); + const quotes = result.atoms.map((atom) => atom.quote); + + assert.ok(quotes.includes("云岚矩阵科技有限公司完成软件版本更新。")); + assert.ok(quotes.includes("客户是否进入下一轮验证?")); + assert.ok(quotes.includes("项目团队确认测试通过!")); + assert.ok(quotes.includes("后续将核验采购范围;")); + assert.ok(quotes.includes("The fictional release remains traceable;")); +}); + +test("DataPro structured fields remain separate verbatim records", () => { + const item = source(); + const result = compile([item]); + const quotes = result.atoms.map((atom) => atom.quote); + + assert.ok(quotes.includes("公司名称:云岚矩阵科技有限公司;")); + assert.ok(quotes.includes("统一社会信用代码:91110000MA0FAKE001;")); + assert.ok(quotes.includes("经营范围:企业软件技术服务。")); + assert.ok(result.atoms.every((atom) => ( + !atom.quote.includes(";统一社会信用代码") + ))); +}); + +test("numbered and bullet list items compile into complete atoms", () => { + const item = source({ + summary: [ + "1. 云岚矩阵科技有限公司负责虚构平台研发", + "2、项目团队计划验证数据权限", + "- 采购团队将核验交付边界", + ].join("\n"), + }); + const result = compile([item]); + const quotes = result.atoms.map((atom) => atom.quote); + + assert.ok(quotes.some((quote) => quote.startsWith("1. "))); + assert.ok(quotes.some((quote) => quote.startsWith("2、"))); + assert.ok(quotes.some((quote) => quote.startsWith("- "))); + assert.ok(quotes.every((quote) => !quote.includes("\n"))); +}); + +test("bounded long-sentence splitting preserves dates amounts and legal names", () => { + const legalName = "云岚矩阵科技有限公司"; + const date = "2026年7月30日"; + const amount = "人民币320万元"; + const item = source({ + summary: `${"虚构技术背景说明,".repeat(30)}${date}${legalName}记录项目金额${amount}` + + `${"并继续描述测试范围,".repeat(30)}本段结束`, + }); + const result = compile([item]); + + assert.ok(result.atoms.length > 1); + assert.ok(result.atoms.some((atom) => atom.quote.includes(legalName))); + assert.ok(result.atoms.some((atom) => atom.quote.includes(date))); + assert.ok(result.atoms.some((atom) => atom.quote.includes(amount))); + assert.ok(result.atoms.every((atom) => atom.quote.length <= 360)); +}); + +test("date amount ratio and quantity metadata are retained", () => { + const item = source({ + summary: "2026年7月30日,云岚矩阵科技有限公司记录金额320万元、比例18.5%和设备12台。", + }); + const result = compile([item]); + const atom = result.atoms[0]; + + assert.deepEqual(atom.dates, ["2026-07-30"]); + assert.ok(atom.numbers.includes("320")); + assert.ok(atom.numbers.includes("18.5")); + assert.ok(atom.numbers.includes("12")); +}); + +test("legal name and unified social credit code remain strong entity anchors", () => { + const result = compile([source()]); + const legalNameAtom = result.atoms.find((atom) => atom.quote.includes(ENTITY.canonical_name)); + const creditCodeAtom = result.atoms.find((atom) => ( + atom.quote.includes(ENTITY.identifiers.unified_social_credit_code) + )); + + assert.equal(legalNameAtom.entity_match, "verified"); + assert.ok(legalNameAtom.entity_anchors.includes(ENTITY.canonical_name)); + assert.equal(creditCodeAtom.entity_match, "verified"); + assert.ok(creditCodeAtom.entity_anchors.includes( + ENTITY.identifiers.unified_social_credit_code, + )); +}); + +test("brand aliases remain alias_scoped and are not upgraded to legal-entity anchors", () => { + const item = source({ + id: "evidence_alias_news", + source_kind: "public", + source_kind_label: "联网搜索", + label: "云岚发布虚构产品动态", + summary: "云岚发布虚构产品动态并介绍测试计划。", + entity_match: "alias_scoped", + source_quality: "traceable", + quality_tier: 2, + official: false, + url: "https://news.example.com/alias-update", + }); + const result = compile([item]); + + assert.equal(result.atoms.length, 1); + assert.equal(result.atoms[0].entity_match, "alias_scoped"); + assert.deepEqual(result.atoms[0].entity_anchors, ["云岚"]); +}); + +test("another company's risk fact is never marked as a strong target-company match", () => { + const item = source({ + id: "evidence_mixed_risk", + summary: "云岚矩阵科技有限公司关注远川样例科技有限公司受到行政处罚的公开信息。", + entity_match: "verified", + }); + const result = compile([item]); + const riskAtom = result.atoms.find((atom) => atom.quote.includes("行政处罚")); + + assert.ok(riskAtom); + assert.equal(riskAtom.entity_match, "unverified"); + assert.ok(riskAtom.event_families.includes("risk")); + assert.ok(result.diagnostics.some((item) => ( + item.code === "risk_subject_not_strongly_anchored" + && item.atom_id === riskAtom.id + ))); +}); + +test("parent and subsidiary risk facts do not inherit the target company's identity", () => { + const result = compile([ + source({ + id: "parent_company_risk", + summary: "云岚矩阵科技有限公司关注远川控股有限公司受到监管处罚的公开信息。", + entity_match: "verified", + }), + source({ + id: "subsidiary_company_risk", + summary: "云岚矩阵科技有限公司关注云岚样例子公司有限公司涉及诉讼的公开信息。", + entity_match: "verified", + }), + ]); + const riskAtoms = result.atoms.filter((atom) => atom.event_families.includes("risk")); + + assert.equal(riskAtoms.length, 2); + assert.ok(riskAtoms.every((atom) => atom.entity_match === "unverified")); + assert.ok(result.diagnostics.filter((item) => ( + item.code === "risk_subject_not_strongly_anchored" + )).length >= 2); +}); + +test("identical and republished content is deduplicated deterministically", () => { + const shared = "2026年7月29日,云岚矩阵科技有限公司发布虚构软件更新。"; + const sharedIndependenceKey = "official.example.com/update"; + const official = source({ + id: "evidence_official_reprint", + source_kind: "public", + source_kind_label: "联网搜索", + label: "官方更新", + summary: shared, + url: "https://official.example.com/update", + source_quality: "official", + quality_tier: 1, + official: true, + independence_key: sharedIndependenceKey, + }); + const reprint = source({ + id: "evidence_media_reprint", + source_kind: "public", + source_kind_label: "联网搜索", + label: "转载更新", + summary: ` ${shared} `, + url: "https://media.example.com/reprint", + source_quality: "traceable", + quality_tier: 2, + official: false, + independence_key: sharedIndependenceKey, + }); + const forward = compile([reprint, official]); + const reversed = compile([official, reprint]); + + assert.deepEqual(forward, reversed); + assert.equal(forward.atoms.filter((atom) => atom.normalized_text.includes("虚构软件更新")).length, 1); + assert.ok(forward.rejected.some((item) => item.reason === "duplicate_content")); + assert.equal( + forward.atoms.find((atom) => atom.normalized_text.includes("虚构软件更新")).citation_id, + official.id, + ); +}); + +test("identical content from independent professional and official sources is retained", () => { + const shared = "云岚矩阵科技有限公司完成虚构软件项目验收。"; + const professional = source({ + id: "independent_professional", + summary: shared, + independence_key: "datapro:fictional-business-record", + }); + const official = source({ + id: "independent_official", + source_kind: "public", + source_kind_label: "联网搜索", + summary: shared, + url: "https://official.example.com/independent-verification", + source_quality: "official", + quality_tier: 1, + official: true, + independence_key: "official.example.com:independent-verification", + }); + + const forward = compile([professional, official]); + const reversed = compile([official, professional]); + const matching = forward.atoms.filter((atom) => atom.normalized_text === shared); + + assert.deepEqual(forward, reversed); + assert.equal(matching.length, 2); + assert.deepEqual( + new Set(matching.map((atom) => atom.citation_id)), + new Set([professional.id, official.id]), + ); + assert.equal(new Set(matching.map((atom) => atom.independence_hash)).size, 2); +}); + +test("missing independence keys fall back to distinct stable source identities", () => { + const shared = "云岚矩阵科技有限公司记录虚构产品交付进展。"; + const first = source({ + id: "fallback_domain_one", + source_kind: "public", + source_kind_label: "联网搜索", + summary: shared, + url: "https://one.example.com/update", + independence_key: "", + }); + const second = source({ + id: "fallback_domain_two", + source_kind: "public", + source_kind_label: "联网搜索", + summary: shared, + url: "https://two.example.com/update", + independence_key: "", + }); + const result = compile([first, second]); + const matching = result.atoms.filter((atom) => atom.normalized_text === shared); + + assert.equal(matching.length, 2); + assert.equal(new Set(matching.map((atom) => atom.independence_hash)).size, 2); +}); + +test("raw independence keys never enter atom rejected or diagnostic output", () => { + const privateIndependenceKey = `local-source:${macosIndependencePath}`; + const result = compile([source({ + id: "private_source_identity", + independence_key: privateIndependenceKey, + })]); + const serialized = JSON.stringify(result); + + assert.ok(result.atoms.every((atom) => /^[a-f0-9]{64}$/u.test(atom.independence_hash))); + assert.doesNotMatch(serialized, /local-source|\/Users\/fictional|independence_key/); +}); + +test("empty navigation search-status and title-fragment content is rejected with reasons", () => { + const result = compile([ + source({ id: "empty", summary: "", excerpt: "" }), + source({ id: "navigation", summary: "首页 > 产品中心 > 点击查看详情" }), + source({ id: "search_status", summary: "正在搜索相关结果,请稍候加载更多" }), + source({ id: "title_fragment", summary: "云岚矩阵公司最新消息" }), + ]); + const reasons = new Set(result.rejected.map((item) => item.reason)); + + assert.equal(result.atoms.length, 0); + assert.ok(reasons.has("missing_source_text")); + assert.ok(reasons.has("navigation_or_search_status")); + assert.ok(reasons.has("non_substantive_fragment")); +}); + +test("summary and excerpt origins keep their own exact offsets", () => { + const summaryItem = source({ + id: "from_summary", + summary: "云岚矩阵科技有限公司完成虚构产品测试。", + excerpt: "不应优先使用的摘录。", + }); + const excerptItem = source({ + id: "from_excerpt", + summary: "", + excerpt: "云岚矩阵科技有限公司记录虚构项目进度。", + }); + const pack = evidencePack([summaryItem, excerptItem]); + const result = compileDossierEvidenceAtoms({ evidencePack: pack }); + const summaryAtom = result.atoms.find((atom) => atom.citation_id === summaryItem.id); + const excerptAtom = result.atoms.find((atom) => atom.citation_id === excerptItem.id); + + assert.equal(summaryAtom.source_text_field, "summary"); + assert.equal(excerptAtom.source_text_field, "excerpt"); + assert.equal( + summaryItem.summary.slice(summaryAtom.quote_start, summaryAtom.quote_end), + summaryAtom.quote, + ); + assert.equal( + excerptItem.excerpt.slice(excerptAtom.quote_start, excerptAtom.quote_end), + excerptAtom.quote, + ); +}); + +test("sparse evidence returns partial and missing coverage without global failure", () => { + const result = compile([source({ + summary: "公司名称:云岚矩阵科技有限公司;经营范围:企业软件技术服务。", + })]); + + assert.ok(result.atoms.length > 0); + assert.equal(result.coverage.company_overview.status, "supported"); + assert.ok(["partial", "missing"].includes(result.coverage.recent_public_updates.status)); + assert.ok(["partial", "missing"].includes(result.coverage.risk_attention.status)); + assert.deepEqual(Object.keys(result.coverage), [ + "company_overview", + "business_dynamics", + "recent_public_updates", + "risk_attention", + "sales_opportunity", + "recommended_actions", + ]); + assert.doesNotMatch(JSON.stringify(result), /risks_and_attention/); +}); + +test("missing URLs remain null and are never fabricated", () => { + const result = compile([source({ url: "" })]); + + assert.ok(result.atoms.length > 0); + assert.ok(result.atoms.every((atom) => atom.url === null)); +}); + +test("source hashes and atoms exclude credentials paths raw refs and runtime randomness", () => { + const item = source({ + url: "https://evidence.example.com/item?api_key=fake-url-secret&utm_source=test&view=1#token", + raw_ref: `Bearer fake-secret-value ${macosProviderPath}`, + provider_response: { token: "fake-provider-token" }, + pid: 12345, + runtime_log: "private runtime output", + }); + const first = compile([item]); + const second = compile([structuredClone(item)]); + const serialized = JSON.stringify(first); + + assert.deepEqual(first, second); + assert.doesNotMatch(serialized, /fake-secret|fake-provider|\/Users\/|12345|runtime output/); + assert.ok(first.atoms.every((atom) => ( + atom.url === "https://evidence.example.com/item?view=1" + ))); + assert.ok(first.atoms.every((atom) => /^[a-f0-9]{64}$/u.test(atom.source_hash))); + assert.ok(first.atoms.every((atom) => /^E_[a-f0-9]{20}$/u.test(atom.id))); +}); + +test("sensitive source text is rejected without echoing the secret or machine path", () => { + const item = source({ + id: "sensitive_summary", + summary: `API_KEY=fake-secret-value-1234567890,配置位于${macosPrivatePath}。`, + }); + const result = compile([item]); + const serialized = JSON.stringify(result); + + assert.equal(result.atoms.length, 0); + assert.ok(result.rejected.some((entry) => entry.reason === "sensitive_content")); + assert.doesNotMatch(serialized, /fake-secret-value|\/Users\/example/); +}); + +test("local absolute paths adjacent to Chinese text are rejected without being echoed", () => { + const result = compile([ + source({ + id: "local_users_path", + summary: `配置位于${macosPrivatePath}。`, + }), + source({ + id: "local_home_path", + summary: `文件保存在${linuxPrivatePath}。`, + }), + source({ + id: "local_windows_path", + summary: `路径为${windowsPrivatePath}。`, + }), + source({ + id: "public_url", + source_kind: "public", + source_kind_label: "联网搜索", + summary: "云岚矩阵科技有限公司发布公开资料,访问https://docs.example.com/home/public/info。", + url: "https://docs.example.com/home/public/info", + }), + ]); + const serialized = JSON.stringify(result); + const sensitiveRejections = result.rejected.filter((entry) => ( + entry.reason === "sensitive_content" + )); + + assert.equal(sensitiveRejections.length, 3); + assert.doesNotMatch(serialized, /\/Users\/example|\/home\/example|C:\\\\Users\\\\example/); + assert.ok(sensitiveRejections.every((entry) => ( + entry.reason === "sensitive_content" + && !Object.hasOwn(entry, "quote") + ))); + assert.ok(result.atoms.some((atom) => ( + atom.citation_id === "public_url" + && atom.url === "https://docs.example.com/home/public/info" + ))); +}); + +test("compiler never mutates the input evidence pack", () => { + const pack = evidencePack([source()]); + const before = structuredClone(pack); + + compileDossierEvidenceAtoms({ evidencePack: pack }); + + assert.deepEqual(pack, before); +}); + +test("organization and event-family metadata reuse grounding semantics", () => { + const item = source({ + summary: "2026年7月30日,云海样例银行公示云岚矩阵科技有限公司入选软件采购项目。", + }); + const result = compile([item]); + const atom = result.atoms[0]; + + assert.ok(atom.organizations.includes("云海样例银行")); + assert.ok(atom.organizations.includes("云岚矩阵科技有限公司")); + assert.ok(atom.event_families.includes("procurement")); +}); + +test("conflict fields are retained as diagnostics without deleting original evidence", () => { + const item = source({ + summary: "云岚矩阵科技有限公司注册资本为1000万元。", + conflict_fields: ["registered_capital"], + }); + const result = compile([item], { + conflicts: [{ + field: "registered_capital", + field_label: "注册资本", + values: [], + }], + }); + + assert.equal(result.atoms.length, 1); + assert.deepEqual(result.atoms[0].conflict_fields, ["registered_capital"]); + assert.ok(result.diagnostics.some((entry) => entry.code === "source_conflict")); +}); + +test("section candidates are deterministic suggestions and may include multiple chapters", () => { + const item = source({ + id: "multi_section", + source_kind: "public", + source_kind_label: "联网搜索", + summary: "2026年7月30日,云岚矩阵科技有限公司发布软件产品并启动采购项目。", + source_quality: "official", + quality_tier: 1, + official: true, + url: "https://official.example.com/multi-section", + }); + const result = compile([item]); + const atom = result.atoms[0]; + + assert.ok(atom.section_candidates.includes("business_dynamics")); + assert.ok(atom.section_candidates.includes("recent_public_updates")); + assert.ok(atom.section_candidates.includes("sales_opportunity")); + assert.ok(atom.section_candidates.includes("recommended_actions")); + assert.ok(atom.section_candidates.length > 1); +}); + +test("coverage does not impose source-count or distinct-source hard floors", () => { + const result = compile([source({ + summary: [ + "公司名称:云岚矩阵科技有限公司;", + "经营范围:企业软件技术服务;", + "2026年7月30日发布虚构产品更新;", + "项目团队将核验采购范围。", + ].join(""), + })]); + + assert.ok(result.atoms.length >= 3); + assert.ok(Object.values(result.coverage).every((entry) => ( + ["supported", "partial", "missing"].includes(entry.status) + ))); + assert.ok(result.diagnostics.every((entry) => entry.code !== "insufficient_source_count")); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/feishuImportScript.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/feishuImportScript.test.mjs new file mode 100644 index 00000000..bf68e82c --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/feishuImportScript.test.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + backendFetch, + extractDocUrl, + messageMaterial, + parseArgs, + retryable, + withRetry, +} from "../scripts/import-feishu-cli.mjs"; + +test("CLI import accepts a private auth-session path without putting tokens in arguments", () => { + const parsed = parseArgs([ + "--company-id", "company_1", + "--doc", "doxcnExampleToken", + "--auth-session", "/private/state/cli-session.json", + ]); + assert.equal(parsed.authSession, "/private/state/cli-session.json"); +}); + +test("backend requests refresh an expired bearer session and rotate the private file", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "siw-auth-session-")); + const sessionFile = path.join(directory, "cli-session.json"); + fs.writeFileSync(sessionFile, JSON.stringify({ + access_token: "expired-access", + refresh_token: "refresh-token", + }), { mode: 0o600 }); + const originalFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (url, options = {}) => { + calls.push({ url: String(url), authorization: new Headers(options.headers).get("authorization") }); + if (String(url).endsWith("/api/auth/cli-refresh")) { + return new Response(JSON.stringify({ + data: { + access_token: "fresh-access", + refresh_token: "rotated-refresh", + expires_in: 3600, + user: { id: "user_1", role: "member" }, + }, + }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (calls.filter((call) => call.url.endsWith("/api/resource")).length === 1) { + return new Response(JSON.stringify({ error: { code: "authentication_required" } }), { status: 401 }); + } + return new Response(JSON.stringify({ data: { ok: true } }), { status: 200 }); + }; + + try { + const response = await backendFetch("http://127.0.0.1:8787/api/resource", {}, { + apiUrl: "http://127.0.0.1:8787", + authSession: sessionFile, + }); + assert.equal(response.status, 200); + assert.equal(calls[0].authorization, "Bearer expired-access"); + assert.equal(calls[2].authorization, "Bearer fresh-access"); + const rotated = JSON.parse(fs.readFileSync(sessionFile, "utf8")); + assert.equal(rotated.access_token, "fresh-access"); + assert.equal(rotated.refresh_token, "rotated-refresh"); + assert.equal(fs.statSync(sessionFile).mode & 0o077, 0); + } finally { + globalThis.fetch = originalFetch; + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test("a bare Feishu document token does not invent a tenant URL", () => { + assert.equal(extractDocUrl("doxcnExampleToken"), ""); + assert.equal( + extractDocUrl("https://example.feishu.cn/docx/doxcnExampleToken"), + "https://example.feishu.cn/docx/doxcnExampleToken", + ); +}); + +test("message material sorts unordered results before advancing its checkpoint", () => { + const source = { + type: "feishu_search", + external_id: "新能源汽车", + }; + const material = messageMaterial({ + title: "飞书消息搜索:新能源汽车", + source, + messages: [ + { message_id: "m3", create_time: "2026-07-21T03:00:00.000Z", content: "third" }, + { message_id: "m1", create_time: "2026-07-21T01:00:00.000Z", content: "first" }, + { message_id: "m2", create_time: "2026-07-21T02:00:00.000Z", content: "second" }, + ], + targetUser: null, + options: { titlePrefix: "", resumeSource: false }, + }); + + assert.equal(material.occurred_at, "2026-07-21T01:00:00.000Z"); + assert.equal(material.source.checkpoint_value, "2026-07-21T03:00:00.000Z"); + assert.equal(material.source.version, "m3"); + assert.deepEqual(material.source_items.map((item) => item.id), ["m1", "m2", "m3"]); +}); + +test("a non-retryable Feishu error reports the one attempt actually made", async () => { + await assert.rejects( + withRetry( + async () => { + throw new Error("permission denied"); + }, + { maxAttempts: 3, retryDelayMs: 0 }, + ), + (error) => error.message === "permission denied" && error.attempts === 1, + ); +}); + +test("a Feishu user id does not masquerade as a 5xx response", () => { + assert.equal( + retryable("need_user_authorization (user: ou_fixture_user_001)"), + false, + ); + assert.equal(retryable("Backend sync-state failed (503)"), true); + assert.equal(retryable("HTTP 429 too many requests"), true); +}); + +test("a transient Feishu error retries and reports the successful attempt", async () => { + let calls = 0; + const result = await withRetry( + async () => { + calls += 1; + if (calls < 3) throw new Error("temporary network timeout"); + return "ok"; + }, + { maxAttempts: 3, retryDelayMs: 0 }, + ); + + assert.equal(result.value, "ok"); + assert.equal(result.attempts, 3); + assert.equal(calls, 3); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/feishuImportTaskService.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/feishuImportTaskService.test.mjs new file mode 100644 index 00000000..0541290b --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/feishuImportTaskService.test.mjs @@ -0,0 +1,198 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { FeishuImportTaskService } from "../src/services/feishuImportTaskService.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +function salesServiceFake() { + return { + imports: [], + requireCompany(companyId) { + if (companyId !== "company_1") throw new Error("unknown company"); + return { id: companyId }; + }, + getMaterialSyncState(companyId, input) { + return { + company_id: companyId, + source_id: input.source.external_id, + source: { status: "active" }, + checkpoint: null, + }; + }, + async importMaterial(companyId, material) { + this.imports.push({ companyId, material }); + return { + action: "created", + material: { id: "material_1", openviking_status: "ready" }, + source: { id: "source_1" }, + openviking_record: { + status: "ready", + raw_ref: "viking://private/resource", + }, + }; + }, + }; +} + +async function waitForTask(service, companyId, taskId) { + for (let index = 0; index < 20; index += 1) { + const task = service.get(companyId, taskId); + if (!["queued", "running"].includes(task.status)) return task; + await new Promise((resolve) => setTimeout(resolve, 1)); + } + throw new Error("Task did not complete."); +} + +test("controlled Feishu import runs through local adapters and exposes only public task fields", async () => { + const salesService = salesServiceFake(); + let receivedOptions = null; + const service = new FeishuImportTaskService({ + env: envReader({ FEISHU_CLI_IMPORT_ENABLED: "true" }), + salesService, + async runner(options) { + receivedOptions = options; + const state = await options.syncStateLoader({ + type: "feishu_doc", + external_id: "doc-token-123", + display_name: "客户方案", + }); + assert.equal(state.company_id, "company_1"); + const imported = await options.materialImporter({ + title: "飞书云文档:客户方案", + source: { type: "feishu_doc", external_id: "doc-token-123" }, + raw_text: "客户希望先完成小范围验证。", + }); + return { + ok: true, + summary: { created: 1, updated: 0, unchanged: 0, failed: 0 }, + imports: [{ + source_type: "feishu_doc", + title: "飞书云文档:客户方案", + action: imported.action, + status: imported.openviking_record.status, + imported_material_id: imported.material.id, + openviking_ref: imported.openviking_record.raw_ref, + provider_run_id: "provider-run-private", + duration_ms: 12, + }], + }; + }, + }); + + const started = await service.start("company_1", { + source_kind: "document", + target: "https://example.feishu.cn/wiki/doc-token-123", + }); + const completed = await waitForTask(service, "company_1", started.id); + + assert.equal(completed.status, "succeeded"); + assert.deepEqual(receivedOptions.docs, ["https://example.feishu.cn/wiki/doc-token-123"]); + assert.equal(receivedOptions.materialImporter instanceof Function, true); + assert.equal(salesService.imports.length, 1); + assert.equal(completed.result.imports[0].material_id, "material_1"); + assert.doesNotMatch(JSON.stringify(completed), /viking:\/\//i); + assert.doesNotMatch(JSON.stringify(completed), /provider-run-private/i); +}); + +test("conversation targets are bounded and only one import can run per company", async () => { + let release; + const runnerWait = new Promise((resolve) => { + release = resolve; + }); + const service = new FeishuImportTaskService({ + env: envReader({ FEISHU_CLI_IMPORT_ENABLED: "true" }), + salesService: salesServiceFake(), + async runner(options) { + await runnerWait; + return { + ok: true, + summary: { created: 0, updated: 0, unchanged: 1, failed: 0 }, + imports: [{ + source_type: options.chatId ? "feishu_chat" : "feishu_p2p", + action: "unchanged", + status: "skipped", + }], + }; + }, + }); + + const first = await service.start("company_1", { + source_kind: "conversation", + target: "oc_91c21c3c611da52e7555c92866e63a04", + }); + await assert.rejects( + () => service.start("company_1", { + source_kind: "conversation", + target: "联系人姓名", + }), + (error) => error.status === 409 && error.code === "feishu_import_in_progress", + ); + release(); + const completed = await waitForTask(service, "company_1", first.id); + assert.equal(completed.status, "succeeded"); +}); + +test("product import accepts names and chat IDs but rejects Open ID and bare document tokens", () => { + const service = new FeishuImportTaskService({ + env: envReader({ FEISHU_CLI_IMPORT_ENABLED: "true" }), + salesService: salesServiceFake(), + }); + + assert.equal( + service.normalizeRequest("company_1", { + source_kind: "conversation", + target: "客户联系人姓名", + }).target, + "客户联系人姓名", + ); + assert.equal( + service.normalizeRequest("company_1", { + source_kind: "conversation", + target: "oc_91c21c3c611da52e7555c92866e63a04", + }).target, + "oc_91c21c3c611da52e7555c92866e63a04", + ); + assert.throws( + () => service.normalizeRequest("company_1", { + source_kind: "conversation", + target: "ou_91c21c3c611da52e7555c92866e63a04", + }), + /不支持 Open ID/, + ); + assert.throws( + () => service.normalizeRequest("company_1", { + source_kind: "document", + target: "CmTHwndaGi6Uask3bvRcyDYInhf", + }), + /完整的 https:\/\//, + ); + assert.equal( + service.normalizeRequest("company_1", { + source_kind: "document", + target: "https://example.feishu.cn/wiki/CmTHwndaGi6Uask3bvRcyDYInhf", + }).target, + "https://example.feishu.cn/wiki/CmTHwndaGi6Uask3bvRcyDYInhf", + ); +}); + +test("legacy Feishu sync configuration keeps the controlled import available after upgrade", () => { + const service = new FeishuImportTaskService({ + env: envReader({ FEISHU_SYNC_ENABLED: "true" }), + salesService: { + requireCompany() { + return { id: "company-1" }; + }, + }, + }); + + assert.deepEqual(service.status(), { + available: true, + supported_sources: ["conversation", "document"], + }); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/frontendRuntimeContract.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/frontendRuntimeContract.test.mjs new file mode 100644 index 00000000..b62d0fe4 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/frontendRuntimeContract.test.mjs @@ -0,0 +1,212 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const backendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const rootDir = path.resolve(backendDir, ".."); +const appSource = await fs.readFile(path.join(rootDir, "frontend", "app.js"), "utf8"); +const textFormatSource = await fs.readFile(path.join(rootDir, "frontend", "text-format.js"), "utf8"); +const htmlSource = await fs.readFile(path.join(rootDir, "frontend", "index.html"), "utf8"); +const styleSource = await fs.readFile(path.join(rootDir, "frontend", "styles.css"), "utf8"); + +test("formal frontend starts empty and has no user-selectable fixture mode", () => { + assert.match(appSource, /let goals = \[\];\s+let companies = \{\};/); + assert.match(appSource, /function resetConnectedState\(\) \{[\s\S]*?goals = \[\];[\s\S]*?companies = \{\};/); + assert.doesNotMatch(appSource, /DEMO_MODE|SALES_WORKBENCH_MODE|safe-demo|applySafeRecordingData/); + assert.doesNotMatch(appSource, /yutong04|南区销售工作台|星澜新能源|曜驰智能/); +}); + +test("formal frontend keeps the sales workspace free of backend operations content", () => { + assert.match(appSource, /销售智能工作台<\/strong>/); + assert.doesNotMatch(appSource, /api\("\/providers\/status"\)/); + assert.doesNotMatch(appSource, /api\("\/admin\/status"\)/); + assert.match(appSource, /\/jobs\?job_type=sales_dossier_generation&entity_id=/); + assert.match(appSource, /api\(`\/jobs\/\$\{encodeURIComponent\(job\.id\)\}`\)/); + assert.doesNotMatch(appSource, /\/provider-runs\?entity_id=/); + assert.doesNotMatch(appSource, /配置诊断|运维状态|运行与资料管理|真实后端已配置|模型 Token/); + assert.doesNotMatch(appSource, /data-source-action|data-job-action/); + assert.doesNotMatch(appSource, /providers\/model\/probe/); + assert.doesNotMatch(appSource, /ARK_API_KEY|SUPABASE_SERVICE_ROLE_KEY|OPENVIKING_API_KEY/); +}); + +test("formal frontend still exposes the complete sales workflow", () => { + assert.match(appSource, /销售目标/); + assert.match(appSource, /查找企业/); + assert.match(appSource, /目标企业池/); + assert.match(appSource, /获取最新档案/); + assert.match(appSource, /历史资料/); + assert.match(appSource, /MATERIAL_FILTERS = \["全部", "档案", "飞书会话", "云文档"\]/); + assert.doesNotMatch(appSource, /MATERIAL_FILTERS = [^\n]*"会议纪要"/); + assert.match(appSource, /id="openFeishuImport"/); + assert.match(appSource, /联系人姓名或会话 ID/); + assert.match(appSource, /完整的飞书云文档或知识库链接/); + assert.doesNotMatch(appSource, /姓名、Open ID 或会话 ID|飞书云文档链接或 Token/); + assert.doesNotMatch(appSource, /导入企业资料/); + assert.match(appSource, /class="library-tools"[\s\S]*?class="secondary-button library-import-button" id="openFeishuImport"[\s\S]*?>导入飞书资料历史资料<\/button>/); + assert.match(appSource, /data-support-view="qa"[\s\S]*?role="tab"[\s\S]*?>资料问答<\/button>/); + assert.match(appSource, /id="supportLibraryPanel"[\s\S]*?role="tabpanel"/); + assert.match(appSource, /id="supportQaPanel"[\s\S]*?role="tabpanel"/); + assert.match(appSource, /state\.supportView = nextView;/); + assert.match(appSource, /仅根据当前企业档案和用户导入的飞书资料回答/); + assert.match(appSource, /paragraphs: \(message\.paragraphs \|\| \[\]\)/); + assert.match(appSource, /class="qa-answer-body"/); + assert.match(appSource, /function renderQaAnswerParagraph/); + assert.match(appSource, /collapseRepeatedCitationRuns\(qaAnswerParagraphs\(message\)\)/); + assert.match(appSource, /dedupeCitationEntries\(rawCitationEntries\)/); + assert.match(appSource, /citationGroup/); + assert.match(appSource, /class="qa-citation-anchor"/); + assert.doesNotMatch(appSource, /
/); + assert.match(appSource, /window\.SalesTextFormat/); + assert.match(appSource, /function splitDisplayParagraphs/); + assert.match(textFormatSource, /function splitReadableBlocks/); + assert.match(textFormatSource, /function collapseRepeatedCitationRuns/); + assert.match(textFormatSource, /function dedupeCitationEntries/); + assert.doesNotMatch(appSource, /\(\[\^\\n\]\)\(\?=\(\?:\\d\+\[\.\)、\]/); + assert.match(appSource, /class="chat-message assistant is-pending"/); + assert.match(appSource, /const pendingMessages = \[\s+\.\.\.qaMessagesForCompany\(current\),\s+\{ role: "user", text: question \},\s+\];/); + assert.match(appSource, /rememberCompanyQa\(current\.id, pendingMessages\);\s+state\.busy = "qa";/); + assert.match(appSource, /function scrollQaToBottom/); + assert.match(appSource, /\/target-enterprises\/\$\{encodeURIComponent\(current\.id\)\}\/dossiers/); + assert.match(appSource, /\/target-enterprises\/\$\{encodeURIComponent\(current\.id\)\}\/qa/); + assert.match(appSource, /data-cancel-dossier-job/); + assert.match(appSource, /data-retry-dossier-job/); + assert.match(appSource, /function compactDossierStageLabel\(job\)/); + assert.match(appSource, /job\?\.stage_detail\?\.message/); + assert.match(appSource, /正在等待自动重试/); + assert.match(appSource, /正在核验专业资料/); + assert.match(appSource, /正在检索公开资料/); + assert.match(appSource, /正在查找资料/); + assert.match(appSource, /正在核验资料/); + assert.match(appSource, /正在整理档案/); + assert.match(appSource, /正在保存结果/); + assert.match(appSource, /class="dossier-job-spinner"/); + assert.match(appSource, /class="dossier-job-flow"/); + assert.doesNotMatch(appSource, /job\.progress/); + assert.match(styleSource, /@keyframes dossier-job-flow/); + assert.match(styleSource, /animation: dossier-job-flow/); + assert.match(appSource, /window\.sessionStorage\.getItem\(storageKey\)/); + assert.match(appSource, /clearDossierRequestIdempotencyKey\(current\.id\)/); + assert.match(appSource, /job\.stage !== "cancelling"/); + assert.match(appSource, /正在等待当前步骤安全结束后取消/); +}); + +test("formal frontend refreshes the active goal count after adding a company", () => { + assert.match(appSource, /if \(!goal\.pool\.includes\(id\)\) goal\.pool\.push\(id\);\s+goal\.stats = goalStats\(goal\.pool\.length\);/); +}); + +test("dossier versions and citations use API evidence only in formal mode", () => { + assert.match(appSource, /previousDossierId: item\.previous_dossier_id \|\| null/); + assert.doesNotMatch(appSource, /providerRunId|provider_run_id/); + assert.match(appSource, /data-dossier="\$\{escapeHtml\(update\.id\)\}"/); + assert.doesNotMatch(appSource, /与上一版比较|data-compare-dossier|version-comparison|\/compare\//); + assert.match(appSource, /if \(update\.citations\?\.length\) return update\.citations;\s+return \[\];/); + assert.match(appSource, /segments: \(paragraph\.segments \|\| \[\]\)\.map/); + assert.match(appSource, /paragraph\.segments\?\.length/); + assert.match(appSource, /renderTextWithCitations\(segment\.text, segment\.citationIds\)/); + assert.match(appSource, /暂无可验证的引用来源/); + assert.match(appSource, /专业数据集(DataPro)/); + assert.match(appSource, /联网搜索/); + assert.match(appSource, /查看数据明细/); + assert.match(appSource, /未标注发布时间/); + assert.match(appSource, /target="_blank"/); + assert.doesNotMatch(appSource, /source\.qualityLabel/); + assert.doesNotMatch(appSource, /source\.freshnessLabel/); + assert.doesNotMatch(appSource, /source\.verificationLabel/); + assert.doesNotMatch(appSource, /source\.conflictLabel|source\.conflict_label/); + assert.doesNotMatch(appSource, /关键字段存在来源差异/); + assert.doesNotMatch(appSource, /source\.entityMatch/); + assert.match(appSource, /source\.summary \|\| source\.excerpt/); + assert.match(appSource, /function professionalSourceDetails/); + assert.match(appSource, /function renderCitationGroups/); + assert.match(appSource, /function sourceSiteName/); + assert.match(appSource, /function sourcePublishLabel/); + assert.doesNotMatch(appSource, /source\.provider/); + assert.match(appSource, /档案正文暂未加载/); + assert.match(appSource, /function isPlaceholderUrl\(value\)/); + assert.match(appSource, /example\\\.\(com\|test\)/); + assert.match(appSource, /async function loadDossierDetail\(record, attempts = 3\)/); + assert.match(appSource, /系统不会用摘要冒充正文/); + assert.doesNotMatch(appSource, /detailLoadMessage|isPlaceholderUrl is not defined/); + assert.doesNotMatch(appSource, /body: item\.summary \|\| ""/); + assert.doesNotMatch(appSource, /\.catch\(\(\) => mapDossierFromApi\(record\)\)/); +}); + +test("formal frontend retries connections without exposing backend error details", () => { + assert.match(appSource, /function apiErrorMessage\(_error, fallback\) \{\s*return fallback \|\| "操作没有完成,请稍后重试。";/); + assert.match( + appSource, + /catch \(error\) \{\s*state\.showNewGoal = false;\s*state\.sidebarNotice = apiErrorMessage\(error, "暂时没能创建销售目标,请稍后再试。"\)/, + ); + assert.match(appSource, /id="retryBoot"/); + assert.match(appSource, /\$\("#retryBoot"\)\?\.addEventListener\("click"/); + assert.match(appSource, /工作台加载时间较长,请稍后重试/); + assert.doesNotMatch(appSource, /无法读取后端业务数据|后端响应超时|请求 \$\{error\.requestId\}|task\.error\?\.message/); +}); + +test("formal frontend authenticates before loading business data and protects mutations with CSRF", () => { + assert.match(appSource, /api\("\/auth\/status", \{ skipAuthRedirect: true \}\)/); + assert.match(appSource, /bootstrap \? "\/auth\/bootstrap" : "\/auth\/login"/); + assert.match(appSource, /name="username" autocomplete="username"/); + assert.doesNotMatch(appSource, /name="email"|type="email"/); + assert.match(appSource, /credentials: "same-origin"/); + assert.match(appSource, /cookieValue\("siw_csrf"\)/); + assert.match(appSource, /headers\["X-CSRF-Token"\] = csrfToken/); + assert.match(appSource, /id="logoutButton"/); + assert.match(appSource, /api\("\/auth\/logout", \{ method: "POST", skipAuthRedirect: true \}\)/); + assert.doesNotMatch(appSource, /AGENT_PLAN_API_KEY|SUPABASE_API_URL|service-role-secret|siw_access/); +}); + +test("formal frontend exposes one local administrator and no email or member flows", () => { + assert.doesNotMatch(appSource, /openMemberAdmin|memberInviteForm|\/admin\/members/); + assert.match(appSource, /设置本机管理员/); + assert.doesNotMatch(appSource, /reset-password|忘记密码|找回密码|重置密码/); + assert.doesNotMatch(appSource, /passwordRecoveryForm|\/auth\/password\/recover|\/auth\/password\/update/); + assert.doesNotMatch(appSource, /工作区成员|成员管理|成员邀请|找回密码|重置邮件/); + assert.doesNotMatch(appSource, /SUPABASE_SERVICE_ROLE_KEY|service_role/); +}); + +test("formal frontend assets carry the current cache key and responsive runtime styles", () => { + assert.match(htmlSource, /销售智能工作台<\/title>/); + assert.match(htmlSource, /20260730-source-list/); + assert.match(htmlSource, /text-format\.js/); + assert.match(styleSource, /\.version-tabs/); + assert.match(styleSource, /\.citation-group/); + assert.match(styleSource, /\.citation-source-row/); + assert.match(styleSource, /\.professional-source-details/); + assert.match(styleSource, /\.qa-answer-body/); + assert.match(styleSource, /\.qa-answer-paragraph/); + assert.match(styleSource, /\.qa-citation-anchor/); + assert.match(styleSource, /vertical-align: super/); + assert.match(styleSource, /\.chat-message\.is-pending/); + assert.match(styleSource, /@keyframes qa-pending-pulse/); + assert.match(styleSource, /\.dossier-report-section/); + assert.match(styleSource, /\.dossier-report-content/); + assert.match(styleSource, /\.library-dossier-link/); + assert.match(styleSource, /\.connection-retry/); + assert.match(styleSource, /\.auth-panel/); + assert.match(styleSource, /\.dialog-modal/); + assert.doesNotMatch(styleSource, /\.member-modal/); + assert.match(styleSource, /\.feishu-import-modal/); + assert.match(styleSource, /\.library-import-button/); + assert.match(styleSource, /@media \(max-width: 780px\)/); + assert.match(styleSource, /\.sales-layout\.is-mobile-navigation-open \.sales-sidebar/); + assert.match(appSource, /id="mobileNavigationToggle"/); +}); + +test("HTTP-hosted frontend uses the same-origin API by default", () => { + assert.match(appSource, /window\.location\.origin/); + assert.match(appSource, /`\$\{window\.location\.origin\}\/api`/); + assert.match(appSource, /\["http:", "https:"\]\.includes\(window\.location\.protocol\)/); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/frontendTextFormat.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/frontendTextFormat.test.mjs new file mode 100644 index 00000000..f723b399 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/frontendTextFormat.test.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import vm from "node:vm"; +import { fileURLToPath } from "node:url"; + +const backendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const source = await fs.readFile( + path.resolve(backendDir, "..", "frontend", "text-format.js"), + "utf8", +); +const sandbox = {}; +sandbox.globalThis = sandbox; +vm.runInNewContext(source, sandbox); + +const { + collapseRepeatedCitationRuns, + dedupeCitationEntries, + normalizeChineseTypography, + splitReadableBlocks, +} = sandbox.SalesTextFormat; + +test("Chinese typography keeps decimals and business numbers intact", () => { + const sourceText = "公开报道分别给出2769.17亿元与9.17亿元,需进一步交叉核验。"; + const normalized = normalizeChineseTypography(sourceText); + assert.match(normalized, /2769\.17亿元与9\.17亿元,/); + assert.doesNotMatch(normalized, /\n/); +}); + +test("readable blocks never treat years or decimals as inline list markers", () => { + const paragraphs = Array.from(splitReadableBlocks( + "2026年7月,公司披露业务进展。风险数据需交叉核验,公开来源给出2769.17亿元与9.17亿元两个口径。", + 180, + )); + assert.equal(paragraphs.length, 1); + assert.match(paragraphs[0], /2026年7月/); + assert.match(paragraphs[0], /2769\.17亿元与9\.17亿元/); +}); + +test("readable blocks repair model line breaks inside percentages and amounts", () => { + const paragraphs = Array.from(splitReadableBlocks( + "产能利用率约\n\n9\n\n4.86%,两篇报道分别给出\n\n2\n\n7\n\n6\n\n9.17亿元与2769亿元。", + 180, + )); + assert.equal(paragraphs.length, 1); + assert.match(paragraphs[0], /产能利用率约94\.86%/); + assert.match(paragraphs[0], /分别给出2769\.17亿元与2769亿元/); +}); + +test("readable blocks preserve real numbered list lines", () => { + const paragraphs = Array.from(splitReadableBlocks( + "建议行动:\n1. 核验法定主体与公开事项归属\n2. 确认采购部门和预算窗口", + 180, + )); + assert.deepEqual(paragraphs, [ + "建议行动:", + "1. 核验法定主体与公开事项归属", + "2. 确认采购部门和预算窗口", + ]); +}); + +test("readable blocks split inline Arabic numbered actions into separate lines", () => { + const paragraphs = Array.from(splitReadableBlocks( + "建议行动:1)核验主体。2)联系采购部门。3)确认预算窗口。", + 180, + )); + assert.deepEqual(paragraphs, [ + "建议行动:", + "1)核验主体。", + "2)联系采购部门。", + "3)确认预算窗口。", + ]); +}); + +test("readable blocks split dot-numbered actions and preserve years and decimals", () => { + const paragraphs = Array.from(splitReadableBlocks( + "建议行动:1. 联系采购部门。2. 核验2026年项目窗口。3. 确认9.17亿元口径。", + 180, + )); + assert.deepEqual(paragraphs, [ + "建议行动:", + "1. 联系采购部门。", + "2. 核验2026年项目窗口。", + "3. 确认9.17亿元口径。", + ]); +}); + +test("readable blocks also split compact dot-numbered actions", () => { + const paragraphs = Array.from(splitReadableBlocks( + "建议行动:1.联系采购部门。2.确认预算窗口。3.准备合规材料。", + 180, + )); + assert.deepEqual(paragraphs, [ + "建议行动:", + "1.联系采购部门。", + "2.确认预算窗口。", + "3.准备合规材料。", + ]); +}); + +test("readable blocks split Chinese ordinal points into separate lines", () => { + const paragraphs = Array.from(splitReadableBlocks( + "销售机会判断:第一,确认业务场景。第二,核验采购时机。第三,准备合规材料。", + 180, + )); + assert.deepEqual(paragraphs, [ + "销售机会判断:", + "第一,确认业务场景。", + "第二,核验采购时机。", + "第三,准备合规材料。", + ]); +}); + +test("readable blocks split 一是 style points without breaking years or decimals", () => { + const paragraphs = Array.from(splitReadableBlocks( + "判断如下:一是关注2026年项目。二是核验9.17亿元口径。三是确认责任部门。", + 180, + )); + assert.deepEqual(paragraphs, [ + "判断如下:", + "一是关注2026年项目。", + "二是核验9.17亿元口径。", + "三是确认责任部门。", + ]); +}); + +test("consecutive answer blocks with the same evidence show one citation at the end of the run", () => { + const paragraphs = Array.from(collapseRepeatedCitationRuns([ + { text: "第一点。", citationIds: ["source-1"] }, + { text: "第二点。", citationIds: ["source-1"] }, + { text: "第三点。", citationIds: ["source-1"] }, + { text: "补充事实。", citationIds: ["source-2"] }, + ])); + assert.deepEqual( + paragraphs.map((item) => Array.from(item.displayCitationIds)), + [[], [], ["source-1"], ["source-2"]], + ); +}); + +test("citation runs keep separate markers when the supporting source set changes", () => { + const paragraphs = Array.from(collapseRepeatedCitationRuns([ + { text: "第一组。", citationIds: ["source-1", "source-2"] }, + { text: "第二组。", citationIds: ["source-2", "source-1"] }, + { text: "第三组。", citationIds: ["source-1"] }, + ])); + assert.deepEqual( + paragraphs.map((item) => Array.from(item.displayCitationIds)), + [[], ["source-2", "source-1"], ["source-1"]], + ); +}); + +test("citation runs do not merge across original answer paragraphs", () => { + const paragraphs = Array.from(collapseRepeatedCitationRuns([ + { text: "第一段第一点。", citationIds: ["source-1"], citationGroup: 0 }, + { text: "第一段第二点。", citationIds: ["source-1"], citationGroup: 0 }, + { text: "第二段。", citationIds: ["source-1"], citationGroup: 1 }, + ])); + assert.deepEqual( + paragraphs.map((item) => Array.from(item.displayCitationIds)), + [[], ["source-1"], ["source-1"]], + ); +}); + +test("duplicate source labels share one visible source number without losing citation ids", () => { + const result = dedupeCitationEntries([ + { id: "chunk-1", label: "飞书云文档:客户需求确认会" }, + { id: "chunk-2", label: "飞书云文档:客户需求确认会" }, + { id: "dossier-1", label: "最近档案 V2" }, + ]); + assert.deepEqual( + Array.from(result.entries, (item) => item.label), + ["飞书云文档:客户需求确认会", "最近档案 V2"], + ); + assert.deepEqual( + JSON.parse(JSON.stringify(result.citationNumbers)), + { "chunk-1": 1, "chunk-2": 1, "dossier-1": 2 }, + ); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/httpSecurity.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/httpSecurity.test.mjs new file mode 100644 index 00000000..7a07bbb3 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/httpSecurity.test.mjs @@ -0,0 +1,386 @@ +import assert from "node:assert/strict"; +import { Readable } from "node:stream"; +import test from "node:test"; + +import { createRouter } from "../src/routes/index.js"; +import { HttpError } from "../src/utils/http.js"; +import { createRateLimiters } from "../src/security/rateLimiter.js"; + +const roles = Object.freeze({ viewer: 0, member: 1, admin: 2, owner: 3 }); + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + number(name, fallback) { + const value = Number(this.value(name, fallback)); + return Number.isFinite(value) ? value : fallback; + }, + }; +} + +function authServiceStub() { + const auditEvents = []; + return { + auditEvents, + async sessionStatus() { + return { enabled: true, authenticated: false, bootstrap_required: true, user: null }; + }, + async bootstrap() { + return { authenticated: true, user: { role: "owner" } }; + }, + async login() { + return { authenticated: true, user: { role: "member" } }; + }, + async refresh() { + return { authenticated: true, user: { role: "member" } }; + }, + async logout() { + return { authenticated: false }; + }, + async recordAudit(auth, event) { + auditEvents.push({ actor_user_id: auth?.principal?.id || null, ...structuredClone(event) }); + return true; + }, + async listAuditEvents() { + return structuredClone(auditEvents); + }, + async authenticateRequest(req) { + const bearer = String(req.headers.authorization || "").match(/^Bearer\s+(.+)$/i)?.[1]; + if (bearer && Object.hasOwn(roles, bearer)) { + return { source: "bearer", principal: { id: `${bearer}-id`, role: bearer } }; + } + if (String(req.headers.cookie || "").includes("session=member")) { + return { source: "cookie", principal: { id: "cookie-member", role: "member" } }; + } + return null; + }, + requireRole(auth, required) { + if (!auth) throw new HttpError(401, "authentication_required", "请先登录。"); + if (roles[auth.principal.role] < roles[required]) { + throw new HttpError(403, "insufficient_role", "权限不足。"); + } + }, + assertCsrf(req, auth) { + if (auth?.source === "cookie" && req.headers["x-csrf-token"] !== "csrf-ok") { + throw new HttpError(403, "csrf_failed", "CSRF failed."); + } + }, + }; +} + +function requestRouter(router, pathname, options = {}) { + const body = options.body || ""; + const headers = Object.fromEntries( + Object.entries(options.headers || {}).map(([name, value]) => [name.toLowerCase(), value]), + ); + if (body && !headers["content-length"]) headers["content-length"] = String(Buffer.byteLength(body)); + const req = Readable.from(body ? [Buffer.from(body)] : []); + req.method = options.method || "GET"; + req.url = pathname; + req.headers = headers; + req.socket = { remoteAddress: "127.0.0.1" }; + return new Promise((resolve, reject) => { + const responseHeaders = {}; + const res = { + statusCode: null, + setHeader(name, value) { + responseHeaders[String(name).toLowerCase()] = value; + }, + writeHead(statusCode, extraHeaders = {}) { + this.statusCode = statusCode; + for (const [name, value] of Object.entries(extraHeaders)) this.setHeader(name, value); + }, + end(responseBody = "") { + const text = Buffer.isBuffer(responseBody) ? responseBody.toString("utf8") : String(responseBody || ""); + resolve({ + status: this.statusCode, + headers: responseHeaders, + text, + json: () => JSON.parse(text || "{}"), + }); + }, + }; + Promise.resolve(router(req, res)).catch(reject); + }); +} + +async function withRouter(run, options = {}) { + const env = envReader({ + API_MAX_BODY_BYTES: "1024", + ALLOWED_ORIGINS: "https://allowed.example", + API_RATE_LIMIT_PER_MIN: "1000", + API_WRITE_RATE_LIMIT_PER_MIN: "1000", + API_PAID_RATE_LIMIT_PER_MIN: "1000", + AUTH_RATE_LIMIT_PER_15_MIN: "1000", + }); + const salesService = { + assertRuntimeReady: async () => {}, + listGoals: () => [{ id: "goal-1", name: "测试目标" }], + createGoal: async (body) => ({ id: "goal-created", name: body.name }), + exportWorkspaceData: () => ({ format: "sales-intelligence-workbench-workspace-export" }), + ...(options.salesService || {}), + }; + const service = { getProviderStatus: () => ({ providers: [] }) }; + const router = createRouter(service, { + env, + salesService, + authService: options.authService || authServiceStub(), + rateLimiters: createRateLimiters(env), + runtimePolicy: options.runtimePolicy || { + ready: true, + fail_closed: false, + blockers: [], + }, + }); + await run((pathname, options) => requestRouter(router, pathname, options)); +} + +test("dossier detail reuses freshly loaded sales state instead of forcing a full Supabase refresh", async () => { + const refreshOptions = []; + await withRouter(async (request) => { + const response = await request("/api/dossiers/dossier-1", { + headers: { authorization: "Bearer viewer" }, + }); + assert.equal(response.status, 200); + assert.equal(response.json().data.id, "dossier-1"); + }, { + salesService: { + refreshPersistedState: async (options) => { + refreshOptions.push(options); + }, + dossierDetail: () => ({ id: "dossier-1", body: [], citations: [] }), + }, + }); + assert.deepEqual(refreshOptions, [{ minIntervalMs: 5_000 }]); +}); + +test("asynchronous dossier routes return 202 and expose only safe task progress", async () => { + const calls = []; + const publicJob = { + id: "job-public-1", + job_type: "sales_dossier_generation", + status: "queued", + stage: "queued", + stage_label: "等待执行", + progress: 0, + entity_type: "target_enterprise", + entity_id: "company-1", + attempt_count: 0, + max_attempts: 3, + retryable: false, + error: null, + result: null, + }; + const internalJob = { + ...publicJob, + request: { hidden_prompt: "private" }, + worker_id: "worker-private", + reservation_id: "reservation-private", + created_by: "member-id", + }; + const toPublicJob = (job) => Object.fromEntries( + Object.entries(job).filter(([key]) => !["request", "worker_id", "reservation_id", "created_by"].includes(key)), + ); + + await withRouter(async (request) => { + const created = await request("/api/target-enterprises/company-1/dossiers", { + method: "POST", + headers: { Authorization: "Bearer member", "Content-Type": "application/json" }, + body: JSON.stringify({ idempotency_key: "request-1" }), + }); + assert.equal(created.status, 202); + assert.equal(created.json().data.id, publicJob.id); + assert.equal(calls[0].body.idempotency_key, "request-1"); + assert.equal(calls[0].options.created_by, "member-id"); + + const listed = await request("/api/jobs?job_type=sales_dossier_generation&entity_id=company-1&limit=1", { + headers: { Authorization: "Bearer viewer" }, + }); + assert.equal(listed.status, 200); + assert.deepEqual(listed.json().data, [publicJob]); + assert.doesNotMatch(listed.text, /hidden_prompt|worker-private|reservation-private/); + + const detail = await request(`/api/jobs/${publicJob.id}`, { + headers: { Authorization: "Bearer viewer" }, + }); + assert.equal(detail.status, 200); + assert.doesNotMatch(detail.text, /hidden_prompt|worker-private|reservation-private/); + + assert.equal((await request(`/api/jobs/${publicJob.id}/cancel`, { + method: "POST", + headers: { Authorization: "Bearer viewer" }, + })).status, 403); + assert.equal((await request(`/api/jobs/${publicJob.id}/cancel`, { + method: "POST", + headers: { Authorization: "Bearer member" }, + })).status, 200); + assert.equal((await request(`/api/jobs/${publicJob.id}/retry`, { + method: "POST", + headers: { Authorization: "Bearer member" }, + })).status, 200); + }, { + runtimePolicy: { + ready: true, + fail_closed: true, + blockers: [], + }, + salesService: { + asyncJobsEnabled: true, + async enqueueDossier(companyId, body, options) { + calls.push({ companyId, body, options }); + return publicJob; + }, + async listPublicJobs() { + return [publicJob]; + }, + async getPublicJob() { + return publicJob; + }, + async cancelJob() { + return { ...internalJob, status: "cancelled", stage: "cancelled" }; + }, + publicJob: toPublicJob, + async retryJob() { + return publicJob; + }, + }, + }); +}); + +test("health stays public while sales and provider APIs enforce role boundaries", async () => { + await withRouter(async (request) => { + assert.equal((await request("/api/health")).status, 200); + assert.equal((await request("/api/sales-goals")).status, 401); + assert.equal((await request("/api/sales-goals", { + headers: { Authorization: "Bearer viewer" }, + })).status, 200); + assert.equal((await request("/api/sales-goals", { + method: "POST", + headers: { Authorization: "Bearer viewer", "Content-Type": "application/json" }, + body: JSON.stringify({ name: "目标" }), + })).status, 403); + assert.equal((await request("/api/sales-goals", { + method: "POST", + headers: { Authorization: "Bearer member", "Content-Type": "application/json" }, + body: JSON.stringify({ name: "目标" }), + })).status, 201); + assert.equal((await request("/api/providers/status", { + headers: { Authorization: "Bearer viewer" }, + })).status, 403); + assert.equal((await request("/api/providers/status", { + headers: { Authorization: "Bearer admin" }, + })).status, 200); + }); +}); + +test("email recovery and multi-user administration are not exposed", async () => { + await withRouter(async (request) => { + assert.equal((await request("/api/auth/password/recover", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "user@example.com" }), + })).status, 404); + assert.equal((await request("/api/auth/password/update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: "a-secure-password" }), + })).status, 404); + assert.equal((await request("/api/admin/members", { + headers: { Authorization: "Bearer admin" }, + })).status, 404); + assert.equal((await request("/api/admin/members", { + method: "POST", + headers: { Authorization: "Bearer admin", "Content-Type": "application/json" }, + body: JSON.stringify({ email: "new@example.com", role: "member" }), + })).status, 404); + }); +}); + +test("workspace business export is owner-only and never cacheable", async () => { + await withRouter(async (request) => { + assert.equal((await request("/api/admin/workspace-export")).status, 401); + assert.equal((await request("/api/admin/workspace-export", { + headers: { Authorization: "Bearer admin" }, + })).status, 403); + const exported = await request("/api/admin/workspace-export", { + headers: { Authorization: "Bearer owner" }, + }); + assert.equal(exported.status, 200); + assert.equal(exported.headers["cache-control"], "no-store"); + assert.equal(exported.json().data.format, "sales-intelligence-workbench-workspace-export"); + }); +}); + +test("business mutations write metadata-only audit events and admins can list them", async () => { + const authService = authServiceStub(); + await withRouter(async (request) => { + const created = await request("/api/sales-goals", { + method: "POST", + headers: { Authorization: "Bearer member", "Content-Type": "application/json" }, + body: JSON.stringify({ name: "华东重点客户", password: "must-not-enter-audit" }), + }); + assert.equal(created.status, 201); + assert.equal(authService.auditEvents.length, 1); + assert.equal(authService.auditEvents[0].action, "sales_goal.created"); + assert.equal(authService.auditEvents[0].entity_type, "sales_goal"); + assert.equal(authService.auditEvents[0].entity_id, "goal-created"); + assert.equal(JSON.stringify(authService.auditEvents[0]).includes("must-not-enter-audit"), false); + + const denied = await request("/api/admin/audit-events", { + headers: { Authorization: "Bearer member" }, + }); + assert.equal(denied.status, 403); + + const listed = await request("/api/admin/audit-events", { + headers: { Authorization: "Bearer admin" }, + }); + assert.equal(listed.status, 200); + assert.equal(listed.json().data[0].action, "sales_goal.created"); + }, { authService }); +}); + +test("cookie mutations require CSRF and oversized JSON is rejected", async () => { + await withRouter(async (request) => { + assert.equal((await request("/api/sales-goals", { + method: "POST", + headers: { Cookie: "session=member", "Content-Type": "application/json" }, + body: JSON.stringify({ name: "目标" }), + })).status, 403); + assert.equal((await request("/api/sales-goals", { + method: "POST", + headers: { Cookie: "session=member", "X-CSRF-Token": "csrf-ok", "Content-Type": "application/json" }, + body: JSON.stringify({ name: "目标" }), + })).status, 201); + const oversized = await request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "local-admin", password: "x".repeat(1500) }), + }); + assert.equal(oversized.status, 413); + assert.equal(oversized.json().error.code, "payload_too_large"); + }); +}); + +test("CORS reflects only explicitly allowed origins and never uses a wildcard", async () => { + await withRouter(async (request) => { + const sameOrigin = await request("/api/health", { + headers: { + Origin: "http://127.0.0.1:8877", + Host: "127.0.0.1:8877", + }, + }); + assert.equal(sameOrigin.status, 200); + assert.equal(sameOrigin.headers["access-control-allow-origin"], undefined); + + const allowed = await request("/api/health", { headers: { Origin: "https://allowed.example" } }); + assert.equal(allowed.status, 200); + assert.equal(allowed.headers["access-control-allow-origin"], "https://allowed.example"); + assert.equal(allowed.headers["access-control-allow-credentials"], "true"); + const rejected = await request("/api/health", { headers: { Origin: "https://evil.example" } }); + assert.equal(rejected.status, 403); + assert.equal(rejected.headers["access-control-allow-origin"], undefined); + assert.notEqual(allowed.headers["content-security-policy"], undefined); + }); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/materialImport.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/materialImport.test.mjs new file mode 100644 index 00000000..7f79b1d6 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/materialImport.test.mjs @@ -0,0 +1,471 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SalesService } from "../src/services/salesService.js"; + +const permissiveTestPolicy = Object.freeze({ + fail_closed: false, +}); + +const strictRuntimePolicy = Object.freeze({ + fail_closed: true, +}); + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +function seed() { + return { + goals: [], + companies: { + company_a: { + id: "company_a", + name: "企业 A", + industry: "测试行业", + material_ids: [], + dossier_ids: [], + }, + company_b: { + id: "company_b", + name: "企业 B", + industry: "测试行业", + material_ids: [], + dossier_ids: [], + }, + }, + dossiers: {}, + materials: {}, + qa_messages: {}, + sync_sources: {}, + sync_checkpoints: {}, + }; +} + +function openVikingFake() { + const writes = []; + const finds = []; + const resources = new Map(); + return { + writes, + finds, + resources, + reads: [], + removals: [], + sessionMessages: [], + sessionUses: [], + sessionCommits: [], + isConfigured: () => true, + isRunEnabled: () => true, + salesCompanyUri: ({ workspaceId, companyId }) => `viking://sales/${workspaceId}/${companyId}`, + salesMaterialUri: ({ workspaceId, companyId, sourceId }) => `viking://sales/${workspaceId}/${companyId}/${sourceId}.md`, + salesDossierUri: ({ workspaceId, companyId, dossierId }) => `viking://sales/${workspaceId}/${companyId}/dossiers/${dossierId}.md`, + salesSessionId: ({ workspaceId, companyId }) => `sales-${workspaceId}-${companyId}`, + async upsertTextResource(input) { + writes.push(input); + resources.set(input.uri, input.content); + return { + ok: true, + uri: input.uri, + raw_ref: input.uri, + summary: "stored", + }; + }, + async readTextResource(uri) { + this.reads.push(uri); + if (!resources.has(uri)) { + return { + ok: false, + http_status: 404, + error: { code: "not_found", message: "Resource not found" }, + }; + } + return { + ok: true, + uri, + content: resources.get(uri), + raw_ref: uri, + }; + }, + async findMemories(query, options) { + finds.push({ query, options }); + return { ok: true, result: { resources: [] } }; + }, + async removeResource(uri) { + this.removals.push(uri); + return { ok: true, uri, raw_ref: uri }; + }, + async addSessionMessages(sessionId, messages) { + this.sessionMessages.push({ sessionId, messages }); + return { ok: true, raw_ref: `openviking:session:${sessionId}:messages` }; + }, + async recordSessionUsed(sessionId, contexts) { + this.sessionUses.push({ sessionId, contexts }); + return { ok: true, raw_ref: `openviking:session:${sessionId}:used` }; + }, + async commitSession(sessionId) { + this.sessionCommits.push(sessionId); + return { ok: true, raw_ref: `openviking:session:${sessionId}:commit` }; + }, + }; +} + +function createService(provider = openVikingFake()) { + return { + provider, + service: new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + openVikingProvider: provider, + }), + }; +} + +test("same source and content is skipped while changed content updates one material", async () => { + const { service, provider } = createService(); + const source = { + type: "feishu_doc", + external_id: "doc-token-1", + checkpoint_key: "revision_id", + checkpoint_value: "1", + config: { document_id: "doc-1", access_token: "must-not-persist" }, + }; + + const created = await service.importMaterial("company_a", { + title: "客户方案", + source, + source_url: "https://example.feishu.cn/wiki/doc-token-1", + raw_text: "第一版内容", + }); + const unchanged = await service.importMaterial("company_a", { + title: "客户方案", + source, + source_url: "https://example.feishu.cn/wiki/doc-token-1", + raw_text: "第一版内容", + }); + const updated = await service.importMaterial("company_a", { + title: "客户方案", + source: { ...source, checkpoint_value: "2" }, + source_url: "https://example.feishu.cn/wiki/doc-token-1", + raw_text: "第二版内容", + }); + + assert.equal(created.action, "created"); + assert.equal(unchanged.action, "unchanged"); + assert.equal(updated.action, "updated"); + assert.equal(created.material.id, unchanged.material.id); + assert.equal(created.material.id, updated.material.id); + assert.equal(provider.writes.length, 2); + assert.equal(provider.writes[0].mode, "create"); + assert.equal(provider.writes[1].mode, "replace"); + assert.equal(updated.checkpoint.checkpoint_value, "2"); + assert.equal(Object.hasOwn(updated.source.config, "access_token"), false); + assert.ok(created.provider_run_id); + assert.equal(service.listMaterials("company_a").length, 1); +}); + +test("incremental Feishu messages merge by id instead of replacing history", async () => { + const { service, provider } = createService(); + const source = { + type: "feishu_p2p", + external_id: "oc_p2p_1", + checkpoint_key: "last_message", + }; + + const first = await service.importMaterial("company_a", { + title: "客户沟通", + source: { ...source, checkpoint_value: "2026-07-20T10:00:00Z" }, + source_items: [ + { id: "om_1", occurred_at: "2026-07-20T10:00:00Z", sender: "客户", content: "需要私有化部署" }, + ], + }); + const second = await service.importMaterial("company_a", { + title: "客户沟通", + source: { ...source, checkpoint_value: "2026-07-20T11:00:00Z" }, + source_items: [ + { id: "om_1", occurred_at: "2026-07-20T10:00:00Z", sender: "客户", content: "需要私有化部署" }, + { id: "om_2", occurred_at: "2026-07-20T11:00:00Z", sender: "销售", content: "已安排方案评审" }, + ], + }); + + const stored = service.data.materials[first.material.id]; + assert.equal(second.action, "updated"); + assert.deepEqual(stored.source_items.map((item) => item.id), ["om_1", "om_2"]); + assert.match(stored.text, /需要私有化部署/); + assert.match(stored.text, /已安排方案评审/); + assert.equal(provider.writes.length, 2); +}); + +test("incremental Feishu import restores prior content from OpenViking after a process restart", async () => { + const provider = openVikingFake(); + const firstService = createService(provider).service; + const source = { + type: "feishu_p2p", + external_id: "oc_restart_1", + checkpoint_key: "last_message", + }; + + const first = await firstService.importMaterial("company_a", { + title: "重启恢复沟通", + source: { ...source, checkpoint_value: "2026-07-20T10:00:00Z" }, + source_items: [ + { id: "om_restart_1", occurred_at: "2026-07-20T10:00:00Z", sender: "客户", content: "需要私有化部署" }, + ], + }); + + const persistedSeed = structuredClone(firstService.data); + persistedSeed.materials[first.material.id].summary = ""; + persistedSeed.materials[first.material.id].text = ""; + persistedSeed.materials[first.material.id].source_items = []; + const restartedService = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: persistedSeed, + openVikingProvider: provider, + }); + + const second = await restartedService.importMaterial("company_a", { + title: "重启恢复沟通", + source: { ...source, checkpoint_value: "2026-07-20T11:00:00Z" }, + source_items: [ + { id: "om_restart_2", occurred_at: "2026-07-20T11:00:00Z", sender: "销售", content: "已安排方案评审" }, + ], + }); + + const stored = restartedService.data.materials[first.material.id]; + assert.equal(second.action, "updated"); + assert.deepEqual(stored.source_items.map((item) => item.id), ["om_restart_1", "om_restart_2"]); + assert.match(stored.text, /需要私有化部署/); + assert.match(stored.text, /已安排方案评审/); + assert.deepEqual(provider.reads, [stored.openviking_uri]); +}); + +test("OpenViking retrieval is restricted to the selected company's Feishu materials subtree", async () => { + const { service, provider } = createService(); + + await service.searchOpenViking(service.data.companies.company_a, "预算情况"); + await service.searchOpenViking(service.data.companies.company_b, "预算情况"); + + assert.equal(provider.finds[0].options.uri, "viking://sales/workspace-test/company_a/materials"); + assert.equal(provider.finds[1].options.uri, "viking://sales/workspace-test/company_b/materials"); + assert.notEqual(provider.finds[0].options.uri, provider.finds[1].options.uri); +}); + +test("runtime does not disguise an empty OpenViking retrieval with local materials", async () => { + const provider = openVikingFake(); + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + openVikingProvider: provider, + }); + service.data.materials.material_1 = { + id: "material_1", + company_id: "company_a", + title: "客户沟通纪要", + source_type: "飞书会议纪要", + text: "客户预算为 100 万元。", + summary: "客户预算为 100 万元。", + openviking_uri: "viking://sales/workspace-test/company_a/material_1.md", + }; + service.data.companies.company_a.material_ids.push("material_1"); + + const contexts = await service.searchOpenViking( + service.data.companies.company_a, + "客户预算是多少?", + ); + + assert.deepEqual(contexts, []); + assert.equal(provider.finds.length, 1); +}); + +test("test policy does not disguise an empty OpenViking retrieval with local material content", async () => { + const { service } = createService(); + service.data.materials.material_1 = { + id: "material_1", + company_id: "company_a", + title: "客户沟通纪要", + source_type: "飞书会议纪要", + text: "客户预算为 100 万元。", + summary: "客户预算为 100 万元。", + openviking_uri: "viking://sales/workspace-test/company_a/material_1.md", + }; + service.data.companies.company_a.material_ids.push("material_1"); + + const contexts = await service.searchOpenViking( + service.data.companies.company_a, + "客户预算是多少?", + ); + + assert.deepEqual(contexts, []); +}); + +test("QA material fallback excludes local records that were not imported from Feishu", async () => { + const { service } = createService(); + service.data.materials.material_1 = { + id: "material_1", + company_id: "company_a", + title: "手工备注", + source_type: "manual", + text: "这条内容不是用户导入的飞书资料。", + summary: "这条内容不是用户导入的飞书资料。", + }; + service.data.companies.company_a.material_ids.push("material_1"); + + const contexts = await service.searchOpenViking( + service.data.companies.company_a, + "客户预算是多少?", + ); + + assert.deepEqual(contexts, []); +}); + +test("QA writes use a workspace-and-company-scoped OpenViking session", async () => { + const { service, provider } = createService(); + const company = service.data.companies.company_a; + + await service.captureQaSession( + company, + { text: "预算是多少?" }, + { text: "当前资料未提供明确预算。" }, + [{ uri: "viking://sales/workspace-test/company_a/materials/source.md" }], + ); + const committed = await service.commitQaMemory("company_a"); + + assert.equal(provider.writes.length, 0); + assert.equal(provider.sessionMessages[0].sessionId, "sales-workspace-test-company_a"); + assert.equal(provider.sessionUses[0].sessionId, "sales-workspace-test-company_a"); + assert.deepEqual(provider.sessionCommits, ["sales-workspace-test-company_a"]); + assert.equal(committed.status, "ready"); + assert.ok(committed.job_id); + assert.ok(committed.provider_run_id); + assert.equal((await service.getJob(committed.job_id)).status, "succeeded"); + assert.equal((await service.getProviderRun(committed.provider_run_id)).job_id, committed.job_id); + assert.equal(Object.hasOwn(committed, "raw_ref"), false); +}); + +test("QA capture keeps the actual OpenViking session id for later persistence and commit", async () => { + const provider = openVikingFake(); + provider.addSessionMessages = async function addSessionMessages(sessionId, messages) { + this.sessionMessages.push({ sessionId, messages }); + return { + ok: true, + session_id: "server-session-company-a", + raw_ref: "openviking:session:server-session-company-a:messages", + }; + }; + const { service } = createService(provider); + const company = service.data.companies.company_a; + + const captured = await service.captureQaSession( + company, + { text: "客户关心什么?" }, + { text: "客户关心数据权限边界。" }, + [{ uri: "viking://sales/workspace-test/company_a/materials/source.md" }], + ); + await service.commitQaMemory("company_a"); + + assert.equal(captured.session_id, "server-session-company-a"); + assert.equal(company.qa_session_id, "server-session-company-a"); + assert.equal(provider.sessionUses[0].sessionId, "server-session-company-a"); + assert.deepEqual(provider.sessionCommits, ["server-session-company-a"]); +}); + +test("paused sources require an explicit resume before importing", async () => { + const { service } = createService(); + const body = { + title: "暂停资料", + source: { type: "feishu_doc", external_id: "paused-doc" }, + raw_text: "内容", + }; + const identity = service.getMaterialSyncState("company_a", body); + service.data.sync_sources[identity.source_id] = { + id: identity.source_id, + status: "paused", + }; + + await assert.rejects( + () => service.importMaterial("company_a", body), + (error) => error.status === 409 && error.code === "sync_source_paused", + ); + const resumed = await service.importMaterial("company_a", { ...body, resume_source: true }); + assert.equal(resumed.action, "created"); +}); + +test("source lifecycle supports pause, resume and deletion from Supabase/OpenViking state", async () => { + const { service, provider } = createService(); + const body = { + title: "待维护资料", + source: { type: "feishu_doc", external_id: "lifecycle-doc" }, + raw_text: "内容", + }; + const imported = await service.importMaterial("company_a", body); + const openVikingUri = service.data.materials[imported.material.id].openviking_uri; + + const sources = service.listMaterialSyncSources("company_a"); + const paused = await service.updateMaterialSyncSource("company_a", { source_id: imported.source.id, action: "pause" }); + const resumed = await service.updateMaterialSyncSource("company_a", { source_id: imported.source.id, action: "resume" }); + const deleted = await service.updateMaterialSyncSource("company_a", { source_id: imported.source.id, action: "delete" }); + + assert.equal(sources.length, 1); + assert.equal(sources[0].id, imported.source.id); + assert.equal(sources[0].material_count, 1); + assert.deepEqual(sources[0].material_ids, [imported.material.id]); + assert.equal(sources[0].checkpoint.last_success_at, imported.checkpoint.last_success_at); + assert.equal(sources[0].openviking_statuses.ready, 1); + assert.equal(paused.source.status, "paused"); + assert.equal(resumed.source.status, "active"); + assert.equal(deleted.source.status, "deleted"); + assert.deepEqual(deleted.affected_material_ids, [imported.material.id]); + assert.deepEqual(provider.removals, [openVikingUri]); + assert.ok(deleted.job_id); + assert.ok(deleted.provider_run_id); + assert.equal((await service.getJob(deleted.job_id)).status, "succeeded"); + assert.equal((await service.getProviderRun(deleted.provider_run_id)).job_id, deleted.job_id); + assert.doesNotMatch(JSON.stringify(imported.openviking_record), /viking:\/\//i); + assert.equal(service.data.materials[imported.material.id], undefined); + assert.deepEqual(service.data.companies.company_a.material_ids, []); +}); + +test("batch material sync is guarded, traceable and keeps raw OpenViking refs private", async () => { + const { service, provider } = createService(); + const imported = await service.importMaterial("company_a", { + title: "客户需求纪要", + source: { type: "feishu_doc", external_id: "batch-sync-doc" }, + raw_text: "客户计划在第三季度完成技术评估。", + }); + provider.writes.length = 0; + + const synced = await service.syncMaterialsToOpenViking("company_a"); + + assert.equal(synced.status, "ready"); + assert.equal(synced.records.length, 1); + assert.equal(synced.records[0].material_id, imported.material.id); + assert.ok(synced.job_id); + assert.ok(synced.provider_run_id); + assert.equal((await service.getJob(synced.job_id)).status, "succeeded"); + assert.equal((await service.getProviderRun(synced.provider_run_id)).job_id, synced.job_id); + assert.equal(provider.writes.length, 1); + assert.doesNotMatch(JSON.stringify(synced), /viking:\/\//i); +}); + +test("source lifecycle rejects a source_id that is not attached to the selected company", async () => { + const { service } = createService(); + const imported = await service.importMaterial("company_a", { + title: "企业 A 私有资料", + source: { type: "feishu_doc", external_id: "company-a-doc" }, + raw_text: "仅属于企业 A 的内容", + }); + + await assert.rejects( + () => service.updateMaterialSyncSource("company_b", { source_id: imported.source.id, action: "pause" }), + (error) => error.status === 404 + && error.code === "sync_source_not_found" + && error.details?.company_id === "company_b", + ); + assert.equal(service.data.sync_sources[imported.source.id].status, "active"); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/materialSync.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/materialSync.test.mjs new file mode 100644 index 00000000..b648cd30 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/materialSync.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildMaterialSyncIdentity, + makeMaterialContentHash, + makeMaterialId, + makeSyncSourceId, + mergeSourceItems, + normalizeExternalId, + renderSourceItems, +} from "../src/sync/materialSync.js"; + +test("Feishu document URLs resolve to one stable source and material identity", () => { + const first = buildMaterialSyncIdentity("company-1", { + title: "销售方案", + source_type: "飞书云文档", + source_url: "https://example.feishu.cn/wiki/AbCdEf?from=copy#section", + }); + const second = buildMaterialSyncIdentity("company-1", { + title: "销售方案(改名)", + source: { + type: "feishu_doc", + external_id: "AbCdEf", + }, + }); + + assert.equal(normalizeExternalId("feishu_doc", first.source_url), "AbCdEf"); + assert.equal(first.source_id, second.source_id); + assert.equal(first.material_id, second.material_id); + assert.equal(first.source_type, "feishu_doc"); +}); + +test("stable identifiers are isolated by source and company", () => { + const sourceA = makeSyncSourceId("feishu_chat", "oc_a"); + const sourceB = makeSyncSourceId("feishu_chat", "oc_b"); + + assert.notEqual(sourceA, sourceB); + assert.notEqual(makeMaterialId("company-a", sourceA), makeMaterialId("company-b", sourceA)); +}); + +test("material hashes ignore line-ending noise but change with business content", () => { + const first = makeMaterialContentHash({ title: "纪要", text: "第一行\r\n第二行" }); + const same = makeMaterialContentHash({ title: "纪要", text: "第一行\n第二行" }); + const changed = makeMaterialContentHash({ title: "纪要", text: "第一行\n内容已更新" }); + + assert.equal(first, same); + assert.notEqual(first, changed); +}); + +test("incremental message items merge by message id and honor deletions", () => { + const merged = mergeSourceItems( + [ + { id: "om_1", occurred_at: "2026-07-20T10:00:00Z", sender: "甲", content: "旧内容" }, + { id: "om_2", occurred_at: "2026-07-20T11:00:00Z", sender: "乙", content: "待删除" }, + ], + [ + { id: "om_1", occurred_at: "2026-07-20T10:00:00Z", sender: "甲", content: "更新内容" }, + { id: "om_2", deleted: true }, + { id: "om_3", occurred_at: "2026-07-20T12:00:00Z", sender: "乙", content: "新增内容" }, + ], + ); + + assert.deepEqual(merged.map((item) => item.id), ["om_1", "om_3"]); + assert.match(renderSourceItems(merged), /更新内容/); + assert.doesNotMatch(renderSourceItems(merged), /待删除/); +}); + diff --git a/demohouse/sales-intelligence-workbench/backend/tests/modelProvider.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/modelProvider.test.mjs new file mode 100644 index 00000000..5547499e --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/modelProvider.test.mjs @@ -0,0 +1,346 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ModelProvider } from "../src/providers/modelProvider.js"; + +function env(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + number(name, fallback) { + const parsed = Number(Object.hasOwn(values, name) ? values[name] : fallback); + return Number.isFinite(parsed) ? parsed : fallback; + }, + }; +} + +test("model timeout defaults to 90 seconds for structured generation", () => { + const provider = new ModelProvider({ env: env() }); + assert.equal(provider.timeoutMs, 90_000); +}); + +test("model timeout is configurable and bounded", () => { + assert.equal(new ModelProvider({ env: env({ MODEL_TIMEOUT_MS: "120000" }) }).timeoutMs, 120_000); + assert.equal(new ModelProvider({ env: env({ MODEL_TIMEOUT_MS: "1000" }) }).timeoutMs, 5_000); + assert.equal(new ModelProvider({ env: env({ MODEL_TIMEOUT_MS: "600000" }) }).timeoutMs, 300_000); +}); + +test("required function calls retry one transient upstream failure", async () => { + let callCount = 0; + const provider = new ModelProvider({ + env: env({ + AGENT_PLAN_API_KEY: "test-agent-plan-key", + MODEL_BASE_URL: "https://ark.example.test/api/plan/v3", + MODEL_NAME: "ark-code-latest", + MODEL_MAX_RETRIES: "1", + }), + sleep: async () => {}, + fetchImpl: async () => { + callCount += 1; + if (callCount === 1) { + return new Response(JSON.stringify({ + error: { code: "service_unavailable", message: "Service temporarily unavailable." }, + }), { status: 503, headers: { "Content-Type": "application/json" } }); + } + return new Response(JSON.stringify({ + id: "resp_function_retry", + status: "completed", + output: [{ + type: "function_call", + call_id: "call_retry", + name: "submit_sales_dossier", + arguments: "{\"summary\":\"ready\"}", + }], + }), { status: 200, headers: { "Content-Type": "application/json" } }); + }, + }); + + const result = await provider.callRequiredFunction({ + system: "Call the required tool.", + payload: { task: "probe" }, + functionName: "submit_sales_dossier", + functionDescription: "Submit dossier.", + parameters: { + type: "object", + additionalProperties: false, + properties: { summary: { type: "string" } }, + required: ["summary"], + }, + }); + + assert.equal(callCount, 2); + assert.equal(result.ok, true); + assert.equal(result.attempts, 2); + assert.deepEqual(result.parsed, { summary: "ready" }); +}); + +test("structured model calls use the Agent Plan Responses API and normalize usage", async () => { + let captured; + const provider = new ModelProvider({ + env: env({ + AGENT_PLAN_API_KEY: "test-agent-plan-key", + MODEL_BASE_URL: "https://ark.example.test/api/plan/v3", + MODEL_NAME: "ark-code-latest", + MODEL_RUN_ENABLED: "true", + }), + fetchImpl: async (url, options) => { + captured = { url, options, body: JSON.parse(options.body) }; + return new Response(JSON.stringify({ + id: "resp_test_1", + model: "glm-test", + output: [ + { + type: "message", + content: [{ type: "output_text", text: "{\"ok\":true,\"message\":\"ready\"}" }], + }, + ], + usage: { + input_tokens: 24, + output_tokens: 8, + total_tokens: 32, + output_tokens_details: { reasoning_tokens: 0 }, + }, + }), { status: 200, headers: { "Content-Type": "application/json" } }); + }, + }); + + const result = await provider.callJson({ + operation: "test", + system: "Only JSON.", + payload: { task: "probe" }, + maxTokens: 80, + }); + + assert.equal(captured.url, "https://ark.example.test/api/plan/v3/responses"); + assert.equal(captured.options.headers.Authorization, "Bearer test-agent-plan-key"); + assert.equal(captured.body.model, "ark-code-latest"); + assert.equal(captured.body.instructions, "Only JSON."); + assert.equal(captured.body.input, JSON.stringify({ task: "probe" })); + assert.equal(captured.body.max_output_tokens, 80); + assert.deepEqual(captured.body.thinking, { type: "disabled" }); + assert.deepEqual(captured.body.text, { format: { type: "json_object" } }); + assert.equal(Object.hasOwn(captured.body, "messages"), false); + assert.equal(Object.hasOwn(captured.body, "max_tokens"), false); + assert.equal(result.ok, true); + assert.deepEqual(result.parsed, { ok: true, message: "ready" }); + assert.deepEqual(result.usage, { + prompt_tokens: 24, + completion_tokens: 8, + total_tokens: 32, + reasoning_tokens: 0, + }); +}); + +test("structured model calls extract the first balanced JSON value from surrounding text", async () => { + const provider = new ModelProvider({ + env: env({ + AGENT_PLAN_API_KEY: "test-agent-plan-key", + MODEL_BASE_URL: "https://ark.example.test/api/plan/v3", + MODEL_NAME: "ark-code-latest", + MODEL_RUN_ENABLED: "true", + }), + fetchImpl: async () => new Response(JSON.stringify({ + id: "resp_balanced_json", + output_text: [ + "以下是结果:", + "```json", + "{\"message\":\"正文中的 } 和 ] 不应提前结束\",\"items\":[1,2]}", + "```", + "以上为结构化结果。", + ].join("\n"), + }), { status: 200, headers: { "Content-Type": "application/json" } }), + }); + + const result = await provider.callJson({ + operation: "test", + system: "Only JSON.", + payload: { task: "probe" }, + }); + + assert.equal(result.ok, true); + assert.deepEqual(result.parsed, { + message: "正文中的 } 和 ] 不应提前结束", + items: [1, 2], + }); +}); + +test("invalid structured output is retained only as bounded in-memory repair input", async () => { + const malformed = `{"title":"档案","body":[{"text":"未闭合`; + const provider = new ModelProvider({ + env: env({ + AGENT_PLAN_API_KEY: "test-agent-plan-key", + MODEL_BASE_URL: "https://ark.example.test/api/plan/v3", + MODEL_NAME: "ark-code-latest", + MODEL_RUN_ENABLED: "true", + }), + fetchImpl: async () => new Response(JSON.stringify({ + id: "resp_invalid_json", + output_text: malformed, + }), { status: 200, headers: { "Content-Type": "application/json" } }), + }); + + const result = await provider.callJson({ + operation: "test", + system: "Only JSON.", + payload: { task: "probe" }, + }); + + assert.equal(result.ok, false); + assert.equal(result.error.code, "invalid_json"); + assert.equal(result.invalid_content, malformed); + assert.equal(result.raw_ref, "model:resp_invalid_json"); +}); + +test("required function calls use a strict single-tool Responses contract", async () => { + let captured; + const provider = new ModelProvider({ + env: env({ + AGENT_PLAN_API_KEY: "test-agent-plan-key", + MODEL_BASE_URL: "https://ark.example.test/api/plan/v3", + MODEL_NAME: "ark-code-latest", + MODEL_RUN_ENABLED: "true", + }), + fetchImpl: async (url, options) => { + captured = { url, body: JSON.parse(options.body) }; + return new Response(JSON.stringify({ + id: "resp_function_1", + status: "completed", + model: "ark-code-latest", + output: [{ + type: "function_call", + call_id: "call_dossier_1", + name: "submit_sales_dossier", + arguments: "{\"summary\":\"ready\"}", + }], + usage: { + input_tokens: 40, + output_tokens: 12, + total_tokens: 52, + }, + }), { status: 200, headers: { "Content-Type": "application/json" } }); + }, + }); + const parameters = { + type: "object", + additionalProperties: false, + properties: { summary: { type: "string" } }, + required: ["summary"], + }; + + const result = await provider.callRequiredFunction({ + operation: "dossier_agent", + system: "Call the required tool.", + payload: { task: "probe" }, + functionName: "submit_sales_dossier", + functionDescription: "Submit dossier.", + parameters, + maxTokens: 900, + }); + + assert.equal(captured.url, "https://ark.example.test/api/plan/v3/responses"); + assert.equal(captured.body.store, false); + assert.equal(captured.body.tool_choice, "required"); + assert.equal(captured.body.text, undefined); + assert.deepEqual(captured.body.tools, [{ + type: "function", + name: "submit_sales_dossier", + description: "Submit dossier.", + strict: true, + parameters, + }]); + assert.equal(result.ok, true); + assert.deepEqual(result.parsed, { summary: "ready" }); + assert.equal(result.function_call_id, "call_dossier_1"); + assert.equal(result.raw_ref, "model:resp_function_1"); + assert.deepEqual(result.usage, { + prompt_tokens: 40, + completion_tokens: 12, + total_tokens: 52, + }); +}); + +test("required function calls reject incomplete responses before parsing output", async () => { + const provider = new ModelProvider({ + env: env({ + AGENT_PLAN_API_KEY: "test-agent-plan-key", + MODEL_BASE_URL: "https://ark.example.test/api/plan/v3", + MODEL_NAME: "ark-code-latest", + }), + fetchImpl: async () => new Response(JSON.stringify({ + id: "resp_function_incomplete", + status: "incomplete", + incomplete_details: { reason: "max_output_tokens" }, + output: [], + }), { status: 200, headers: { "Content-Type": "application/json" } }), + }); + + const result = await provider.callRequiredFunction({ + system: "Call the required tool.", + payload: { task: "probe" }, + functionName: "submit_sales_dossier", + functionDescription: "Submit dossier.", + parameters: { + type: "object", + additionalProperties: false, + properties: { summary: { type: "string" } }, + required: ["summary"], + }, + }); + + assert.equal(result.ok, false); + assert.equal(result.error.code, "incomplete_response"); + assert.equal(result.error.retryable, true); + assert.equal(result.raw_ref, "model:resp_function_incomplete"); +}); + +test("required function calls reject missing or malformed tool arguments", async () => { + const responses = [ + { + id: "resp_function_missing", + status: "completed", + output: [{ type: "message", content: [{ type: "output_text", text: "plain text" }] }], + }, + { + id: "resp_function_invalid", + status: "completed", + output: [{ + type: "function_call", + call_id: "call_invalid", + name: "submit_sales_dossier", + arguments: "{\"summary\":", + }], + }, + ]; + const provider = new ModelProvider({ + env: env({ + AGENT_PLAN_API_KEY: "test-agent-plan-key", + MODEL_BASE_URL: "https://ark.example.test/api/plan/v3", + MODEL_NAME: "ark-code-latest", + }), + fetchImpl: async () => new Response(JSON.stringify(responses.shift()), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + }); + const request = { + system: "Call the required tool.", + payload: { task: "probe" }, + functionName: "submit_sales_dossier", + functionDescription: "Submit dossier.", + parameters: { + type: "object", + additionalProperties: false, + properties: { summary: { type: "string" } }, + required: ["summary"], + }, + }; + + const missing = await provider.callRequiredFunction(request); + const invalid = await provider.callRequiredFunction(request); + + assert.equal(missing.ok, false); + assert.equal(missing.error.code, "missing_function_call"); + assert.equal(invalid.ok, false); + assert.equal(invalid.error.code, "invalid_function_arguments"); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/openVikingProvider.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/openVikingProvider.test.mjs new file mode 100644 index 00000000..3808ed55 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/openVikingProvider.test.mjs @@ -0,0 +1,258 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { OpenVikingProvider } from "../src/providers/openVikingProvider.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + number(name, fallback) { + return Object.hasOwn(values, name) ? Number(values[name]) : fallback; + }, + }; +} + +test("OpenViking timeout allows long resource ingestion and remains bounded", () => { + assert.equal(new OpenVikingProvider({ env: envReader() }).timeoutMs, 120_000); + assert.equal(new OpenVikingProvider({ env: envReader({ OPENVIKING_TIMEOUT_MS: "180000" }) }).timeoutMs, 180_000); + assert.equal(new OpenVikingProvider({ env: envReader({ OPENVIKING_TIMEOUT_MS: "1000" }) }).timeoutMs, 5_000); + assert.equal(new OpenVikingProvider({ env: envReader({ OPENVIKING_TIMEOUT_MS: "900000" }) }).timeoutMs, 300_000); +}); + +test("sales OpenViking URIs isolate workspace, company and source", () => { + const provider = new OpenVikingProvider({ + env: envReader({ OPENVIKING_SALES_ROOT_URI: "viking://resources/sales-root" }), + }); + + assert.equal( + provider.salesMaterialUri({ workspaceId: "Workspace A", companyId: "Company A", sourceId: "sync_123" }), + "viking://resources/sales-root/workspace-a/companies/company-a/materials/sync_123.md", + ); + assert.notEqual( + provider.salesCompanyUri({ workspaceId: "workspace-a", companyId: "company-a" }), + provider.salesCompanyUri({ workspaceId: "workspace-a", companyId: "company-b" }), + ); + assert.equal( + provider.salesDossierUri({ workspaceId: "Workspace A", companyId: "Company A", dossierId: "Dossier 1" }), + "viking://resources/sales-root/workspace-a/companies/company-a/dossiers/dossier-1.md", + ); + assert.equal( + provider.salesSessionId({ workspaceId: "Workspace A", companyId: "Company A" }), + "sales-workspace-a-company-a", + ); +}); + +test("text resource writes use explicit create and replace modes", async () => { + const calls = []; + const provider = new OpenVikingProvider({ + env: envReader({ + OPENVIKING_RUN_ENABLED: "true", + OPENVIKING_CLI: process.execPath, + }), + execFile: async (_command, args) => { + calls.push(args); + return { + stdout: JSON.stringify({ + ok: true, + result: { semantic_status: "queued", vector_status: "queued" }, + }), + stderr: "", + }; + }, + }); + const uri = provider.salesMaterialUri({ workspaceId: "workspace-a", companyId: "company-a", sourceId: "source-a" }); + + const created = await provider.upsertTextResource({ uri, content: "first", mode: "create" }); + const updated = await provider.upsertTextResource({ uri, content: "second", mode: "replace" }); + + assert.equal(created.ok, true); + assert.equal(updated.ok, true); + assert.equal(created.uri, uri); + assert.equal(created.processing_status, "queued"); + assert.deepEqual(calls[0], ["--agent-id", "default", "write", uri, "--content", "first", "--mode", "create", "-o", "json"]); + assert.deepEqual(calls[1], ["--agent-id", "default", "write", uri, "--content", "second", "--mode", "replace", "-o", "json"]); +}); + +test("text resource reads return canonical content from the official HTTP endpoint", async () => { + const calls = []; + const provider = new OpenVikingProvider({ + env: envReader({ + OPENVIKING_BASE_URL: "https://openviking.example", + OPENVIKING_API_KEY: "private-key", + }), + fetchImpl: async (url, options) => { + calls.push({ url, options }); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ result: { content: "# 飞书资料\n客户关注私有化部署。" } }); + }, + }; + }, + }); + + const result = await provider.readTextResource("viking://resources/company/material.md"); + + assert.equal(result.ok, true); + assert.equal(result.content, "# 飞书资料\n客户关注私有化部署。"); + assert.equal(calls[0].options.method, "GET"); + assert.equal(calls[0].options.body, undefined); + assert.match(calls[0].url, /\/api\/v1\/content\/read\?uri=/); + assert.match(calls[0].url, /raw=true$/); +}); + +test("company-scoped retrieval passes the exact subtree URI", async () => { + const calls = []; + const provider = new OpenVikingProvider({ + env: envReader({ OPENVIKING_CLI: process.execPath }), + execFile: async (_command, args) => { + calls.push(args); + return { stdout: JSON.stringify({ result: { resources: [] } }), stderr: "" }; + }, + }); + const uri = provider.salesCompanyUri({ workspaceId: "workspace-a", companyId: "company-a" }); + + const result = await provider.findMemories("预算", { uri, limit: 5 }); + + assert.equal(result.ok, true); + assert.deepEqual(calls[0], ["--agent-id", "default", "find", "预算", "--uri", uri, "--node-limit", "5", "-o", "json"]); +}); + +test("session capture follows the official create and per-message HTTP flow", async () => { + const calls = []; + const response = (status, payload) => ({ + ok: status >= 200 && status < 300, + status, + async text() { + return JSON.stringify(payload); + }, + }); + const provider = new OpenVikingProvider({ + env: envReader({ + OPENVIKING_RUN_ENABLED: "true", + OPENVIKING_BASE_URL: "https://openviking.example", + OPENVIKING_API_KEY: "private-key", + OPENVIKING_AGENT_ID: "sales-workbench", + }), + fetchImpl: async (url, options) => { + calls.push({ url, options }); + if (options.method === "GET") { + return response(404, { status: "error", error: { code: "NOT_FOUND", message: "Session not found" } }); + } + if (url.endsWith("/api/v1/sessions")) { + return response(200, { result: { session_id: "sales-workspace-company" } }); + } + return response(200, { result: { ok: true } }); + }, + }); + + const captured = await provider.addSessionMessages("sales-workspace-company", [ + { role: "user", content: "客户关注数据权限。" }, + { role: "assistant", content: "下一步确认权限边界。" }, + ]); + const committed = await provider.commitSession(captured.session_id); + const deleted = await provider.deleteSession(captured.session_id); + + assert.equal(captured.ok, true); + assert.equal(captured.created, true); + assert.equal(captured.session_id, "sales-workspace-company"); + assert.equal(committed.ok, true); + assert.equal(deleted.ok, true); + assert.equal(calls.some((call) => call.url.includes("/messages/batch")), false); + assert.deepEqual(JSON.parse(calls[1].options.body), { session_id: "sales-workspace-company" }); + assert.deepEqual(JSON.parse(calls[2].options.body), { + role: "user", + parts: [{ type: "text", text: "客户关注数据权限。" }], + }); + assert.deepEqual(JSON.parse(calls[3].options.body), { + role: "assistant", + parts: [{ type: "text", text: "下一步确认权限边界。" }], + }); + assert.deepEqual(JSON.parse(calls[4].options.body), { + telemetry: false, + keep_recent_count: 6, + }); + assert.equal(calls[5].options.method, "DELETE"); + assert.equal(calls[5].options.body, undefined); + assert.ok(calls.every((call) => call.options.headers["X-OpenViking-Agent"] === "sales-workbench")); + assert.ok(calls.every((call) => call.options.headers.Authorization === "Bearer private-key")); +}); + +test("session context restores normalized live messages and archive overview", async () => { + const calls = []; + const provider = new OpenVikingProvider({ + env: envReader({ + OPENVIKING_BASE_URL: "https://openviking.example", + OPENVIKING_API_KEY: "private-key", + }), + fetchImpl: async (url, options) => { + calls.push({ url, options }); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + result: { + latest_archive_overview: "客户持续关注数据权限。", + messages: [ + { + id: "message-1", + role: "user", + parts: [{ type: "text", text: "预算确认了吗?" }], + created_at: "2026-07-26T09:00:00.000Z", + }, + { + id: "message-2", + role: "assistant", + parts: [{ type: "text", text: "资料中尚未确认预算。" }], + created_at: "2026-07-26T09:00:01.000Z", + }, + ], + }, + }); + }, + }; + }, + }); + + const result = await provider.getSessionContext("sales-company-a", { tokenBudget: 2400 }); + + assert.equal(result.ok, true); + assert.equal(result.latest_archive_overview, "客户持续关注数据权限。"); + assert.deepEqual(result.messages.map(({ id, role, text }) => ({ id, role, text })), [ + { id: "message-1", role: "user", text: "预算确认了吗?" }, + { id: "message-2", role: "assistant", text: "资料中尚未确认预算。" }, + ]); + assert.equal(calls[0].options.method, "GET"); + assert.match(calls[0].url, /\/sessions\/sales-company-a\/context\?token_budget=2400$/); +}); + +test("local ovcli config supplies HTTP URL, API key and agent identity", () => { + const provider = new OpenVikingProvider({ + env: envReader(), + cliConfig: { + url: "https://api.vikingdb.cn-beijing.volces.com/openviking", + api_key: "local-private-key", + agent_id: "local-agent", + }, + }); + + assert.equal(provider.baseUrl, "https://api.vikingdb.cn-beijing.volces.com/openviking"); + assert.equal(provider.apiKey, "local-private-key"); + assert.equal(provider.agentId, "local-agent"); + assert.equal(provider.isConfigured(), true); +}); + +test("Agent Plan key is not reused as OpenViking data-plane authentication", () => { + const provider = new OpenVikingProvider({ + env: envReader({ + AGENT_PLAN_API_KEY: "agent-plan-key", + OPENVIKING_BASE_URL: "https://api.vikingdb.cn-beijing.volces.com/openviking", + }), + cliConfig: {}, + }); + + assert.equal(provider.apiKey, ""); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/openVikingQaBoundary.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/openVikingQaBoundary.test.mjs new file mode 100644 index 00000000..8c640dfe --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/openVikingQaBoundary.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { WORKSPACE_TABLE_SPECS, RESTORE_ORDER } from "../src/backup/supabaseBackup.js"; +import { SupabaseDataRepository } from "../src/repositories/supabaseDataRepository.js"; + +const backendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const rootDir = path.resolve(backendDir, ".."); +const migration = await fs.readFile( + path.join(rootDir, "supabase", "migrations", "202607280001_openviking_qa_boundary.sql"), + "utf8", +); + +test("OpenViking QA boundary is delivered as a non-destructive forward migration", () => { + assert.match(migration, /rename to sales_qa_messages_legacy/i); + assert.match(migration, /revoke all[\s\S]*?from public, anon, authenticated/i); + assert.match(migration, /grant all[\s\S]*?to service_role/i); + assert.match(migration, /values \('202607280001'/); + assert.doesNotMatch(migration, /drop table|delete from|truncate/i); +}); + +test("Supabase backup and restore never carry legacy QA message bodies", () => { + assert.equal(WORKSPACE_TABLE_SPECS.some(({ table }) => table === "sales_qa_messages"), false); + assert.equal(WORKSPACE_TABLE_SPECS.some(({ table }) => table === "sales_qa_messages_legacy"), false); + assert.equal(RESTORE_ORDER.includes("sales_qa_messages"), false); + assert.equal(RESTORE_ORDER.includes("sales_qa_messages_legacy"), false); +}); + +test("Supabase Data API repository exposes no QA body persistence method", () => { + assert.equal(Object.hasOwn(SupabaseDataRepository.prototype, "persistSalesQaMessage"), false); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/paidWorkflowGuard.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/paidWorkflowGuard.test.mjs new file mode 100644 index 00000000..f998b16f --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/paidWorkflowGuard.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { PaidWorkflowGuard, paidWorkflowLimits } from "../src/limits/paidWorkflowGuard.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +function job(id, type = "sales_dossier_generation") { + const createdAt = new Date().toISOString(); + return { + id, + job_type: type, + status: "running", + attempt_count: 1, + max_attempts: 2, + started_at: createdAt, + created_at: createdAt, + updated_at: createdAt, + is_paid: true, + }; +} + +test("paid workflow limits use safe strict-runtime defaults", () => { + assert.deepEqual(paidWorkflowLimits(envReader()), { + max_concurrent: 2, + daily_limit: 100, + timezone: "Asia/Shanghai", + stale_after_seconds: 1800, + }); +}); + +test("local guard rejects excess concurrency and releases the slot on completion", async () => { + const guard = new PaidWorkflowGuard({ + env: envReader({ + PAID_WORKFLOW_MAX_CONCURRENCY: "1", + PAID_WORKFLOW_DAILY_LIMIT: "10", + PAID_WORKFLOW_BUDGET_TIMEZONE: "UTC", + PAID_WORKFLOW_STALE_AFTER_SECONDS: "3600", + }), + }); + + const first = await guard.reserve(job("job-1")); + assert.equal(first.budget.running, 1); + await assert.rejects( + () => guard.reserve(job("job-2")), + (error) => error.status === 429 && error.code === "paid_workflow_concurrency_exceeded", + ); + + await guard.finish({ ...first.job, status: "succeeded", finished_at: new Date().toISOString() }); + const second = await guard.reserve(job("job-2")); + assert.equal(second.budget.running, 1); + assert.equal(second.budget.used_today, 2); +}); + +test("local guard counts every paid attempt against the daily limit", async () => { + const guard = new PaidWorkflowGuard({ + env: envReader({ + PAID_WORKFLOW_MAX_CONCURRENCY: "2", + PAID_WORKFLOW_DAILY_LIMIT: "1", + PAID_WORKFLOW_BUDGET_TIMEZONE: "UTC", + }), + }); + const first = await guard.reserve(job("job-1", "sales_company_search")); + await guard.finish({ ...first.job, status: "failed", finished_at: new Date().toISOString() }); + + await assert.rejects( + () => guard.reserve(job("job-2", "sales_qa")), + (error) => error.status === 429 && error.code === "paid_workflow_daily_limit_exceeded", + ); + const snapshot = await guard.snapshot(); + assert.equal(snapshot.used_today, 1); + assert.equal(snapshot.by_job_type.sales_company_search, 1); +}); + +test("runtime delegates reservation and completion to persistent repository RPCs", async () => { + const calls = []; + const repository = { + async reservePaidWorkflow(candidate, reservationId, limits) { + calls.push({ operation: "reserve", candidate, reservationId, limits }); + return { job: candidate, budget: { running: 1, used_today: 1 } }; + }, + async finishPaidWorkflow(candidate, reservationId) { + calls.push({ operation: "finish", candidate, reservationId }); + return candidate; + }, + async getPaidWorkflowUsage(timezone) { + calls.push({ operation: "snapshot", timezone }); + return { running: 0, used_today: 1, by_job_type: { sales_qa: 1 } }; + }, + }; + const guard = new PaidWorkflowGuard({ env: envReader(), repository, failClosed: true }); + const reservation = await guard.reserve(job("job-prod", "sales_qa")); + await guard.finish({ ...reservation.job, status: "succeeded", finished_at: new Date().toISOString() }); + const snapshot = await guard.snapshot(); + + assert.deepEqual(calls.map((call) => call.operation), ["reserve", "finish", "snapshot"]); + assert.match(reservation.job.reservation_id, /^usage_reservation_/); + assert.equal(snapshot.daily_limit, 100); + assert.equal(snapshot.by_job_type.sales_qa, 1); +}); + +test("runtime fails closed when the persistent reservation capability is missing", async () => { + const guard = new PaidWorkflowGuard({ env: envReader(), repository: {}, failClosed: true }); + await assert.rejects( + () => guard.reserve(job("job-prod")), + (error) => error.status === 503 && error.code === "usage_guard_unavailable", + ); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/providerCircuitBreaker.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/providerCircuitBreaker.test.mjs new file mode 100644 index 00000000..064b893c --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/providerCircuitBreaker.test.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ProviderCircuitBreaker } from "../src/limits/providerCircuitBreaker.js"; + +const retryableFailure = { + code: "timeout", + category: "timeout", + retryable: true, +}; + +test("provider circuit opens after repeated retryable failures and recovers after one probe", () => { + let now = 1_000; + const breaker = new ProviderCircuitBreaker({ + enabled: true, + failureThreshold: 2, + cooldownSeconds: 10, + now: () => now, + }); + + const first = breaker.beforeCall("model"); + breaker.recordFailure(first, retryableFailure); + assert.equal(breaker.snapshot()[0].open, false); + + const second = breaker.beforeCall("model"); + breaker.recordFailure(second, retryableFailure); + assert.equal(breaker.snapshot()[0].open, true); + assert.throws( + () => breaker.beforeCall("model"), + (error) => error.code === "provider_circuit_open" && error.retry_after_seconds === 10, + ); + + now += 10_000; + const probe = breaker.beforeCall("model"); + assert.equal(probe.halfOpen, true); + assert.throws(() => breaker.beforeCall("model"), /temporarily unavailable/); + breaker.recordSuccess(probe); + + assert.deepEqual(breaker.snapshot(), []); + assert.equal(breaker.beforeCall("model").halfOpen, false); +}); + +test("half-open retryable failure reopens the circuit", () => { + let now = 2_000; + const breaker = new ProviderCircuitBreaker({ + enabled: true, + failureThreshold: 1, + cooldownSeconds: 5, + now: () => now, + }); + + const initial = breaker.beforeCall("datapro"); + breaker.recordFailure(initial, retryableFailure); + now += 5_000; + const probe = breaker.beforeCall("datapro"); + breaker.recordFailure(probe, retryableFailure); + + const state = breaker.snapshot()[0]; + assert.equal(state.open, true); + assert.equal(state.retry_after_seconds, 5); +}); + +test("configuration and validation failures do not open the provider circuit", () => { + const breaker = new ProviderCircuitBreaker({ + enabled: true, + failureThreshold: 1, + cooldownSeconds: 5, + }); + + const token = breaker.beforeCall("web_search"); + breaker.recordFailure(token, { + code: "missing_config", + category: "configuration", + retryable: false, + }); + + assert.deepEqual(breaker.snapshot(), []); + assert.equal(breaker.beforeCall("web_search").halfOpen, false); +}); + +test("a non-retryable response resets the consecutive retryable failure count", () => { + const breaker = new ProviderCircuitBreaker({ + enabled: true, + failureThreshold: 2, + cooldownSeconds: 5, + }); + + const first = breaker.beforeCall("openviking"); + breaker.recordFailure(first, retryableFailure); + const validation = breaker.beforeCall("openviking"); + breaker.recordFailure(validation, { + code: "validation_error", + category: "validation", + retryable: false, + }); + const next = breaker.beforeCall("openviking"); + breaker.recordFailure(next, retryableFailure); + + const state = breaker.snapshot()[0]; + assert.equal(state.consecutive_failures, 1); + assert.equal(state.open, false); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/providerResult.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/providerResult.test.mjs new file mode 100644 index 00000000..4092e5e7 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/providerResult.test.mjs @@ -0,0 +1,252 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { WebSearchProvider } from "../src/providers/webSearchProvider.js"; +import { classifyProviderError, executeProviderCall, providerFailure } from "../src/providers/providerResult.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + number(name, fallback = 0) { + const value = Number(Object.hasOwn(values, name) ? values[name] : fallback); + return Number.isFinite(value) ? value : fallback; + }, + }; +} + +test("provider errors use stable categories and retryability", () => { + assert.deepEqual(classifyProviderError({ code: "timeout" }), { category: "timeout", retryable: true }); + assert.deepEqual(classifyProviderError({ code: "missing_config" }), { category: "configuration", retryable: false }); + assert.deepEqual(classifyProviderError({ code: "4003" }), { category: "validation", retryable: false }); + assert.deepEqual(classifyProviderError({ code: "invalid_query" }), { category: "validation", retryable: false }); + assert.deepEqual(classifyProviderError({ http_status: 401 }), { category: "authentication", retryable: false }); + assert.deepEqual( + classifyProviderError({ code: "10500", message: "Internal Error" }), + { category: "upstream", retryable: true }, + ); + const failure = providerFailure("model", { code: "network_error", message: "connection reset" }); + assert.equal(failure.provider, "model"); + assert.equal(failure.provider_mode, "real"); + assert.equal(failure.error.category, "network"); + assert.equal(failure.error.retryable, true); +}); + +test("retry helper retries only retryable failures", async () => { + let calls = 0; + const result = await executeProviderCall(async () => { + calls += 1; + if (calls === 1) return providerFailure("web_search", { code: "network_error", message: "temporary" }); + return { ok: true, provider: "web_search", provider_mode: "real" }; + }, { max_retries: 1, sleep: async () => {} }); + + assert.equal(calls, 2); + assert.equal(result.ok, true); + assert.equal(result.attempts, 2); +}); + +test("web search retries a transient network failure once", async () => { + let calls = 0; + const retryDelays = []; + const provider = new WebSearchProvider({ + env: envReader({ + WEB_SEARCH_API_KEY: "test-key", + WEB_SEARCH_MAX_RETRIES: "1", + WEB_SEARCH_TIMEOUT_MS: "1000", + }), + fetchImpl: async () => { + calls += 1; + if (calls === 1) throw new Error("temporary network failure"); + return { + ok: true, + status: 200, + async json() { + return { + ResponseMetadata: { RequestId: "request-1" }, + Result: { ResultCount: 0, WebResults: [] }, + }; + }, + }; + }, + sleep: async (milliseconds) => { + retryDelays.push(milliseconds); + }, + }); + + const result = await provider.search({ query: "测试查询", count: 1 }); + assert.equal(result.ok, true); + assert.equal(result.attempts, 2); + assert.equal(calls, 2); + assert.deepEqual(retryDelays, [2500]); +}); + +test("web search retries the official 10500 temporary-unavailable response once", async () => { + let calls = 0; + const retryDelays = []; + const provider = new WebSearchProvider({ + env: envReader({ + WEB_SEARCH_API_KEY: "test-key", + WEB_SEARCH_MAX_RETRIES: "1", + WEB_SEARCH_TIMEOUT_MS: "1000", + }), + fetchImpl: async () => { + calls += 1; + return { + ok: true, + status: 200, + async json() { + return { + ResponseMetadata: { + RequestId: `request-${calls}`, + Error: { + Code: "10500", + Message: "Ark AgentPlan service is temporarily unavailable. Please retry later.", + }, + }, + }; + }, + }; + }, + sleep: async (milliseconds) => { + retryDelays.push(milliseconds); + }, + }); + + const result = await provider.search({ query: "测试查询", count: 1 }); + assert.equal(result.ok, false); + assert.equal(result.error.code, "10500"); + assert.equal(result.error.retryable, true); + assert.equal(result.attempts, 2); + assert.equal(calls, 2); + assert.deepEqual(retryDelays, [2500]); +}); + +test("web search retries a 10500 Internal Error response once", async () => { + let calls = 0; + const provider = new WebSearchProvider({ + env: envReader({ + WEB_SEARCH_API_KEY: "test-key", + WEB_SEARCH_MAX_RETRIES: "1", + WEB_SEARCH_TIMEOUT_MS: "1000", + }), + fetchImpl: async () => { + calls += 1; + if (calls === 1) { + return { + ok: true, + status: 200, + async json() { + return { + ResponseMetadata: { + RequestId: "request-internal-error", + Error: { Code: "10500", Message: "Internal Error" }, + }, + }; + }, + }; + } + return { + ok: true, + status: 200, + async json() { + return { + ResponseMetadata: { RequestId: "request-recovered" }, + Result: { ResultCount: 0, WebResults: [] }, + }; + }, + }; + }, + sleep: async () => {}, + }); + + const result = await provider.search({ query: "测试查询", count: 1 }); + assert.equal(result.ok, true); + assert.equal(result.attempts, 2); + assert.equal(calls, 2); +}); + +test("web search sends official authority filter and query rewrite fields", async () => { + let requestBody = null; + let requestHeaders = null; + const provider = new WebSearchProvider({ + env: envReader({ + WEB_SEARCH_API_KEY: "test-key", + WEB_SEARCH_MAX_RETRIES: "0", + WEB_SEARCH_TIMEOUT_MS: "1000", + }), + fetchImpl: async (_url, options) => { + requestBody = JSON.parse(options.body); + requestHeaders = options.headers; + return { + ok: true, + status: 200, + async json() { + return { + ResponseMetadata: { RequestId: "request-2" }, + Result: { ResultCount: 0, WebResults: [] }, + }; + }, + }; + }, + }); + + const result = await provider.search({ + query: "权威来源测试", + count: 3, + auth_level: 1, + query_rewrite: true, + }); + + assert.equal(result.ok, true); + assert.deepEqual(requestBody.Filter, { AuthInfoLevel: 1 }); + assert.deepEqual(requestBody.QueryControl, { QueryRewrite: true }); + assert.equal(Object.hasOwn(requestBody, "AuthLevel"), false); + assert.equal(requestHeaders["X-Traffic-Tag"], "skill_web_search_common"); +}); + +test("web search cleans structured titles and discards epoch publish times", async () => { + const provider = new WebSearchProvider({ + env: envReader({ + WEB_SEARCH_API_KEY: "test-key", + WEB_SEARCH_MAX_RETRIES: "0", + WEB_SEARCH_TIMEOUT_MS: "1000", + }), + fetchImpl: async () => ({ + ok: true, + status: 200, + async json() { + return { + ResponseMetadata: { RequestId: "request-clean" }, + Result: { + ResultCount: 3, + WebResults: [ + { + Title: "--- title: 比亚迪与合作伙伴发布新项目 source: 示例网 datetime: 2026-07-20", + Url: "https://news.example.org/byd", + Summary: " 比亚迪发布合作动态。\n", + PublishTime: 0, + }, + { + Title: "正常标题", + Url: "https://news.example.org/current", + PublishTime: 1784505600, + }, + { + Title: "没有日期的旧结果", + Url: "https://news.example.org/epoch", + PublishTime: 0, + }, + ], + }, + }; + }, + }), + }); + + const result = await provider.search({ query: "比亚迪 最新合作", count: 2 }); + assert.equal(result.results[0].title, "比亚迪与合作伙伴发布新项目"); + assert.equal(result.results[0].summary, "比亚迪发布合作动态。"); + assert.equal(result.results[0].publish_time, "2026-07-20T00:00:00.000Z"); + assert.equal(result.results[1].publish_time, "2026-07-20T00:00:00.000Z"); + assert.equal(result.results[2].publish_time, null); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/providerRunStore.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/providerRunStore.test.mjs new file mode 100644 index 00000000..bfa3bd90 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/providerRunStore.test.mjs @@ -0,0 +1,180 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ProviderCircuitBreaker } from "../src/limits/providerCircuitBreaker.js"; +import { ProviderRunStore } from "../src/observability/providerRunStore.js"; +import { SalesService } from "../src/services/salesService.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +test("provider run records redact secrets and retain safe usage metadata", async () => { + const store = new ProviderRunStore(); + const run = await store.startRun({ operation: "test" }); + + await store.executeStep(run.id, { + provider: "model", + operation: "probe", + input_summary: "Authorization: Bearer fake", + output_summary: "Probe completed.", + }, async () => ({ + ok: true, + request_id: "request-1", + raw_ref: "model:request-1", + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + })); + await store.completeRun(run.id, { result_ref: "result:1" }); + + const saved = await store.get(run.id); + assert.equal(saved.status, "succeeded"); + assert.match(saved.steps[0].input_summary, /\[REDACTED\]/); + assert.equal(saved.steps[0].usage.total_tokens, 15); + assert.equal(saved.steps[0].raw_ref, "model:request-1"); + assert.equal(saved.app_mode, "production"); +}); + +test("sales service provider run APIs expose diagnostics without internal references", async () => { + const store = new ProviderRunStore(); + const service = new SalesService({ + env: envReader(), + runtimePolicy: { fail_closed: false }, + providerRunStore: store, + }); + const run = await store.startRun({ + operation: "public_provider_run", + app_mode: "production", + entity_type: "target_enterprise", + entity_id: "company-1", + }); + await store.executeStep(run.id, { + provider: "model", + operation: "generate", + input_summary: "Generate a report.", + }, async () => ({ + ok: true, + request_id: "request-private", + raw_ref: "model:request-private", + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + })); + await store.completeRun(run.id, { result_ref: "dossier:private" }); + + const detail = await service.getProviderRun(run.id); + const listed = await service.listProviderRuns({ operation: "public_provider_run" }); + for (const publicRun of [detail, listed[0]]) { + assert.equal(publicRun.id, run.id); + assert.equal(publicRun.steps[0].provider, "model"); + assert.equal(publicRun.steps[0].usage.total_tokens, 15); + assert.equal(Object.hasOwn(publicRun, "result_ref"), false); + assert.equal(Object.hasOwn(publicRun, "app_mode"), false); + assert.equal(Object.hasOwn(publicRun.steps[0], "request_id"), false); + assert.equal(Object.hasOwn(publicRun.steps[0], "raw_ref"), false); + } +}); + +test("provider run failure retains bounded redacted validation diagnostics", async () => { + const store = new ProviderRunStore(); + const run = await store.startRun({ operation: "validation_failure", app_mode: "production" }); + + await store.failRun(run.id, { + code: "model_unavailable", + message: "Dossier validation failed.", + category: "workflow", + details: { + validation_errors: [ + "经营与业务动态必须优先引用语义匹配的专业数据库", + "Bearer private-token", + ], + }, + }); + + const saved = await store.get(run.id); + assert.deepEqual(saved.error.validation_errors, [ + "经营与业务动态必须优先引用语义匹配的专业数据库", + "Bearer [REDACTED]", + ]); +}); + +test("provider runs can be reloaded from a persistent repository", async () => { + const saved = new Map(); + const repository = { + persistProviderRun(run) { + saved.set(run.id, structuredClone(run)); + return run; + }, + getProviderRun(runId) { + return saved.has(runId) ? structuredClone(saved.get(runId)) : null; + }, + listProviderRuns() { + return [...saved.values()].map((run) => structuredClone(run)); + }, + }; + const firstStore = new ProviderRunStore({ repository, failOnPersistenceError: true }); + const run = await firstStore.startRun({ operation: "persistent_test", entity_id: "company-1" }); + const step = await firstStore.startStep(run.id, { provider: "supabase", operation: "persist" }); + await firstStore.finishStep(run.id, step.id, { ok: true, usage: { total_tokens: 0 } }); + await firstStore.completeRun(run.id, { result_ref: "result:company-1" }); + + const secondStore = new ProviderRunStore({ repository, failOnPersistenceError: true }); + assert.equal((await secondStore.get(run.id)).status, "succeeded"); + assert.equal((await secondStore.get(run.id)).steps.length, 1); + assert.equal((await secondStore.list({ operation: "persistent_test" }))[0].id, run.id); +}); + +test("provider run start fails closed when required persistence is unavailable", async () => { + const store = new ProviderRunStore({ + repository: { + persistProviderRun() { + throw new Error("database unavailable"); + }, + }, + failOnPersistenceError: true, + }); + + await assert.rejects(() => store.startRun({ operation: "must_persist" }), /database unavailable/); +}); + +test("provider run store blocks an open circuit before another upstream call", async () => { + let calls = 0; + const store = new ProviderRunStore({ + circuitBreaker: new ProviderCircuitBreaker({ + enabled: true, + failureThreshold: 1, + cooldownSeconds: 30, + }), + }); + const run = await store.startRun({ operation: "circuit_test" }); + const operation = async () => { + calls += 1; + return { + ok: false, + error: { code: "timeout", category: "timeout", retryable: true }, + }; + }; + + await store.executeStep(run.id, { provider: "model", operation: "generate" }, operation); + await assert.rejects( + () => store.executeStep(run.id, { provider: "model", operation: "generate" }, operation), + (error) => error.code === "provider_circuit_open", + ); + + const saved = await store.get(run.id); + assert.equal(calls, 1); + assert.equal(saved.steps.length, 2); + assert.equal(saved.steps[1].error.code, "provider_circuit_open"); +}); + +test("cancelling a provider run also closes its running step", async () => { + const store = new ProviderRunStore(); + const run = await store.startRun({ operation: "cancel_test" }); + await store.startStep(run.id, { provider: "web_search", operation: "search" }); + + const cancelled = await store.cancelRun(run.id, { summary: "User cancelled the task." }); + assert.equal(cancelled.status, "cancelled"); + assert.ok(cancelled.finished_at); + assert.equal(cancelled.steps[0].status, "cancelled"); + assert.equal(cancelled.steps[0].output_summary, "User cancelled the task."); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/publicDocumentation.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/publicDocumentation.test.mjs new file mode 100644 index 00000000..a4aeb87d --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/publicDocumentation.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(testDir, "../.."); +const docsRoot = path.join(projectRoot, "docs"); +const hasPublicDocs = fs.existsSync(path.join(docsRoot, "api", "api-contract.md")); + +function read(relativePath) { + return fs.readFileSync(path.join(projectRoot, relativePath), "utf8"); +} + +test("public API contract documents the current sales workbench only", { + skip: !hasPublicDocs, +}, () => { + const contract = read("docs/api/api-contract.md"); + assert.match(contract, /\/api\/sales-goals/); + assert.match(contract, /\/api\/target-enterprises/); + assert.doesNotMatch(contract, /\/api\/change-cards/); + assert.doesNotMatch(contract, /competitive-change-card/i); +}); + +test("public documentation points to versioned migrations and current authentication", { + skip: !hasPublicDocs, +}, () => { + const index = read("docs/README.md"); + const schema = read("docs/database/supabase-schema.md"); + const security = read("SECURITY.md"); + + assert.doesNotMatch(index, /supabase-schema\.sql/); + assert.match(schema, /supabase\/migrations\//); + assert.doesNotMatch(schema, /docs\/open-source\//); + assert.match(security, /Supabase Auth/); + assert.doesNotMatch(security, /does not yet include HTTP user authentication/i); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/releaseSecretScan.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/releaseSecretScan.test.mjs new file mode 100644 index 00000000..d6cdcb22 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/releaseSecretScan.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { scanReleaseTree, scanTextForSecrets } from "../scripts/check-release-secrets.mjs"; + +test("release secret scan accepts empty examples and does not expose matched values", () => { + assert.deepEqual(scanTextForSecrets("AGENT_PLAN_API_KEY=\nSUPABASE_SERVICE_ROLE_KEY=<your-key>\n", ".env.example"), []); + + const synthetic = ["ark", "aaaaaaaa", "bbbb", "cccc", "dddd", "eeeeeeeeeeee", "ffff"].join("-"); + const findings = scanTextForSecrets(`AGENT_PLAN_API_KEY=${synthetic}\n`, "unsafe.env"); + assert.ok(findings.some((finding) => finding.rule === "agent_plan_api_key")); + assert.ok(findings.some((finding) => finding.rule === "configured_agent_plan_api_key")); + assert.equal(JSON.stringify(findings).includes(synthetic), false); +}); + +test("release tree scan catches private config files and skips ignored dependency folders", async (context) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "sales-release-secret-scan-")); + context.after(() => fs.rm(root, { recursive: true, force: true })); + + await fs.mkdir(path.join(root, "node_modules")); + await fs.writeFile(path.join(root, ".env.example"), "AGENT_PLAN_API_KEY=<your-key>\n"); + await fs.writeFile(path.join(root, ".env"), "AGENT_PLAN_API_KEY=synthetic-secret-value\n"); + await fs.writeFile(path.join(root, "node_modules", ".env"), "ignored=true\n"); + + const findings = await scanReleaseTree(root); + assert.deepEqual(findings, [{ rule: "forbidden_secret_file", path: ".env" }]); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/runtimePolicy.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/runtimePolicy.test.mjs new file mode 100644 index 00000000..b89b42d9 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/runtimePolicy.test.mjs @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createRuntimePolicy } from "../src/config/runtimePolicy.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + hasAny(names) { + return names.some((name) => Boolean(this.value(name))); + }, + hasAll(names) { + return names.every((name) => Boolean(this.value(name))); + }, + }; +} + +const readyConfiguration = Object.freeze({ + REPOSITORY_MODE: "supabase", + SUPABASE_READ_ONLY: "false", + SUPABASE_API_URL: "https://supabase.example.test", + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", + APP_WORKSPACE_ID: "54768bef-53aa-47d0-a9e3-bbca4593cf58", + HTTP_AUTH_ENABLED: "true", + AGENT_PLAN_API_KEY: "test-key", + DATAPRO_RUN_ENABLED: "true", + WEB_SEARCH_RUN_ENABLED: "true", + MODEL_RUN_ENABLED: "true", + OPENVIKING_BASE_URL: "https://openviking.example.test", + OPENVIKING_API_KEY: "test-openviking-key", + OPENVIKING_RUN_ENABLED: "true", +}); + +test("missing real storage and providers block readiness", () => { + const policy = createRuntimePolicy({ + env: envReader({ + REPOSITORY_MODE: "memory", + }), + }); + + assert.equal(policy.ready, false); + assert.equal(policy.fail_closed, true); + assert.match(policy.blockers.join(" | "), /REPOSITORY_MODE must be supabase/); + assert.match(policy.blockers.join(" | "), /DataPro/); + assert.match(policy.blockers.join(" | "), /web search/); + assert.match(policy.blockers.join(" | "), /model provider/); +}); + +test("fully configured runtime is structurally ready", () => { + const policy = createRuntimePolicy({ env: envReader(readyConfiguration) }); + assert.equal(policy.ready, true); + assert.deepEqual(policy.blockers, []); +}); + +test("authentication and paid-workflow protections are mandatory", () => { + const policy = createRuntimePolicy({ + env: envReader({ + ...readyConfiguration, + HTTP_AUTH_ENABLED: "false", + PAID_WORKFLOW_MAX_CONCURRENCY: "0", + PAID_WORKFLOW_DAILY_LIMIT: "0", + PAID_WORKFLOW_STALE_AFTER_SECONDS: "0", + PAID_WORKFLOW_BUDGET_TIMEZONE: "Mars/Olympus", + }), + }); + const blockers = policy.blockers.join(" | "); + assert.match(blockers, /HTTP_AUTH_ENABLED must be true/); + assert.match(blockers, /PAID_WORKFLOW_MAX_CONCURRENCY/); + assert.match(blockers, /PAID_WORKFLOW_DAILY_LIMIT/); + assert.match(blockers, /PAID_WORKFLOW_STALE_AFTER_SECONDS/); + assert.match(blockers, /PAID_WORKFLOW_BUDGET_TIMEZONE/); +}); + +test("the persistent worker queue and circuit breaker are mandatory", () => { + const policy = createRuntimePolicy({ + env: envReader({ + ...readyConfiguration, + ASYNC_JOBS_ENABLED: "false", + JOB_WORKER_LEASE_SECONDS: "30", + PROVIDER_CIRCUIT_BREAKER_ENABLED: "false", + PROVIDER_CIRCUIT_BREAKER_FAILURE_THRESHOLD: "0", + PROVIDER_CIRCUIT_BREAKER_COOLDOWN_SECONDS: "0", + }), + }); + const blockers = policy.blockers.join(" | "); + assert.match(blockers, /ASYNC_JOBS_ENABLED must be true/); + assert.match(blockers, /JOB_WORKER_LEASE_SECONDS must be at least 60/); + assert.match(blockers, /PROVIDER_CIRCUIT_BREAKER_ENABLED must be true/); + assert.match(blockers, /PROVIDER_CIRCUIT_BREAKER_FAILURE_THRESHOLD/); + assert.match(blockers, /PROVIDER_CIRCUIT_BREAKER_COOLDOWN_SECONDS/); +}); + +test("proxied deployments require secure cookies and explicit HTTPS origins", () => { + const unsafe = createRuntimePolicy({ + env: envReader({ + ...readyConfiguration, + TRUST_PROXY: "true", + AUTH_COOKIE_SECURE: "false", + ALLOWED_ORIGINS: "http://sales.example.test", + }), + }); + const blockers = unsafe.blockers.join(" | "); + assert.match(blockers, /AUTH_COOKIE_SECURE=true/); + assert.match(blockers, /HTTPS ALLOWED_ORIGINS/); + + const safe = createRuntimePolicy({ + env: envReader({ + ...readyConfiguration, + TRUST_PROXY: "true", + AUTH_COOKIE_SECURE: "true", + ALLOWED_ORIGINS: "https://sales.example.test", + }), + }); + assert.equal(safe.ready, true); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/salesCompanySearch.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/salesCompanySearch.test.mjs new file mode 100644 index 00000000..514f2c04 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/salesCompanySearch.test.mjs @@ -0,0 +1,237 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SalesService } from "../src/services/salesService.js"; + +const permissiveTestPolicy = Object.freeze({ + fail_closed: false, +}); + +const strictRuntimePolicy = Object.freeze({ + fail_closed: true, +}); + +function emptyState() { + return { + goals: [], + companies: {}, + dossiers: {}, + materials: {}, + qa_messages: {}, + sync_sources: {}, + sync_checkpoints: {}, + jobs: {}, + }; +} + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +function createRepository() { + const persistedCompanies = []; + const jobs = new Map(); + return { + persistedCompanies, + async getSalesState() { + return emptyState(); + }, + async persistSalesGoal() {}, + async persistSalesSearchResults() {}, + async persistSalesCompany(company) { + persistedCompanies.push(structuredClone(company)); + }, + async persistJob(job) { + jobs.set(job.id, structuredClone(job)); + }, + async reservePaidWorkflow(job, reservationId) { + const reserved = { ...structuredClone(job), reservation_id: reservationId, is_paid: true }; + jobs.set(reserved.id, reserved); + return { job: reserved, budget: { running: 1, used_today: jobs.size } }; + }, + async finishPaidWorkflow(job) { + jobs.set(job.id, structuredClone(job)); + return structuredClone(job); + }, + async getJob(jobId) { + return jobs.has(jobId) ? structuredClone(jobs.get(jobId)) : null; + }, + async persistProviderRun() {}, + }; +} + +function webProvider() { + return { + isRunEnabled: () => true, + async search() { + return { + ok: true, + results: [{ + title: "测试企业官网动态", + summary: "测试企业发布了最新业务公告。", + url: "https://company.test/news", + }], + }; + }, + }; +} + +test("company search uses structured DataPro identities and deduplicates repeated searches", async () => { + const repository = createRepository(); + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: emptyState(), + repository, + dataProProvider: { + isRunEnabled: () => true, + async callTool() { + return { + ok: true, + raw_ref: "datapro:trace-company-search", + parsed: { + code: 0, + data: { + items: [{ + 公司名称: "北京测试科技有限公司", + 统一社会信用代码: "91110000TEST000001", + 法定代表人: "张三", + 注册资本: "1000万元人民币", + 企业状态: "存续", + 所属行业: "企业软件", + 注册地址: "北京市海淀区测试路1号", + 成立日期: "2020-01-02", + 经营范围: "软件开发与技术服务。", + }], + }, + }, + summary: "公司名称:北京测试科技有限公司;统一社会信用代码:91110000TEST000001", + }; + }, + }, + webSearchProvider: webProvider(), + }); + await service.assertRuntimeReady(); + const goal = await service.createGoal({ name: "测试销售目标" }); + + const first = await service.searchCompanies(goal.id, { query: "测试科技" }); + const second = await service.searchCompanies(goal.id, { query: "测试科技" }); + + assert.equal(first.length, 1); + assert.equal(first[0].name, "北京测试科技有限公司"); + assert.equal(first[0].identity_status, "verified"); + assert.equal(first[0].unified_social_credit_code, "91110000TEST000001"); + assert.equal(first[0].legal_representative, "张三"); + assert.equal(first[0].registered_capital, "1000万元人民币"); + assert.equal(first[0].location, "北京市"); + assert.match(first[0].reason, /专业数据集已核验/); + assert.equal(second[0].id, first[0].id); + assert.equal(Object.keys(service.data.companies).length, 1); + assert.ok(first[0].id.startsWith("company_dp_")); + assert.equal(repository.persistedCompanies.at(-1).professional_source_ref, "datapro:trace-company-search"); + assert.ok(repository.persistedCompanies.at(-1).aliases.includes("测试科技")); + const runs = await service.listProviderRuns({ operation: "sales_company_search" }); + assert.equal(runs.length, 2); + assert.deepEqual(runs[0].steps.map((step) => step.provider), ["datapro", "web_search"]); + assert.ok(runs.every((run) => run.status === "succeeded")); +}); + +test("company search can parse a DataPro text summary when structured items are absent", async () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: permissiveTestPolicy, + seed: emptyState(), + dataProProvider: { + isRunEnabled: () => true, + async callTool() { + return { + ok: true, + summary: "公司名称:上海示例信息技术有限公司;统一社会信用代码:91310000TEST000002;法人姓名:李四;企业状态:存续;注册地址:上海市浦东新区示例路2号", + }; + }, + }, + webSearchProvider: webProvider(), + }); + const goal = await service.createGoal({ name: "文本结果测试" }); + const results = await service.searchCompanies(goal.id, { query: "示例信息" }); + + assert.equal(results.length, 1); + assert.equal(results[0].name, "上海示例信息技术有限公司"); + assert.equal(results[0].unified_social_credit_code, "91310000TEST000002"); + assert.equal(results[0].legal_representative, "李四"); + assert.equal(results[0].location, "上海市"); +}); + +test("runtime search rejects a successful DataPro response without an identifiable company", async () => { + const repository = createRepository(); + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + seed: emptyState(), + repository, + dataProProvider: { + isRunEnabled: () => true, + async callTool() { + return { ok: true, summary: "DataPro 返回成功,但没有企业主体字段。", parsed: { code: 0, items: [] } }; + }, + }, + webSearchProvider: webProvider(), + }); + await service.assertRuntimeReady(); + const goal = await service.createGoal({ name: "生产校验" }); + + await assert.rejects( + () => service.searchCompanies(goal.id, { query: "无法识别的公司" }), + (error) => error.status === 503 + && error.code === "datapro_unavailable" + && error.details.reason === "company_identity_unavailable", + ); + assert.equal(Object.keys(service.data.companies).length, 0); +}); + +test("runtime search keeps a verified DataPro candidate when optional web search is unavailable", async () => { + const repository = createRepository(); + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + seed: emptyState(), + repository, + dataProProvider: { + isRunEnabled: () => true, + async callTool() { + return { + ok: true, + raw_ref: "datapro:verified-without-web", + parsed: { + items: [{ + 企业名称: "广州可靠数据有限公司", + 统一社会信用代码: "91440100TEST000003", + 经营状态: "存续", + 注册地址: "广东省广州市天河区可靠路3号", + }], + }, + }; + }, + }, + webSearchProvider: { + isRunEnabled: () => true, + async search() { + return { ok: false, error: { code: "10500", message: "upstream unavailable" } }; + }, + }, + }); + await service.assertRuntimeReady(); + const goal = await service.createGoal({ name: "降级搜索" }); + const results = await service.searchCompanies(goal.id, { query: "可靠数据" }); + + assert.equal(results.length, 1); + assert.equal(results[0].identity_status, "verified"); + assert.match(results[0].reason, /联网公开信息暂不可用/); + assert.ok(results[0].warnings.some((warning) => warning.includes("10500"))); + const run = (await service.listProviderRuns({ operation: "sales_company_search" }))[0]; + assert.equal(run.status, "succeeded_with_issues"); + assert.equal(run.steps.find((step) => step.provider === "web_search").status, "failed"); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/salesDossierEvidenceCompilerIntegration.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/salesDossierEvidenceCompilerIntegration.test.mjs new file mode 100644 index 00000000..7cafc5e5 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/salesDossierEvidenceCompilerIntegration.test.mjs @@ -0,0 +1,249 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildDossierEvidencePack, +} from "../src/evidence/salesEvidence.js"; +import { + SalesService, +} from "../src/services/salesService.js"; + +const COMPANY = { + id: "company_fictional_cloud", + name: "云穹矩阵科技有限公司", + initial: "云", + industry: "企业软件", + location: "北京", + tags: [], + progress: { + label: "新商机", + summary: "待生成档案", + evidence: "尚未生成", + updated_at: null, + }, + dossier_ids: [], + material_ids: [], + qa_session_id: "sales-company_fictional_cloud", +}; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +function seed() { + return { + goals: [], + companies: { + [COMPANY.id]: structuredClone(COMPANY), + }, + dossiers: {}, + materials: {}, + qa_messages: { [COMPANY.id]: [] }, + sync_sources: {}, + sync_checkpoints: {}, + jobs: {}, + }; +} + +function fullEvidencePack() { + return buildDossierEvidencePack({ + company: { + ...COMPANY, + unified_social_credit_code: "91110000MA0CLOUD01", + }, + generatedAt: "2026-07-31T10:00:00.000Z", + collected: { + professional: [ + { + label: "企业工商数据库", + query: "云穹矩阵科技有限公司 企业工商信息", + summary: [ + "公司名称:云穹矩阵科技有限公司;", + "统一社会信用代码:91110000MA0CLOUD01;", + "经营范围:企业软件与知识库产品。", + ].join(""), + source_group: "business", + }, + { + label: "企业风险数据库", + query: "云穹矩阵科技有限公司 风险信息", + summary: "云穹矩阵科技有限公司披露项目交付周期延长,需要核验实施排期。", + source_group: "risk", + }, + { + label: "企业经营数据库", + query: "云穹矩阵科技有限公司 经营动态", + summary: "云穹矩阵科技有限公司持续升级知识库产品与企业协作检索能力。", + source_group: "market", + }, + ], + public_sources: [{ + label: "云穹矩阵产品升级公告", + summary: "2026年7月30日,云穹矩阵科技有限公司披露知识库产品升级进展。", + url: "https://official.example.com/cloud-product-update", + site_name: "虚构企业官网", + published_at: "2026-07-30T08:00:00.000Z", + official: true, + }], + }, + }); +} + +function sectionResponse(request) { + const evidenceBySection = request.payload.evidence_by_section; + const evidenceId = (key, predicate = () => true) => { + const atom = evidenceBySection[key].allowed_evidence.find(predicate) + || evidenceBySection[key].allowed_evidence[0]; + assert.ok(atom, `${key} must have allowed evidence`); + return atom.id; + }; + return { + sections: { + company_overview: { + text: "云穹矩阵科技有限公司经营企业软件与知识库产品。", + evidence_ids: [evidenceId("company_overview", (atom) => atom.quote.includes("经营范围"))], + }, + business_dynamics: { + text: "云穹矩阵科技有限公司持续升级知识库产品与企业协作检索能力。", + evidence_ids: [evidenceId("business_dynamics", (atom) => atom.quote.includes("持续升级"))], + }, + recent_public_updates: { + text: "2026年7月30日,云穹矩阵科技有限公司披露知识库产品升级进展。", + evidence_ids: [evidenceId("recent_public_updates")], + }, + risk_attention: { + text: "云穹矩阵科技有限公司披露项目交付周期延长,需要核验实施排期。", + evidence_ids: [evidenceId("risk_attention")], + }, + sales_opportunity: { + text: "知识库产品升级形成销售沟通窗口,但不代表企业已有采购意向。", + evidence_ids: [evidenceId("sales_opportunity", (atom) => atom.quote.includes("持续升级"))], + }, + recommended_actions: { + text: "销售人员应联系产品负责人核验知识库产品升级范围和实施排期。", + evidence_ids: [evidenceId("recommended_actions", (atom) => atom.quote.includes("持续升级"))], + }, + }, + }; +} + +test("SalesService compiles evidence before the Agent and persists server-derived citations", async () => { + const modelCalls = []; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: { fail_closed: true }, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callRequiredFunction(request) { + modelCalls.push(structuredClone(request)); + return { + ok: true, + parsed: sectionResponse(request), + raw_ref: "model:atom-contract", + }; + }, + }, + }); + + const dossier = await service.generateDossierWithModel( + service.data.companies[COMPANY.id], + fullEvidencePack(), + [], + ); + + assert.equal(modelCalls.length, 1); + assert.ok(modelCalls[0].payload.evidence_by_section); + assert.equal(modelCalls[0].payload.citations, undefined); + assert.equal(modelCalls[0].payload.allowed_citation_ids, undefined); + assert.doesNotMatch(JSON.stringify(modelCalls[0].parameters), /quote|citation_id|url/iu); + assert.equal(dossier.body.length, 6); + assert.ok(dossier.body.every((section) => ( + section.segments.length === 1 + && section.segments[0].citation_ids.length >= 1 + && section.citation_ids.length >= 1 + ))); + assert.ok(dossier.citations.length >= 4); + assert.equal(dossier.body[2].text.startsWith("近期公开动态:"), true); +}); + +test("SalesService completes six grounded sections when only legal-entity evidence is available", async () => { + let modelCalls = 0; + const sparsePack = buildDossierEvidencePack({ + company: { + ...COMPANY, + unified_social_credit_code: "91110000MA0CLOUD01", + }, + generatedAt: "2026-07-31T10:00:00.000Z", + collected: { + professional: [{ + label: "企业工商数据库", + query: "云穹矩阵科技有限公司 企业工商信息", + summary: "公司名称:云穹矩阵科技有限公司;统一社会信用代码:91110000MA0CLOUD01;经营范围:企业软件。", + }], + }, + }); + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: { fail_closed: true }, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callRequiredFunction(request) { + modelCalls += 1; + const evidenceId = (key) => ( + request.payload.evidence_by_section[key].allowed_evidence[0].id + ); + return { + ok: true, + parsed: { + sections: { + company_overview: { + text: "云穹矩阵科技有限公司的经营范围包括企业软件。", + evidence_ids: [evidenceId("company_overview")], + }, + business_dynamics: { + text: "该企业从事企业软件相关经营活动。", + evidence_ids: [evidenceId("business_dynamics")], + }, + recent_public_updates: { + text: "该企业当前公开登记的经营范围包含企业软件。", + evidence_ids: [evidenceId("recent_public_updates")], + }, + risk_attention: { + text: "商务推进需要结合企业软件业务核验项目责任边界。", + evidence_ids: [evidenceId("risk_attention")], + }, + sales_opportunity: { + text: "企业软件业务可形成方案沟通场景,但不代表企业已有采购意向。", + evidence_ids: [evidenceId("sales_opportunity")], + }, + recommended_actions: { + text: "销售人员应围绕企业软件业务联系相关负责人,确认实际应用场景和决策流程。", + evidence_ids: [evidenceId("recommended_actions")], + }, + }, + }, + raw_ref: "model:sparse-grounded", + }; + }, + }, + }); + + const dossier = await service.generateDossierWithModel( + service.data.companies[COMPANY.id], + sparsePack, + [], + ); + + assert.equal(modelCalls, 1); + assert.equal(dossier.body.length, 6); + assert.ok(dossier.body.every((section) => ( + section.segments.length === 1 + && section.segments[0].citation_ids.length === 1 + ))); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/salesEvidence.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/salesEvidence.test.mjs new file mode 100644 index 00000000..bab0cf8d --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/salesEvidence.test.mjs @@ -0,0 +1,607 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + assessQaAnswerability, + buildDossierEvidencePack, + buildQaEnumerationRequirements, + buildQaEvidence, + evidencePackCitations, + makeDossierFingerprint, + validateDossierModelAnswer, + validateProductionEvidencePack, + validateQaModelAnswer, +} from "../src/evidence/salesEvidence.js"; + +const company = { id: "company_xinlan", name: "星蓝新能源科技有限公司" }; + +test("evidence packs keep stable ids and reject unrelated public results", () => { + const pack = buildDossierEvidencePack({ + company, + generatedAt: "2026-07-21T10:00:00.000Z", + collected: { + professional: [{ + label: "企业工商数据库", + query: "星蓝新能源科技有限公司 企业工商信息", + summary: "公司名称:星蓝新能源科技有限公司;经营范围:新能源汽车相关业务。", + }], + public_sources: [ + { + label: "星蓝发布最新公告", + summary: "星蓝新能源科技有限公司发布最新公告。", + url: "https://example.org/xinlan?a=1&utm_source=test", + site_name: "星蓝官网", + published_at: "2026-07-20T08:00:00Z", + }, + { + label: "无关企业新闻", + summary: "另一家公司发布公告。", + url: "https://example.org/unrelated", + }, + ], + }, + }); + + assert.equal(pack.items.length, 2); + assert.equal(pack.rejected.length, 1); + assert.equal(pack.rejected[0].reason, "entity_not_verified"); + assert.equal(pack.data_as_of, "2026-07-20T08:00:00.000Z"); + assert.match(pack.items[1].url, /^https:\/\/example\.org\/xinlan\?a=1$/); + assert.equal(pack.items[0].source_quality_label, "专业权威来源"); + assert.equal(pack.items[1].freshness_label, "近期资料"); + assert.equal(pack.items[1].site_name, "星蓝官网"); + assert.equal(evidencePackCitations(pack)[1].site_name, "星蓝官网"); + assert.equal(pack.policy.current_public_count, 1); + assert.equal(validateProductionEvidencePack(pack).ok, true); +}); + +test("evidence packs retain brand-alias public news without treating it as the legal entity", () => { + const pack = buildDossierEvidencePack({ + company: { + id: "company_byd_industry", + name: "比亚迪汽车工业有限公司", + aliases: ["比亚迪"], + }, + generatedAt: "2026-07-24T10:00:00.000Z", + collected: { + professional: [{ + label: "企业工商数据库", + query: "比亚迪汽车工业有限公司 企业工商信息", + summary: "公司名称:比亚迪汽车工业有限公司;经营范围:汽车制造。", + }], + public_sources: [ + { + label: "比亚迪发布供应链合作动态", + summary: "比亚迪与合作伙伴发布供应链合作计划。", + url: "https://news.example.org/byd-cooperation", + published_at: "2026-07-20T08:00:00Z", + query: "比亚迪 2026 最新项目 合作", + }, + { + label: "其他汽车品牌新闻", + summary: "其他汽车品牌发布新车型。", + url: "https://news.example.org/other", + published_at: "2026-07-20T08:00:00Z", + }, + ], + }, + }); + + const aliasEvidence = pack.items.find((item) => item.label.includes("供应链合作")); + assert.equal(aliasEvidence.entity_match, "alias_scoped"); + assert.ok(pack.rejected.some((item) => item.label === "其他汽车品牌新闻")); +}); + +test("evidence packs derive a scoped brand alias from China investment-company names", () => { + const pack = buildDossierEvidencePack({ + company: { + id: "company_bosch_china", + name: "博世(中国)投资有限公司", + }, + generatedAt: "2026-07-29T10:00:00.000Z", + collected: { + professional: [{ + label: "企业工商数据库", + query: "博世(中国)投资有限公司 企业工商信息", + summary: "公司名称:博世(中国)投资有限公司;经营范围:机械制造、电子和信息产业投资。", + }], + public_sources: [{ + label: "博世发布在华合作项目动态", + summary: "博世与合作伙伴发布在华技术合作项目计划。", + url: "https://news.example.org/bosch-cooperation", + published_at: "2026-07-28T08:00:00Z", + query: "博世 2026 合作 项目", + }], + }, + }); + + const aliasEvidence = pack.items.find((item) => item.source_kind === "public"); + assert.equal(aliasEvidence.entity_match, "alias_scoped"); +}); + +test("evidence packs reject verification-gate pages instead of treating them as report sources", () => { + const pack = buildDossierEvidencePack({ + company, + generatedAt: "2026-07-29T10:00:00.000Z", + collected: { + professional: [{ + label: "企业工商数据库", + query: "星蓝新能源科技有限公司 企业工商信息", + summary: "公司名称:星蓝新能源科技有限公司;经营范围:新能源汽车相关业务。", + }], + public_sources: [{ + label: "星蓝新能源科技有限公司法律风险", + summary: "For better experience, please complete the verification process. TIME: 2026-07-29 09:00:00", + url: "https://example.org/verification-gate", + published_at: "2026-07-28T08:00:00Z", + }], + }, + }); + + assert.equal(pack.items.length, 1); + assert.equal(pack.rejected.length, 1); + assert.equal(pack.rejected[0].reason, "content_not_substantive"); + assert.equal(pack.policy.traceable_public_count, 0); +}); + +test("evidence hash ignores collection time but changes with source content", () => { + const input = { + company, + collected: { + professional: [{ + label: "企业工商数据库", + query: "星蓝新能源科技有限公司 企业工商信息", + summary: "公司名称:星蓝新能源科技有限公司。", + }], + }, + }; + const first = buildDossierEvidencePack({ ...input, generatedAt: "2026-07-21T10:00:00Z" }); + const second = buildDossierEvidencePack({ ...input, generatedAt: "2026-07-21T11:00:00Z" }); + const changed = buildDossierEvidencePack({ + ...input, + generatedAt: "2026-07-21T11:00:00Z", + collected: { + professional: [{ + ...input.collected.professional[0], + summary: "公司名称:星蓝新能源科技有限公司;经营范围已更新。", + }], + }, + }); + + assert.equal(first.evidence_hash, second.evidence_hash); + assert.notEqual(first.evidence_hash, changed.evidence_hash); + assert.equal(evidencePackCitations(first)[0].entity_match, "verified"); +}); + +test("dossier fingerprints are deterministic and include citation-backed content", () => { + const dossier = { + title: "星蓝最近档案", + summary: "近期信息已更新。", + body: [{ text: "近期动态:已发布公告。", citation_ids: ["evidence_1"] }], + citations: [{ id: "evidence_1", summary: "公告摘要" }], + }; + assert.equal(makeDossierFingerprint(dossier), makeDossierFingerprint(structuredClone(dossier))); + assert.notEqual( + makeDossierFingerprint(dossier), + makeDossierFingerprint({ ...dossier, summary: "近期信息发生变化。" }), + ); +}); + +test("QA validation derives citations from allowed evidence and rejects fabricated ids", () => { + const evidence = buildQaEvidence({ + dossier: { + id: "dossier_1", + title: "星蓝新能源科技有限公司销售情报报告", + version_no: 2, + summary: "企业近期发布了产品更新公告。", + body: [{ text: "近期公开动态:企业近期发布了产品更新公告。" }], + }, + contexts: [{ uri: "viking://resources/workspace/company/materials/a.md", title: "会议纪要", abstract: "客户关注预算窗口。" }], + }); + const dossierEvidence = evidence.find((item) => item.source_kind === "企业档案"); + const result = validateQaModelAnswer({ + paragraphs: [ + { text: "企业近期发布了产品更新公告。", citation_ids: [dossierEvidence.id] }, + { text: "客户关注预算窗口。", citation_ids: ["fabricated"] }, + ], + insufficient: false, + }, evidence); + + assert.equal(result.citations.length, 1); + assert.equal(result.citations[0].source_kind, "企业档案"); + assert.deepEqual(evidence.map((item) => item.source_kind).sort(), ["企业档案", "内部资料"]); + assert.ok(result.errors.some((item) => item.includes("无效引用"))); + assert.ok(result.errors.some((item) => item.includes("缺少有效引用"))); +}); + +test("QA removes an unrequested gap paragraph and rejects a risk paragraph citing the wrong dossier section", () => { + const evidence = [{ + id: "recent_section", + label: "测试企业 销售情报报告 V2 · 近期公开动态", + source_kind: "企业档案", + source_quality: "verified_dossier", + summary: "近期公开动态:2026年7月30日,测试企业发布产品升级公告。", + }]; + const result = validateQaModelAnswer({ + paragraphs: [{ + text: "风险:该企业的交付周期需要核验。", + citation_ids: ["recent_section"], + }, { + text: "缺口:还需要补充更多资料。", + citation_ids: ["recent_section"], + }], + insufficient: false, + }, evidence, { question: "说明该企业的主要风险。" }); + + assert.equal(result.paragraphs.length, 1); + assert.ok(result.errors.some((item) => item.includes("风险与关注事项"))); + + const requested = validateQaModelAnswer({ + paragraphs: [{ + text: "缺口:还需要补充交付记录。", + citation_ids: ["recent_section"], + }], + insufficient: false, + }, evidence, { question: "还有哪些资料缺口?" }); + assert.equal(requested.paragraphs.length, 1); +}); + +test("QA evidence reads and ranks the relevant chunk instead of sending one long material blob", () => { + const evidence = buildQaEvidence({ + question: "客户的预算窗口和试点范围是什么?", + dossier: { + id: "dossier_qa_1", + title: "测试企业销售情报报告", + version_no: 2, + body: [ + { text: "企业与业务概览:该企业提供知识库产品。" }, + { text: "建议行动:确认试点范围和预算窗口。" }, + ], + }, + contexts: [{ + material_id: "material_long", + title: "客户需求确认会", + source_kind: "会议纪要", + uri: "viking://resources/material_long.md", + score: 0.72, + content: `${"一般背景信息。".repeat(220)}\n\n预算窗口:客户计划在第四季度确认预算;试点范围为两个业务部门。`, + }], + maxItems: 6, + }); + + assert.ok(evidence.length >= 3); + assert.ok(evidence.some((item) => item.summary.includes("第四季度确认预算"))); + assert.ok(evidence[0].summary.includes("预算") || evidence[0].summary.includes("试点范围")); + assert.ok(evidence.every((item) => item.summary.length <= 1800)); + assert.equal(assessQaAnswerability("客户的预算窗口是什么?", evidence).supported, true); +}); + +test("QA evidence carries a Markdown heading into the following table block", () => { + const content = [ + "# Agent Plan CookBook", + "## 项目介绍", + "这是一份个人投资助手搭建教程。", + "### 核心使用能力", + "| 能力点 | 说明 |", + "|-|-|", + "| 语言模型 | 完成需求理解和网站交付 |", + "| 联网搜索 | 补充公开新闻和行业动态 |", + "| 专业数据集 | 查询股票金融和企业工商数据 |", + "## 前置准备", + "购买套餐并完成环境配置。", + "## 网站开发流程", + "生成方案、开发页面并完成调试。", + ].join("\n\n"); + + const evidence = buildQaEvidence({ + question: "文档的核心使用能力有哪些?", + contexts: [{ + material_id: "material_doc", + title: "个人投资助手 CookBook", + source_kind: "云文档", + content, + }], + maxItems: 2, + }); + + assert.match(evidence[0].summary, /核心使用能力/); + assert.match(evidence[0].summary, /语言模型/); + assert.match(evidence[0].summary, /联网搜索/); + assert.doesNotMatch(evidence[0].summary, /^### 核心使用能力$/); +}); + +test("QA evidence ranks the complete capability table above title-only noise", () => { + const content = [ + "<title>Agent Plan CookBook -「个人投资助手」", + "更多 CookBook 可见:", + "---", + "# 一、项目介绍", + "「**核心使用能力**」", + "| **能力点** | 说明 |", + "|-|-|", + "| **语言模型** | 支持模型切换与网站交付 |", + "| **Claude code/ Agent 能力** | 承接需求理解、任务编排与开发 |", + "| **联网搜索** | 补充公开新闻和行业动态 |", + "| **Data MCP:股票金融数据/国内企业工商数据** | 查询专业结构化数据 |", + "| **多工具兼容** | 可在多个主流 Agent 平台中使用 |", + "| **消耗统一计量** | 在控制台查看统一计量结果 |", + "---", + "# 二、前置准备", + "购买套餐并完成环境配置。", + ].join("\n\n"); + + const evidence = buildQaEvidence({ + question: "这份个人投资助手文档明确使用了哪些核心能力?", + contexts: [{ + material_id: "material_full_table", + title: "飞书云文档:Agent Plan CookBook -「个人投资助手」", + source_kind: "云文档", + score: 0.7, + content, + }], + maxItems: 3, + }); + + assert.match(evidence[0].summary, /核心使用能力/); + assert.match(evidence[0].summary, /语言模型/); + assert.match(evidence[0].summary, /Claude code\/ Agent 能力/); + assert.match(evidence[0].summary, /联网搜索/); + assert.match(evidence[0].summary, /Data MCP/); + assert.match(evidence[0].summary, /多工具兼容/); + assert.match(evidence[0].summary, /消耗统一计量/); + assert.ok(evidence.every((item) => item.summary !== "---")); + + const competingEvidence = [{ + id: "evidence_demand_types", + label: "个人投资助手 CookBook", + source_kind: "云文档", + retrieval_score: 0.99, + summary: "### Step2 识别核心需求 | 需求类型 | 核心诉求 | |-|-| | 主动查看 | 想快速了解某只股票最近有没有值得关注的变化 | | 持续跟踪 | 不想每天手动查公告、新闻和风险事件 |", + }]; + const requirements = buildQaEnumerationRequirements( + "这份个人投资助手文档明确使用了哪些核心能力?", + [...competingEvidence, ...evidence], + ); + assert.deepEqual( + requirements.map((item) => item.label), + [ + "语言模型", + "Claude code/ Agent 能力", + "联网搜索", + "Data MCP:股票金融数据/国内企业工商数据", + "多工具兼容", + "消耗统一计量", + ], + ); + const incomplete = validateQaModelAnswer({ + paragraphs: [{ + text: "文档使用语言模型、Claude Code、联网搜索和 Data MCP。", + citation_ids: [evidence[0].id], + }], + insufficient: false, + }, evidence, { enumerationRequirements: requirements }); + assert.deepEqual( + incomplete.missing_enumeration_items.map((item) => item.label), + ["多工具兼容", "消耗统一计量"], + ); + assert.ok(incomplete.errors.some((item) => item.includes("回答遗漏枚举项"))); +}); + +test("QA enumeration completeness ignores unrelated tables for compare-style questions", () => { + const evidence = [{ + id: "evidence_trace_span", + label: "全链路数据体系建设研讨会", + source_kind: "飞书云文档", + retrieval_score: 0.99, + summary: [ + "Trace 通过唯一 Trace ID 串联一次完整调用,每个执行节点对应一个 Span。", + "| 阶段 | 说明 |", + "|-|-|", + "| 接入 | 完成数据接入 |", + "| 路由 | 完成请求路由 |", + "| 调用 | 完成模型调用 |", + "| 验收 | 完成效果验收 |", + ].join(" "), + }]; + + const requirements = buildQaEnumerationRequirements( + "Trace 和 Span 分别承担什么作用?请用三点说明。", + evidence, + ); + + assert.deepEqual(requirements, []); +}); + +test("QA answerability rejects unrelated questions even when enterprise evidence exists", () => { + const evidence = buildQaEvidence({ + question: "今天当地天气怎么样?", + dossier: { + id: "dossier_qa_2", + title: "测试企业销售情报报告", + body: [{ text: "企业与业务概览:该企业提供知识库产品。" }], + }, + contexts: [{ + material_id: "material_qa_2", + title: "客户需求确认会", + source_kind: "会议纪要", + content: "客户希望先验证知识库问答,并确认数据权限边界。", + }], + }); + + assert.equal(assessQaAnswerability("今天当地天气怎么样?", evidence).supported, false); +}); + +test("runtime evidence policy records public-source gaps without rejecting a legally anchored dossier", () => { + const pack = buildDossierEvidencePack({ + company, + generatedAt: "2026-07-21T10:00:00.000Z", + collected: { + professional: [{ + label: "企业工商数据库", + query: "星蓝新能源科技有限公司 企业工商信息", + summary: "星蓝新能源科技有限公司主体信息。", + }], + public_sources: [{ + label: "星蓝动态摘要", + summary: "星蓝新能源科技有限公司发布业务动态。", + }], + }, + }); + + const validation = validateProductionEvidencePack(pack); + assert.equal(pack.data_as_of, null); + assert.equal(validation.ok, true); + assert.equal(validation.policy.traceable_public_count, 0); + assert.equal(validation.policy.current_public_count, 0); + assert.equal(validation.policy.legal_entity_anchor_count, 1); +}); + +test("runtime evidence policy rejects a professional result that does not anchor the legal entity", () => { + const pack = buildDossierEvidencePack({ + company, + generatedAt: "2026-07-21T10:00:00.000Z", + collected: { + professional: [{ + label: "企业工商数据库", + query: "星蓝新能源科技有限公司 企业工商信息", + summary: "该记录仅描述新能源汽车相关业务,没有返回可核对的企业名称或统一社会信用代码。", + }], + }, + }); + + const validation = validateProductionEvidencePack(pack); + assert.equal(validation.ok, false); + assert.equal(validation.policy.legal_entity_anchor_count, 0); + assert.ok(validation.errors.some((item) => item.includes("目标主体"))); +}); + +test("evidence packs detect competing critical numbers from contemporaneous sources", () => { + const pack = buildDossierEvidencePack({ + company, + generatedAt: "2026-07-21T10:00:00.000Z", + collected: { + professional: [ + { + label: "企业工商数据库", + query: "星蓝新能源科技有限公司 注册资本", + summary: "星蓝新能源科技有限公司注册资本为1000万元。", + }, + { + label: "企业风险数据库", + query: "星蓝新能源科技有限公司 注册资本", + summary: "星蓝新能源科技有限公司注册资本为2000万元。", + }, + ], + }, + }); + + assert.equal(pack.conflicts.length, 1); + assert.equal(pack.conflicts[0].field, "registered_capital"); + assert.equal(pack.policy.conflict_count, 1); + assert.ok(pack.items.every((item) => item.conflict_fields.includes("registered_capital"))); +}); + +test("dossier validation requires two sources to agree on a critical number", () => { + const disagreeing = [ + { + id: "professional-1", + label: "企业工商数据库", + source_kind: "专业数据集", + quality_tier: 1, + independence_key: "datapro:business", + summary: "星蓝新能源科技有限公司注册资本为1000万元。", + }, + { + id: "professional-2", + label: "企业风险数据库", + source_kind: "专业数据集", + quality_tier: 1, + independence_key: "datapro:risk", + summary: "星蓝新能源科技有限公司注册资本为2000万元。", + }, + { + id: "public-1", + label: "近期公告", + source_kind: "联网搜索", + quality_tier: 2, + independence_key: "example.org", + summary: "星蓝新能源科技有限公司发布近期公告。", + }, + ]; + const parsed = { + body: [ + { text: "企业与业务概览:该企业注册资本为1000万元,并面向企业客户提供相关服务。", citation_ids: ["professional-1", "professional-2"] }, + { text: "经营与业务动态:专业数据可用于核验该企业当前经营主体。", citation_ids: ["professional-1"] }, + { text: "近期公开动态:企业发布了近期公告。", citation_ids: ["public-1"] }, + { text: "风险与关注事项:当前仍需交叉核验关键经营数字。", citation_ids: ["professional-1", "public-1"] }, + { text: "销售机会判断:当前可继续核验业务需求与合作场景。", citation_ids: ["professional-1", "public-1"] }, + { text: "建议行动:确认业务部门、采购计划和数据合规要求。", citation_ids: ["professional-1", "public-1"] }, + ], + }; + + const rejected = validateDossierModelAnswer(parsed, disagreeing); + assert.ok(rejected.errors.some((item) => item.includes("未获得双来源一致支持"))); + + const agreeing = disagreeing.map((item) => item.id === "professional-2" + ? { ...item, summary: "星蓝新能源科技有限公司注册资本为1000万元。" } + : item); + assert.deepEqual(validateDossierModelAnswer(parsed, agreeing).errors, []); +}); + +test("dossier validation does not require unrelated sources to pad citation counts", () => { + const evidence = [ + { + id: "professional-business", + label: "企业工商数据库", + source_kind: "专业数据集", + independence_key: "datapro:business", + summary: "星蓝新能源科技有限公司从事新能源汽车相关业务。", + }, + { + id: "professional-market", + label: "金融数据库", + source_kind: "专业数据集", + independence_key: "datapro:finance", + summary: "星蓝新能源科技有限公司持续推进新能源业务。", + }, + { + id: "public-project", + label: "星蓝新能源项目合作公告", + source_kind: "联网搜索", + independence_key: "official.example.org", + summary: "星蓝新能源科技有限公司发布新能源项目合作公告。", + }, + { + id: "public-delivery", + label: "星蓝新能源设备交付公告", + source_kind: "联网搜索", + independence_key: "news.example.net", + summary: "星蓝新能源科技有限公司披露设备交付进展。", + }, + ]; + const titles = [ + "企业与业务概览", + "经营与业务动态", + "近期公开动态", + "风险与关注事项", + "销售机会判断", + "建议行动", + ]; + const sparse = { + body: titles.map((title) => ({ + text: `${title}:这是由已核验来源支持的完整业务事实说明。`, + citation_ids: ["professional-business", "public-project"], + })), + }; + const sparseValidation = validateDossierModelAnswer(sparse, evidence); + assert.deepEqual(sparseValidation.errors, []); + + const covered = { + body: titles.map((title, index) => ({ + text: `${title}:这是由已核验来源支持的完整业务事实说明。`, + citation_ids: index % 2 + ? ["professional-market", "public-delivery"] + : ["professional-business", "public-project"], + })), + }; + assert.deepEqual(validateDossierModelAnswer(covered, evidence).errors, []); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/salesFailClosed.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/salesFailClosed.test.mjs new file mode 100644 index 00000000..344e5ed7 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/salesFailClosed.test.mjs @@ -0,0 +1,472 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SalesService } from "../src/services/salesService.js"; + +const strictRuntimePolicy = Object.freeze({ + fail_closed: true, +}); + +const permissiveTestPolicy = Object.freeze({ + fail_closed: false, +}); + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +function unavailableProviders() { + return { + dataProProvider: { + maxSources: 1, + isRunEnabled: () => true, + callTool: async () => ({ ok: false, error: { code: "temporarily_unavailable" } }), + }, + webSearchProvider: { + isRunEnabled: () => true, + search: async () => ({ ok: false, error: { code: "temporarily_unavailable" }, results: [] }), + }, + }; +} + +test("the runtime starts with no business data", () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: permissiveTestPolicy, + }); + + assert.deepEqual(service.data.goals, []); + assert.deepEqual(service.data.companies, {}); +}); + +test("test data is loaded only when a test explicitly injects a seed", () => { + const seed = { + goals: [{ id: "goal-1", name: "Test goal" }], + companies: {}, + dossiers: {}, + materials: {}, + qa_messages: {}, + }; + const service = new SalesService({ + env: envReader(), + runtimePolicy: permissiveTestPolicy, + seed, + }); + + assert.deepEqual(service.data.goals, seed.goals); +}); + +test("an empty persistent repository replaces injected test data", async () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + seed: { + goals: [{ id: "seed-goal", name: "Seed" }], + companies: { seed: { id: "seed", name: "Seed Company" } }, + dossiers: {}, + materials: {}, + qa_messages: {}, + }, + repository: { + getSalesState() { + return { + goals: [], + companies: {}, + dossiers: {}, + materials: {}, + qa_messages: {}, + }; + }, + }, + }); + + await service.assertRuntimeReady(); + assert.deepEqual(service.data.goals, []); + assert.deepEqual(service.data.companies, {}); + assert.equal(service.persistence.enabled, true); +}); + +test("the runtime refuses to continue when verified professional evidence is unavailable", async () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + ...unavailableProviders(), + }); + + await assert.rejects( + () => service.collectDossierEvidence({ id: "company-1", name: "测试企业" }), + (error) => error.status === 503 && error.code === "datapro_unavailable", + ); +}); + +test("the runtime preserves retryability when public evidence has a transient provider failure", async () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + dataProProvider: { + maxSources: 1, + isRunEnabled: () => true, + callTool: async () => ({ + ok: true, + summary: "已核验的企业专业资料", + raw_ref: "datapro:test", + }), + }, + webSearchProvider: { + isRunEnabled: () => true, + search: async () => ({ + ok: false, + error: { + code: "10500", + category: "upstream", + retryable: true, + }, + results: [], + }), + }, + }); + + await assert.rejects( + () => service.collectDossierEvidence({ id: "company-1", name: "测试企业" }), + (error) => ( + error.status === 503 + && error.code === "web_search_unavailable" + && error.retryable === true + && error.details.retryable === true + ), + ); +}); + +test("a unit-test policy can inspect issues without inventing professional evidence", async () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: permissiveTestPolicy, + ...unavailableProviders(), + }); + + const evidence = await service.collectDossierEvidence({ id: "company-1", name: "测试企业" }); + assert.deepEqual(evidence.professional, []); + assert.deepEqual(evidence.public_sources, []); + assert.ok(evidence.issues.length >= 2); +}); + +test("the runtime refuses rule-based dossier fallback when the model is disabled", async () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + modelProvider: { isRunEnabled: () => false }, + }); + + await assert.rejects( + () => service.generateDossierWithModel( + { id: "company-1", name: "测试企业有限公司", industry: "测试行业", location: "测试地区" }, + { + professional: [ + { label: "企业工商数据库", summary: "测试企业有限公司经营测试行业相关的软件与技术服务业务。" }, + { label: "金融数据库", summary: "测试企业有限公司持续推进软件产品研发与客户交付。" }, + ], + public_sources: [ + { + label: "测试企业有限公司发布产品升级公告", + summary: "测试企业有限公司于2026年7月发布产品升级公告。", + url: "https://news.test/company-update", + published_at: "2026-07-20T09:00:00.000Z", + }, + { + label: "测试企业有限公司披露项目交付计划", + summary: "测试企业有限公司披露项目分阶段交付计划。", + url: "https://official.test/company-delivery", + published_at: "2026-07-21T09:00:00.000Z", + }, + ], + }, + [], + ), + (error) => error.status === 503 && error.code === "model_unavailable", + ); +}); + +test("the runtime does not persist a rule dossier when the final model quality gate returns no dossier", async () => { + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test", ASYNC_JOBS_ENABLED: "false" }), + runtimePolicy: strictRuntimePolicy, + seed: { + goals: [], + companies: { + company_1: { + id: "company_1", + name: "测试科技有限公司", + industry: "企业软件", + location: "北京", + dossier_ids: [], + material_ids: [], + }, + }, + dossiers: {}, + materials: {}, + qa_messages: {}, + jobs: {}, + }, + providerRunStore: { + startRun: async () => ({ id: "run-1" }), + failRun: async () => null, + }, + }); + service.startJob = async () => ({ id: "job-1" }); + service.assertJobActive = async () => ({ id: "job-1" }); + service.trackProviderStep = async (_runId, _input, operation) => operation(); + service.collectDossierEvidence = async () => ({ + professional: [{ + label: "企业工商数据库", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件。", + }], + public_sources: [{ + label: "测试科技有限公司发布产品升级公告", + summary: "测试科技有限公司于2026年7月发布企业知识库产品升级公告。", + url: "https://news.test/product-update", + published_at: "2026-07-20T09:00:00.000Z", + }], + issues: [], + }); + service.generateDossierWithModel = async () => null; + service.buildRuleDossier = () => { + throw new Error("rule fallback must not run"); + }; + service.failJob = async () => null; + + await assert.rejects( + () => service.createDossier("company_1"), + (error) => ( + error.status === 503 + && error.code === "model_unavailable" + && error.details.reason === "dossier_quality_gate_failed" + ), + ); + assert.deepEqual(service.data.dossiers, {}); +}); + +test("the runtime requires the actually cited sources to anchor the legal entity", async () => { + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test", ASYNC_JOBS_ENABLED: "false" }), + runtimePolicy: strictRuntimePolicy, + seed: { + goals: [], + companies: { + company_1: { + id: "company_1", + name: "测试科技有限公司", + industry: "企业软件", + location: "北京", + dossier_ids: [], + material_ids: [], + }, + }, + dossiers: {}, + materials: {}, + qa_messages: {}, + jobs: {}, + }, + providerRunStore: { + startRun: async () => ({ id: "run-1" }), + failRun: async () => null, + }, + }); + service.startJob = async () => ({ id: "job-1" }); + service.assertJobActive = async () => ({ id: "job-1" }); + service.trackProviderStep = async (_runId, _input, operation) => operation(); + service.collectDossierEvidence = async () => ({ + professional: [ + { + label: "企业工商数据库", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件与知识库产品。", + }, + { + label: "企业风险数据库", + summary: "项目交付、合同责任和供应保障事项需要持续核验。", + }, + ], + public_sources: [ + { + label: "测试科技有限公司产品升级公告", + summary: "测试科技有限公司于2026年7月发布销售知识库产品升级公告。", + url: "https://news.test/product-update", + published_at: "2026-07-20T09:00:00.000Z", + }, + { + label: "测试科技有限公司项目交付公告", + summary: "测试科技有限公司于2026年7月披露企业软件项目的分阶段交付安排。", + url: "https://official.test/project-delivery", + published_at: "2026-07-21T09:00:00.000Z", + }, + ], + issues: [], + }); + service.generateDossierWithModel = async () => ({ + id: "under-sourced-model-dossier", + company_id: "company_1", + title: "测试科技有限公司 销售情报报告", + summary: "测试科技有限公司近期升级企业知识库产品。", + body: [ + { text: "企业与业务概览:测试科技有限公司面向企业客户提供知识库软件。", citation_ids: ["p2"] }, + { text: "经营与业务动态:公司持续升级企业知识库产品与内容检索能力。", citation_ids: ["p2"] }, + { text: "近期公开动态:测试科技有限公司于2026年7月发布产品升级公告。", citation_ids: ["w1"] }, + { text: "风险与关注事项:项目推进前需要确认实施排期和合同责任边界。", citation_ids: ["p2", "w1"] }, + { text: "销售机会判断:产品升级形成沟通窗口,但不代表客户已经形成采购意向。", citation_ids: ["p2", "w1"] }, + { text: "建议行动:1. 联系产品负责人。\n2. 核验实施排期。\n3. 准备试点方案。", citation_ids: ["p2", "w1"] }, + ], + citations: [ + { id: "p1", label: "企业工商数据库", source_kind: "专业数据集", summary: "测试科技有限公司主营企业软件与知识库产品。" }, + { id: "p2", label: "企业风险数据库", source_kind: "专业数据集", summary: "项目交付和合同责任需要持续核验。" }, + { id: "w1", label: "测试科技有限公司产品升级公告", source_kind: "联网搜索", url: "https://news.test/product-update", summary: "测试科技有限公司于2026年7月发布产品升级公告。" }, + { id: "w2", label: "测试科技有限公司项目交付公告", source_kind: "联网搜索", url: "https://official.test/project-delivery", summary: "测试科技有限公司于2026年7月披露项目交付安排。" }, + ], + memory_summary: "测试科技有限公司近期升级企业知识库产品。", + created_at: "2026-07-29T10:00:00.000Z", + }); + service.failJob = async () => null; + + await assert.rejects( + () => service.createDossier("company_1"), + (error) => ( + error.status === 503 + && error.code === "model_unavailable" + && error.details.reason === "public_dossier_quality_gate_failed" + && error.details.validation_errors.some((message) => /目标法定主体/.test(message)) + ), + ); + assert.deepEqual(service.data.dossiers, {}); +}); + +test("dossier lists hide six-section records that contain search debris or question-like facts", () => { + const goodBody = [ + { text: "企业与业务概览:测试科技有限公司面向企业客户提供知识库软件,并持续服务销售团队的信息管理场景。", citation_ids: ["professional-1"] }, + { text: "经营与业务动态:公司在2026年持续升级企业知识库产品,重点增强权限管理、内容检索和协作能力。", citation_ids: ["professional-1"] }, + { text: "近期公开动态:公司于2026年7月发布产品升级公告,披露了面向销售团队的新协作功能。", citation_ids: ["public-1"] }, + { text: "风险与关注事项:公开公告提示交付计划仍受实施资源影响,商务推进前应确认项目排期和责任边界。", citation_ids: ["public-1"] }, + { text: "销售机会判断:产品升级形成了知识库集成和数据治理的沟通窗口,但不代表客户已经形成采购意向。", citation_ids: ["professional-1", "public-1"] }, + { text: "建议行动:1. 联系产品负责人确认升级范围和试点计划。\n2. 准备权限治理与交付边界材料。\n3. 核验预算窗口和采购流程。", citation_ids: ["professional-1", "public-1"] }, + ]; + const citations = [ + { id: "professional-1", label: "企业工商数据库", source_kind: "专业数据集", summary: "测试科技有限公司主营企业软件。" }, + { id: "public-1", label: "测试科技有限公司产品升级公告", source_kind: "联网搜索", summary: "测试科技有限公司于2026年7月发布产品升级公告。" }, + ]; + const service = new SalesService({ + env: envReader(), + runtimePolicy: permissiveTestPolicy, + seed: { + goals: [], + companies: { + company_1: { + id: "company_1", + name: "测试科技有限公司", + dossier_ids: ["bad-dossier", "good-dossier"], + material_ids: [], + }, + }, + dossiers: { + "bad-dossier": { + id: "bad-dossier", + company_id: "company_1", + summary: "测试科技有限公司是否有法律诉讼-启信宝。", + body: goodBody.map((paragraph, index) => ( + index === 2 + ? { text: "近期公开动态:测试科技有限公司是否有法律诉讼-启信宝。", citation_ids: ["public-1"] } + : paragraph + )), + citations, + version_no: 2, + created_at: "2026-07-29T10:00:00.000Z", + }, + "good-dossier": { + id: "good-dossier", + company_id: "company_1", + summary: "测试科技有限公司近期升级企业知识库产品,销售侧可围绕权限治理、系统集成和试点交付窗口继续核验。", + body: goodBody, + citations, + version_no: 1, + created_at: "2026-07-28T10:00:00.000Z", + }, + }, + materials: {}, + qa_messages: {}, + jobs: {}, + }, + }); + + assert.deepEqual(service.listDossiers("company_1").map((item) => item.id), ["good-dossier"]); +}); + +test("strict dossier detail keeps a concise record when its claims are grounded and the subject is anchored", () => { + const body = [ + { text: "企业与业务概览:测试科技有限公司面向企业客户提供知识库软件,并持续服务销售团队的信息管理场景。", citation_ids: ["professional-1"] }, + { text: "经营与业务动态:公司持续升级企业知识库产品,重点增强权限管理、内容检索和协作能力。", citation_ids: ["professional-1"] }, + { text: "近期公开动态:测试科技有限公司于2026年7月发布产品升级公告,披露面向销售团队的新协作功能。", citation_ids: ["public-1"] }, + { text: "风险与关注事项:公开公告提示交付计划仍受实施资源影响,商务推进前应确认项目排期和责任边界。", citation_ids: ["public-1"] }, + { text: "销售机会判断:产品升级形成知识库集成和数据治理的沟通窗口,但不代表客户已经形成采购意向。", citation_ids: ["professional-1", "public-1"] }, + { text: "建议行动:1. 联系产品负责人确认升级范围。\n2. 准备权限治理材料。\n3. 核验预算窗口。", citation_ids: ["professional-1", "public-1"] }, + ]; + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + seed: { + goals: [], + companies: { + company_1: { + id: "company_1", + name: "测试科技有限公司", + dossier_ids: ["under-sourced-dossier"], + material_ids: [], + }, + }, + dossiers: { + "under-sourced-dossier": { + id: "under-sourced-dossier", + company_id: "company_1", + summary: "测试科技有限公司近期升级企业知识库产品。", + body, + citations: [ + { + id: "professional-1", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "测试科技有限公司主营企业软件与知识库产品。", + }, + { + id: "public-1", + label: "测试科技有限公司产品升级公告", + source_kind: "联网搜索", + url: "https://news.test/product-update", + summary: "测试科技有限公司于2026年7月发布产品升级公告。", + }, + ], + version_no: 1, + created_at: "2026-07-29T10:00:00.000Z", + }, + }, + materials: {}, + qa_messages: {}, + jobs: {}, + }, + }); + + assert.deepEqual( + service.listDossiers("company_1").map((item) => item.id), + ["under-sourced-dossier"], + ); + assert.equal(service.dossierDetail("under-sourced-dossier").citations.length, 2); +}); + +test("business access requires a working persistent repository", async () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + }); + + await assert.rejects( + () => service.assertRuntimeReady(), + (error) => error.status === 503 && error.code === "supabase_unavailable", + ); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/salesQaQuality.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/salesQaQuality.test.mjs new file mode 100644 index 00000000..76335062 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/salesQaQuality.test.mjs @@ -0,0 +1,272 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + assessQaAnswerability, + buildQaEvidence, + fuseQaRetrievalContexts, +} from "../src/evidence/salesEvidence.js"; + +const dossier = { + id: "dossier_quality_v2", + title: "远航能源销售情报报告", + version_no: 2, + body: [ + { text: "企业与业务概览:远航能源主营储能系统集成与电池管理平台。" }, + { text: "经营与业务动态:公司正在推进华东区域工商业储能项目。" }, + { text: "近期公开动态:近期公开信息显示公司启动了新一轮供应商遴选。" }, + { text: "风险与关注事项:项目尚未完成预算审批,交付周期是当前主要风险。" }, + { text: "销售机会判断:储能监控、运维和数据平台存在进一步合作机会。" }, + { text: "建议行动:先确认预算审批节点,再向信息化部门提交小范围验证方案。" }, + ], +}; + +const contexts = [ + { + material_id: "doc_budget", + title: "云文档:储能平台立项说明", + source_kind: "云文档", + score: 0.82, + content: [ + "项目背景:客户计划统一管理华东区域的储能站点。", + "技术范围:一期先接入十二个站点,验证监控告警和设备健康分析。", + "预算与排期:首期预算为320万元,计划在第四季度完成采购,采购前需完成安全评审。", + "验收要求:告警到达率不低于99.9%,并支持私有化部署。", + ].join("\n\n"), + }, + { + material_id: "chat_people", + title: "飞书会话:7月客户沟通", + source_kind: "飞书会话", + score: 0.76, + content: [ + "销售:本轮验证由谁牵头?", + "客户:信息化部的周敏负责方案评审,采购部的林涛负责商务流程。", + "客户:目前主要顾虑是历史设备协议不统一,希望先做三个站点的兼容性验证。", + ].join("\n"), + }, + { + material_id: "doc_unrelated", + title: "云文档:员工活动安排", + source_kind: "云文档", + score: 0.2, + content: "员工活动计划在园区举办,内容与销售项目无关。", + }, +]; + +const cases = [ + { + question: "客户的预算是多少,计划什么时候采购?", + expectedMaterialId: "doc_budget", + }, + { + question: "谁负责方案评审和商务流程?", + expectedMaterialId: "chat_people", + }, + { + question: "客户当前最主要的顾虑是什么?", + expectedMaterialId: "chat_people", + }, + { + question: "这个项目有哪些风险,下一步应该怎么推进?", + expectedText: "预算审批", + }, + { + question: "一期计划接入多少个站点?", + expectedMaterialId: "doc_budget", + }, + { + question: "验收时对告警到达率有什么要求?", + expectedMaterialId: "doc_budget", + }, + { + question: "客户是否要求私有化部署?", + expectedMaterialId: "doc_budget", + }, + { + question: "采购部由谁负责商务流程?", + expectedMaterialId: "chat_people", + }, +]; + +test("QA retrieval quality gate keeps every golden fact inside top five evidence chunks", () => { + let hits = 0; + for (const item of cases) { + const evidence = buildQaEvidence({ + dossier, + contexts, + question: item.question, + maxItems: 8, + }); + const topFive = evidence.slice(0, 5); + const matched = item.expectedMaterialId + ? topFive.some((candidate) => candidate.material_id === item.expectedMaterialId) + : topFive.some((candidate) => candidate.summary.includes(item.expectedText)); + if (matched) hits += 1; + assert.equal(matched, true, `未命中问题:${item.question}`); + assert.equal(assessQaAnswerability(item.question, evidence).supported, true); + } + assert.equal(hits / cases.length, 1); +}); + +test("QA retrieval quality gate rejects an unrelated question instead of forcing an answer", () => { + const question = "明天上海会不会下雨?"; + const evidence = buildQaEvidence({ dossier, contexts, question, maxItems: 8 }); + const assessment = assessQaAnswerability(question, evidence); + assert.equal(assessment.supported, false); + assert.equal(assessment.reason, "low_relevance"); +}); + +test("QA evidence remains bounded and preserves source diversity", () => { + const evidence = buildQaEvidence({ + dossier, + contexts, + question: "总结项目需求、负责人、风险和下一步行动", + maxItems: 8, + }); + assert.ok(evidence.length <= 8); + assert.ok(evidence.some((item) => item.source_kind === "企业档案")); + assert.ok(evidence.some((item) => item.source_kind !== "企业档案")); + assert.ok(evidence.every((item) => item.summary.length <= 1600)); +}); + +test("QA retrieval fusion promotes evidence recalled by multiple query variants", () => { + const fused = fuseQaRetrievalContexts([ + { + query: "远航能源 客户预算", + contexts: [ + { + material_id: "doc_general", + uri: "viking://sales/workspace/company/materials/general.md", + abstract: "一般项目背景。", + score: 0.9, + }, + { + material_id: "doc_budget", + uri: "viking://sales/workspace/company/materials/budget.md", + abstract: "首期预算为 320 万元。", + score: 0.8, + }, + ], + }, + { + query: "远航能源 采购时间 预算窗口", + contexts: [ + { + material_id: "doc_budget", + uri: "viking://sales/workspace/company/materials/budget.md", + abstract: "第四季度完成采购。", + score: 0.84, + }, + { + material_id: "doc_schedule", + uri: "viking://sales/workspace/company/materials/schedule.md", + abstract: "项目排期说明。", + score: 0.79, + }, + ], + }, + ], { + maxContexts: 3, + maxPerMaterial: 2, + }); + + assert.equal(fused[0].material_id, "doc_budget"); + assert.equal(fused[0].query_hits, 2); + assert.deepEqual(fused[0].matched_queries, [ + "远航能源 客户预算", + "远航能源 采购时间 预算窗口", + ]); + assert.ok(fused[0].fusion_score > fused[1].fusion_score); +}); + +test("QA retrieval fusion preserves distinct sections but limits one material from crowding out others", () => { + const fused = fuseQaRetrievalContexts([ + { + query: "客户需求 风险 下一步", + contexts: [ + { + material_id: "doc_project", + uri: "viking://sales/workspace/company/materials/project/requirements.md", + abstract: "客户需要私有化部署。", + score: 0.91, + }, + { + material_id: "doc_project", + uri: "viking://sales/workspace/company/materials/project/risks.md", + abstract: "预算审批尚未完成。", + score: 0.89, + }, + { + material_id: "doc_project", + uri: "viking://sales/workspace/company/materials/project/background.md", + abstract: "一般项目背景。", + score: 0.88, + }, + { + material_id: "chat_people", + uri: "viking://sales/workspace/company/materials/chat.md", + abstract: "周敏负责方案评审。", + score: 0.82, + }, + ], + }, + ], { + maxContexts: 4, + maxPerMaterial: 2, + }); + + assert.equal(fused.filter((item) => item.material_id === "doc_project").length, 2); + assert.ok(fused.some((item) => item.material_id === "chat_people")); +}); + +test("QA evidence does not force an unrelated dossier section into a focused internal-material answer", () => { + const evidence = buildQaEvidence({ + dossier, + contexts, + question: "谁负责方案评审和商务流程?", + maxItems: 2, + }); + + assert.equal(evidence.length, 2); + assert.equal(evidence[0].material_id, "chat_people"); + assert.ok(evidence.every((item) => item.source_kind !== "企业档案")); +}); + +test("QA chunk overlap keeps a fact intact when it crosses a long-text boundary", () => { + const boundaryContent = `${"背景".repeat(549)}第四季度确认预算,首批试点覆盖两个部门。`; + const evidence = buildQaEvidence({ + contexts: [{ + material_id: "doc_boundary", + title: "客户项目计划", + source_kind: "云文档", + content: boundaryContent, + score: 0.8, + }], + question: "客户什么时候确认预算?", + maxItems: 4, + }); + + assert.ok(evidence.some((item) => item.summary.includes("第四季度确认预算"))); +}); + +test("QA evidence expands a matched chunk with adjacent document context", () => { + const evidence = buildQaEvidence({ + contexts: [{ + material_id: "doc_context_window", + title: "客户采购安排", + source_kind: "云文档", + content: [ + "项目范围:首批验证覆盖两个业务部门。", + "预算窗口:客户计划在第四季度确认 320 万元预算。", + "付款安排:合同签署后支付首款,验收通过后支付尾款。", + ].join("\n\n"), + score: 0.86, + }], + question: "客户什么时候确认预算,付款怎么安排?", + maxItems: 3, + }); + + assert.match(evidence[0].summary, /第四季度确认 320 万元预算/); + assert.match(evidence[0].summary, /验收通过后支付尾款/); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/salesStage4Workflow.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/salesStage4Workflow.test.mjs new file mode 100644 index 00000000..d2c7ae2a --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/salesStage4Workflow.test.mjs @@ -0,0 +1,3098 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assessDossierEvidenceCoverage, + SalesService, +} from "../src/services/salesService.js"; + +const permissiveTestPolicy = Object.freeze({ + fail_closed: false, +}); + +const strictRuntimePolicy = Object.freeze({ + fail_closed: true, +}); + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +function seed() { + return { + goals: [], + companies: { + company_1: { + id: "company_1", + name: "测试科技有限公司", + initial: "测", + industry: "企业软件", + location: "北京", + tags: [], + progress: { label: "新商机", summary: "待生成档案", evidence: "暂无", updated_at: null }, + dossier_ids: [], + material_ids: ["material_1"], + qa_session_id: "sales-company_1", + }, + }, + dossiers: {}, + materials: { + material_1: { + id: "material_1", + company_id: "company_1", + title: "客户需求确认会", + summary: "客户希望先验证知识库问答,并要求明确数据权限边界。", + source_type: "飞书会议纪要", + openviking_uri: "viking://resources/workspace-test/companies/company_1/materials/material_1", + updated_at: "2026-07-20T08:00:00.000Z", + }, + }, + qa_messages: { company_1: [] }, + sync_sources: {}, + sync_checkpoints: {}, + jobs: {}, + }; +} + +function stagedDossierPlan(request, { preferredPublicTitle = "" } = {}) { + const evidenceBySection = request.payload.evidence_by_section; + const evidence = (key, predicate = () => true, preferPublic = false) => { + const candidates = evidenceBySection?.[key]?.allowed_evidence || []; + if (preferPublic && preferredPublicTitle) { + const preferred = candidates.find((item) => ( + item.title.includes(preferredPublicTitle) && predicate(item) + )); + if (preferred) return preferred; + } + return candidates.find(predicate) || candidates[0]; + }; + const complete = (value) => ( + /[。!?]$/u.test(String(value || "")) ? String(value) : `${String(value || "")}。` + ); + let businessDynamicsEvidenceId = ""; + const builders = { + company_overview() { + const atom = evidence("company_overview", (item) => ( + /经营范围|面向企业|主营/.test(item.quote) + )); + return { + text: "该企业经营企业软件相关业务。", + evidence_ids: [atom.id], + }; + }, + business_dynamics() { + const atom = evidence("business_dynamics", (item) => ( + item.source_kind === "professional" + && /金融数据库|汽车销量数据库|科研学术数据搜索服务/.test(item.title) + )); + businessDynamicsEvidenceId = atom.id; + return { text: complete(atom.quote), evidence_ids: [atom.id] }; + }, + recent_public_updates() { + const atom = evidence( + "recent_public_updates", + (item) => item.id !== businessDynamicsEvidenceId, + true, + ); + return { text: complete(atom.quote), evidence_ids: [atom.id] }; + }, + risk_attention() { + const atom = evidence("risk_attention"); + return { + text: "业务推进前需要核验企业软件的实施范围和责任边界。", + evidence_ids: [atom.id], + }; + }, + sales_opportunity() { + const atom = evidence("sales_opportunity", (item) => ( + /升级|更新|项目|产品/.test(item.quote) + ), true); + return { + text: "产品更新为销售知识库场景提供试点沟通窗口,但不代表企业已有采购意向。", + evidence_ids: [atom.id], + }; + }, + recommended_actions() { + const atom = evidence("recommended_actions", (item) => ( + /升级|更新|项目|产品|交付/.test(item.quote) + ), true); + return { + text: "销售人员应联系产品负责人确认产品更新范围、试点目标和验收边界。", + evidence_ids: [atom.id], + }; + }, + }; + const required = request.parameters.properties.sections.required; + return { + sections: Object.fromEntries(required.map((key) => [key, builders[key]()])), + }; +} + +function createWorkflowService({ + sharedSessionMessages = new Map(), + seedData = seed(), +} = {}) { + let publicSummary = "测试科技有限公司发布了企业知识库产品更新公告。"; + const modelCalls = []; + const sessionMessages = sharedSessionMessages; + const modelProvider = { + isRunEnabled: () => true, + async callJson(input) { + modelCalls.push(structuredClone(input)); + if (input.operation === "sales_qa") { + const dossier = input.payload.evidence.find((item) => item.source_kind === "企业档案"); + const internal = input.payload.evidence.find((item) => item.source_kind !== "企业档案"); + return { + ok: true, + parsed: { + paragraphs: [ + { text: "当前企业档案显示该企业近期更新了知识库产品。", citation_ids: [dossier.id] }, + { text: "历史沟通中,客户要求先确认数据权限边界。", citation_ids: [internal.id] }, + ], + insufficient: false, + }, + usage: { prompt_tokens: 120, completion_tokens: 60, total_tokens: 180 }, + raw_ref: "model:qa-1", + }; + } + if (input.operation === "sales_dossier_agent_plan" || input.operation === "sales_dossier_agent_replan") { + return { + ok: true, + parsed: stagedDossierPlan(input, { + preferredPublicTitle: "测试科技有限公司产品更新公告", + }), + usage: { prompt_tokens: 180, completion_tokens: 80, total_tokens: 260 }, + raw_ref: `model:dossier-plan-${modelCalls.length}`, + }; + } + throw new Error(`unexpected dossier operation: ${input.operation}`); + }, + async callRequiredFunction(input) { + return this.callJson(input); + }, + }; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test", ASYNC_JOBS_ENABLED: "false" }), + runtimePolicy: permissiveTestPolicy, + seed: seedData, + dataProProvider: { + maxSources: 1, + isRunEnabled: () => true, + async callTool(query) { + return { + ok: true, + summary: "测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件。", + raw_ref: "datapro:company_1", + query, + }; + }, + }, + webSearchProvider: { + isRunEnabled: () => true, + async search() { + return { + ok: true, + results: [{ + title: "测试科技有限公司产品更新公告", + summary: publicSummary, + url: "https://news.test/company-1-update", + publish_time: "2026-07-20T09:00:00.000Z", + }], + }; + }, + }, + modelProvider, + openVikingProvider: { + isConfigured: () => true, + isRunEnabled: () => true, + salesCompanyUri: ({ workspaceId, companyId }) => `viking://resources/${workspaceId}/companies/${companyId}`, + salesSessionId: ({ workspaceId, companyId }) => `sales-${workspaceId}-${companyId}`, + async findMemories() { + return { + ok: true, + result: { + resources: [ + { + uri: "viking://resources/workspace-test/companies/company_1/materials/material_1.md", + title: "material_1.md", + abstract: "客户希望先验证知识库问答,并要求明确数据权限边界。", + }, + { + uri: "viking://resources/workspace-test/companies/company_1/materials/overview.md", + title: "overview", + abstract: "内部目录 company_dp_should_not_be_visible 的实现说明。", + }, + ], + }, + }; + }, + async getSessionContext(sessionId) { + const messages = sessionMessages.get(sessionId) || []; + if (!messages.length) { + return { ok: false, http_status: 404, error: { code: "not_found", message: "Session not found" } }; + } + return { + ok: true, + session_id: sessionId, + messages, + latest_archive_overview: "", + raw_ref: `openviking:session:${sessionId}:context`, + }; + }, + async addSessionMessages(sessionId, messages) { + const existing = sessionMessages.get(sessionId) || []; + const appended = messages.map((message, index) => ({ + id: `session-message-${existing.length + index + 1}`, + role: message.role, + text: message.content, + created_at: "2026-07-26T10:00:00.000Z", + })); + sessionMessages.set(sessionId, [...existing, ...appended]); + return { + ok: true, + session_id: sessionId, + raw_ref: `openviking:session:${sessionId}:messages`, + }; + }, + async recordSessionUsed() { + return { ok: true }; + }, + async commitSession(sessionId) { + return { ok: true, raw_ref: `openviking:session:${sessionId}:commit` }; + }, + }, + }); + return { + service, + modelCalls, + sessionMessages, + changePublicSummary(value) { + publicSummary = value; + }, + }; +} + +test("dossier generation skips unchanged evidence and versions material changes", async () => { + const fixture = createWorkflowService(); + const first = await fixture.service.createDossier("company_1"); + const firstModelCalls = fixture.modelCalls.filter((call) => call.operation === "sales_dossier_agent_plan"); + + assert.equal(first.action, "created"); + assert.equal(first.detail.version_no, 1); + assert.equal(first.detail.previous_dossier_id, null); + assert.equal(Object.hasOwn(first.detail, "evidence_hash"), false); + assert.equal(Object.hasOwn(first.detail, "dossier_fingerprint"), false); + assert.equal(Object.hasOwn(first.detail, "provider_run_id"), false); + assert.equal(firstModelCalls.length, 1); + assert.equal(first.detail.body.length, 6); + assert.ok( + first.detail.body.every((paragraph) => paragraph.citation_ids.length > 0), + JSON.stringify({ body: first.detail.body, citations: first.detail.citations }, null, 2), + ); + assert.ok(first.detail.citations.every((citation) => ["专业数据集", "联网搜索"].includes(citation.source_kind))); + assert.equal(first.detail.citations.some((citation) => citation.source_kind === "内部资料"), false); + assert.equal(firstModelCalls[0].payload.citations, undefined); + assert.doesNotMatch( + JSON.stringify(firstModelCalls[0].payload.evidence_by_section), + /内部资料|openviking|viking:\/\//iu, + ); + + const unchanged = await fixture.service.createDossier("company_1"); + assert.equal(unchanged.action, "no_material_change"); + assert.equal(unchanged.detail.id, first.detail.id); + assert.equal(fixture.modelCalls.filter((call) => call.operation === "sales_dossier_agent_plan").length, 1); + assert.equal(Object.keys(fixture.service.data.dossiers).length, 1); + + fixture.changePublicSummary("测试科技有限公司新增了面向销售团队的知识库协作能力。"); + const changed = await fixture.service.createDossier("company_1"); + assert.equal(changed.action, "created"); + assert.equal(changed.detail.version_no, 2); + assert.equal(changed.detail.previous_dossier_id, first.detail.id); + assert.equal( + fixture.modelCalls.filter((call) => call.operation === "sales_dossier_agent_plan").length, + 2, + ); + assert.equal(Object.keys(fixture.service.data.dossiers).length, 2); + + const hiddenDossierId = "dossier-hidden-v9"; + const changedRecord = fixture.service.data.dossiers[changed.detail.id]; + fixture.service.data.dossiers[hiddenDossierId] = { + ...structuredClone(changedRecord), + id: hiddenDossierId, + version_no: 9, + summary: "测试科技有限公司是否有法律诉讼-启信宝。", + body: changedRecord.body.map((paragraph, index) => ( + index === 2 + ? { ...paragraph, text: "近期公开动态:测试科技有限公司是否有法律诉讼-启信宝。" } + : structuredClone(paragraph) + )), + evidence_hash: "hidden-low-quality-evidence", + created_at: "2026-07-29T10:00:00.000Z", + }; + fixture.service.data.companies.company_1.dossier_ids.unshift(hiddenDossierId); + fixture.changePublicSummary("测试科技有限公司新增了面向销售负责人的客户洞察能力。"); + const afterHiddenVersion = await fixture.service.createDossier("company_1"); + assert.equal(afterHiddenVersion.action, "created"); + assert.equal(afterHiddenVersion.detail.version_no, 10); + assert.equal(afterHiddenVersion.detail.previous_dossier_id, changed.detail.id); + assert.equal(fixture.service.listDossiers("company_1").some((item) => item.id === hiddenDossierId), false); + + const jobs = await fixture.service.listJobs({ job_type: "sales_dossier_generation" }); + assert.equal(jobs.length, 4); + assert.ok(jobs.every((job) => job.status === "succeeded")); +}); + +test("dossier generation does not persist a second version when added evidence leaves the public report unchanged", async () => { + const fixture = createWorkflowService(); + const stablePublicResults = [ + { + title: "测试科技有限公司产品更新公告", + summary: "测试科技有限公司发布了企业知识库产品更新公告。", + url: "https://news.test/company-1-update", + publish_time: "2026-07-20T09:00:00.000Z", + }, + { + title: "测试科技有限公司产品交付说明", + summary: "测试科技有限公司披露企业知识库产品交付范围与实施安排。", + url: "https://news.test/company-1-delivery", + publish_time: "2026-07-18T09:00:00.000Z", + }, + ]; + fixture.service.webSearchProvider.search = async () => ({ + ok: true, + results: stablePublicResults, + }); + const first = await fixture.service.createDossier("company_1"); + fixture.service.webSearchProvider.search = async () => ({ + ok: true, + results: [ + ...stablePublicResults, + { + title: "测试科技有限公司产品更新补充说明", + summary: "测试科技有限公司补充披露了企业知识库产品更新安排。", + url: "https://news.test/company-1-update-note", + publish_time: "2026-07-19T09:00:00.000Z", + }, + ], + }); + fixture.service.modelProvider.callRequiredFunction = async (input) => { + fixture.modelCalls.push(structuredClone(input)); + if (input.operation === "sales_dossier_agent_plan") { + return { + ok: true, + parsed: stagedDossierPlan(input, { + preferredPublicTitle: "测试科技有限公司产品更新公告", + }), + raw_ref: "model:same-report-plan", + }; + } + throw new Error(`unexpected dossier operation: ${input.operation}`); + }; + + const second = await fixture.service.createDossier("company_1"); + + assert.equal(second.action, "no_report_change"); + assert.equal(second.detail.id, first.detail.id); + assert.equal(Object.keys(fixture.service.data.dossiers).length, 1); +}); + +test("unchanged evidence regenerates a legacy dossier that no longer meets citation coverage", async () => { + const fixture = createWorkflowService(); + const first = await fixture.service.createDossier("company_1"); + const stored = fixture.service.data.dossiers[first.detail.id]; + const professionalId = stored.citations.find((citation) => citation.source_kind === "专业数据集")?.id; + assert.ok(professionalId); + stored.body = stored.body.map((paragraph) => ({ + ...paragraph, + citation_ids: [professionalId], + segments: (paragraph.segments || []).map((segment) => ({ + ...segment, + citation_ids: [professionalId], + })), + })); + + const regenerated = await fixture.service.createDossier("company_1"); + assert.equal(regenerated.action, "created"); + assert.equal(regenerated.detail.version_no, 2); + assert.equal(regenerated.detail.previous_dossier_id, first.detail.id); + assert.equal(fixture.modelCalls.filter((call) => call.operation === "sales_dossier_agent_plan").length, 2); + assert.ok(regenerated.detail.citations.length >= 2); +}); + +test("dossier evidence collection supplements professional data with public risk queries", async () => { + const webQueries = []; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + dataProProvider: { + maxSources: 2, + isRunEnabled: () => true, + planDossierQueries: () => [ + { + label: "企业工商数据库", + purpose: "主体与经营信息核验", + query: "测试科技有限公司 企业工商数据", + }, + { + label: "企业风险数据库", + purpose: "风险与关注事项核验", + query: "测试科技有限公司 企业风险数据", + }, + ], + async callTool(query) { + return { + ok: true, + summary: query.includes("风险") + ? "企业风险信息包含经营异常、行政处罚、司法诉讼和限制高消费等核验维度。" + : "测试科技有限公司经营范围包括企业软件与知识库产品。", + raw_ref: `datapro:${query}`, + }; + }, + }, + webSearchProvider: { + isRunEnabled: () => true, + async search(input) { + webQueries.push(input.query); + return { + ok: true, + results: [{ + title: `${input.query}公开结果`, + summary: "公开来源披露了与该查询相关的企业事项。", + url: `https://news.test/${webQueries.length}`, + publish_time: "2026-07-20T09:00:00.000Z", + }], + }; + }, + }, + }); + + await service.collectDossierEvidence(service.data.companies.company_1); + + assert.ok(webQueries.some((query) => ( + /行政处罚/.test(query) + && /司法诉讼/.test(query) + && /失信被执行/.test(query) + && /经营异常/.test(query) + ))); +}); + +test("dossier evidence collection resumes completed provider queries from a durable checkpoint", async () => { + let dataProCalls = 0; + let webCalls = 0; + let savedCheckpoint = null; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + dataProProvider: { + maxSources: 2, + isRunEnabled: () => true, + planDossierQueries: () => [ + { + label: "企业工商数据库", + purpose: "主体与经营信息核验", + query: "测试科技有限公司 企业工商数据", + }, + { + label: "企业风险数据库", + purpose: "风险与关注事项核验", + query: "测试科技有限公司 企业风险数据", + }, + ], + async callTool(query) { + dataProCalls += 1; + return { + ok: true, + summary: query.includes("风险") + ? "测试科技有限公司的企业风险数据包含司法诉讼、行政处罚和经营异常核验结果。" + : "公司名称:测试科技有限公司;经营范围:企业软件与知识库产品。", + raw_ref: `datapro:${dataProCalls}`, + }; + }, + }, + webSearchProvider: { + isRunEnabled: () => true, + async search() { + webCalls += 1; + return { + ok: true, + results: [{ + title: "测试科技有限公司发布知识库项目公告", + summary: "测试科技有限公司于2026年7月发布知识库项目公告,并披露产品交付安排。", + url: `https://official.test/update-${webCalls}`, + site_name: "测试科技有限公司", + auth_level: 2, + publish_time: "2026-07-25T09:00:00.000Z", + }], + }; + }, + }, + }); + const company = service.data.companies.company_1; + + const first = await service.collectDossierEvidence(company, "", { + save_checkpoint: async (checkpoint) => { + savedCheckpoint = structuredClone(checkpoint); + }, + }); + const firstDataProCalls = dataProCalls; + const firstWebCalls = webCalls; + assert.ok(first.professional.length >= 2); + assert.ok(first.public_sources.length >= 1); + assert.ok(savedCheckpoint.completed_query_keys.length >= firstDataProCalls + firstWebCalls); + + const resumed = await service.collectDossierEvidence(company, "", { + checkpoint: savedCheckpoint, + save_checkpoint: async (checkpoint) => { + savedCheckpoint = structuredClone(checkpoint); + }, + }); + + assert.equal(dataProCalls, firstDataProCalls); + assert.equal(webCalls, firstWebCalls); + assert.deepEqual(resumed.professional, first.professional); + assert.deepEqual(resumed.public_sources, first.public_sources); +}); + +test("dossier evidence collection uses bounded concurrency for independent provider queries", async () => { + let activeDataPro = 0; + let maxActiveDataPro = 0; + let activeWeb = 0; + let maxActiveWeb = 0; + const service = new SalesService({ + env: envReader({ + APP_WORKSPACE_ID: "workspace-test", + DOSSIER_DATAPRO_CONCURRENCY: "2", + DOSSIER_WEB_CONCURRENCY: "3", + }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + dataProProvider: { + maxSources: 3, + isRunEnabled: () => true, + planDossierQueries: () => [1, 2, 3].map((index) => ({ + label: index === 1 ? "企业工商数据库" : `专业数据库 ${index}`, + purpose: `专业核验 ${index}`, + query: `测试科技有限公司 专业查询 ${index}`, + })), + async callTool() { + activeDataPro += 1; + maxActiveDataPro = Math.max(maxActiveDataPro, activeDataPro); + await new Promise((resolve) => setTimeout(resolve, 5)); + activeDataPro -= 1; + return { + ok: true, + summary: "公司名称:测试科技有限公司;经营范围:企业软件与知识库产品。", + }; + }, + }, + webSearchProvider: { + isRunEnabled: () => true, + async search(input) { + activeWeb += 1; + maxActiveWeb = Math.max(maxActiveWeb, activeWeb); + await new Promise((resolve) => setTimeout(resolve, 5)); + activeWeb -= 1; + return { + ok: true, + results: [{ + title: "测试科技有限公司发布知识库项目公告", + summary: "测试科技有限公司于2026年7月发布知识库项目公告,并披露产品交付安排。", + url: `https://official.test/${encodeURIComponent(input.query)}`, + publish_time: "2026-07-25T09:00:00.000Z", + }], + }; + }, + }, + }); + + await service.collectDossierEvidence(service.data.companies.company_1); + + assert.equal(maxActiveDataPro, 2); + assert.equal(maxActiveWeb, 3); + assert.ok(maxActiveDataPro <= 2); + assert.ok(maxActiveWeb <= 3); +}); + +test("dossier evidence collection follows coverage gaps with bounded topic queries", async () => { + const webQueries = []; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + dataProProvider: { + maxSources: 1, + isRunEnabled: () => true, + planDossierQueries: () => [{ + label: "企业工商数据库", + purpose: "主体信息核验", + query: "测试科技有限公司 企业工商数据", + }], + async callTool() { + return { + ok: true, + summary: "公司名称:测试科技有限公司;经营范围:企业软件与知识库产品。", + raw_ref: "datapro:company", + }; + }, + }, + webSearchProvider: { + isRunEnabled: () => true, + async search(input) { + webQueries.push(input.query); + if (input.query.includes("官方公告 项目 合作 投资")) { + return { + ok: true, + results: [{ + title: "测试科技有限公司发布知识库项目合作公告", + summary: "测试科技有限公司于2026年7月发布知识库项目合作公告,并推进产品交付。", + url: "https://official.test/project", + publish_time: "2026-07-25T09:00:00.000Z", + }], + }; + } + if (input.query.includes("监管 处罚 诉讼 召回 经营异常")) { + return { + ok: true, + results: [{ + title: "测试科技有限公司行政处罚整改公告", + summary: "测试科技有限公司于2026年7月披露行政处罚整改进展,相关事项已进入整改阶段。", + url: "https://regulator.test/risk", + publish_time: "2026-07-24T09:00:00.000Z", + }], + }; + } + return { + ok: true, + results: [{ + title: "测试科技有限公司企业介绍", + summary: "测试科技有限公司提供企业软件、知识库和内容检索产品与服务。", + url: "https://profile.test/company", + publish_time: "2026-07-20T09:00:00.000Z", + }], + }; + }, + }, + }); + + const company = service.data.companies.company_1; + const collected = await service.collectDossierEvidence(company); + const coverage = assessDossierEvidenceCoverage(company, collected); + + assert.ok(webQueries.length > 5); + assert.ok(webQueries.length <= 9); + assert.ok(webQueries.some((query) => query.includes("官方公告 项目 合作 投资"))); + assert.ok(webQueries.some((query) => query.includes("监管 处罚 诉讼 召回 经营异常"))); + assert.equal(coverage.recent_public, true); + assert.equal(coverage.operations, true); + assert.equal(coverage.risk, true); +}); + +test("dossier evidence collection searches a scoped brand alias for China investment companies", async () => { + const webQueries = []; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + dataProProvider: { isRunEnabled: () => false }, + webSearchProvider: { + isRunEnabled: () => true, + async search(input) { + webQueries.push(input.query); + return { ok: true, results: [] }; + }, + }, + }); + + await service.collectDossierEvidence({ + id: "company_bosch", + name: "博世(中国)投资有限公司", + aliases: [], + }); + + assert.ok(webQueries.some((query) => /^博世 2026/.test(query))); + assert.ok(webQueries.some((query) => /^博世(中国)投资有限公司 2026/.test(query))); +}); + +test("dossier evidence collection follows a discovered authoritative host when no usable recent event exists", async () => { + const webQueries = []; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + dataProProvider: { isRunEnabled: () => false }, + webSearchProvider: { + isRunEnabled: () => true, + async search(input) { + webQueries.push(structuredClone(input)); + if (/^site:bosch\.com\.cn/.test(input.query)) { + return { + ok: true, + results: [ + { + title: "博世中国与合作伙伴签署智能驾驶战略合作协议", + summary: "博世中国宣布与合作伙伴签署智能驾驶战略合作协议,双方将推进面向中国市场的量产应用。", + url: "https://bosch.com.cn/news-and-stories/strategic-cooperation/", + site_name: "博世", + auth_level: 2, + publish_time: "2026-07-20T09:00:00.000Z", + }, + { + title: "博世中国披露智能制造项目进展", + summary: "博世中国披露智能制造项目进展,项目将加强本地研发、生产和供应链协同能力。", + url: "https://bosch.com.cn/news-and-stories/manufacturing-project/", + site_name: "博世", + auth_level: 2, + publish_time: "2026-07-21T09:00:00.000Z", + }, + ], + }; + } + if (/^博世 2026/.test(input.query)) { + return { + ok: true, + results: [{ + title: "博世在中国", + summary: "博世在中国持续提供汽车技术、工业技术与消费品相关产品和服务。", + url: "https://bosch.com.cn/our-company/bosch-in-china/", + site_name: "博世", + auth_level: 2, + }], + }; + } + return { ok: true, results: [] }; + }, + }, + }); + + const collected = await service.collectDossierEvidence({ + id: "company_bosch", + name: "博世(中国)投资有限公司", + aliases: [], + }); + + assert.ok(webQueries.every((input) => input.auth_level === 1)); + assert.ok(webQueries.some((input) => /^site:bosch\.com\.cn/.test(input.query))); + assert.ok(collected.public_sources.some((source) => /智能驾驶战略合作/.test(source.label))); + assert.ok(collected.public_sources.some((source) => /智能制造项目进展/.test(source.label))); +}); + +test("brand-scoped evidence must not be written as a confirmed legal-entity event", () => { + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + }); + const company = { + id: "company_bosch", + name: "博世(中国)投资有限公司", + aliases: ["博世"], + }; + const citations = [ + { + id: "professional_bosch", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "公司名称:博世(中国)投资有限公司;经营范围:机械制造、电子和信息产业投资。", + entity_match: "verified", + }, + { + id: "public_bosch", + label: "博世与合作伙伴签署智能驾驶战略合作协议", + source_kind: "联网搜索", + summary: "博世与合作伙伴签署智能驾驶战略合作协议,双方将推进面向中国市场的量产应用。", + url: "https://bosch.com.cn/news-and-stories/strategic-cooperation/", + entity_match: "alias_scoped", + }, + ]; + const body = [ + { text: "企业与业务概览:博世(中国)投资有限公司从事机械制造、电子和信息产业相关投资与业务。", citation_ids: ["professional_bosch"] }, + { text: "经营与业务动态:该法定主体的专业资料显示其业务范围覆盖机械制造、电子和信息产业投资。", citation_ids: ["professional_bosch"] }, + { text: "近期公开动态:博世(中国)投资有限公司与合作伙伴签署智能驾驶战略合作协议。", citation_ids: ["public_bosch"] }, + { text: "风险与关注事项:商务推进前应核验具体签约主体、项目责任边界和量产安排。", citation_ids: ["professional_bosch"] }, + { text: "销售机会判断:智能驾驶合作形成技术与量产协同的沟通窗口,但不代表目标企业已经形成采购意向。", citation_ids: ["professional_bosch", "public_bosch"] }, + { text: "建议行动:1. 核验签约主体和项目阶段。\n2. 联系业务与采购负责人。\n3. 准备量产协同方案。", citation_ids: ["professional_bosch", "public_bosch"] }, + ]; + + const errors = service.publicDossierQualityErrors({ body, citations }, company); + assert.ok(errors.some((item) => item.includes("主体边界"))); + + const corrected = structuredClone(body); + corrected[2].text = "近期公开动态:博世集团相关业务与合作伙伴签署智能驾驶战略合作协议,具体法定签约主体仍需核验。"; + assert.deepEqual(service.publicDossierQualityErrors({ body: corrected, citations }, company), []); +}); + +test("dossier readability gate rejects a search-title fragment but accepts concise complete facts", () => { + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + }); + const company = service.data.companies.company_1; + const citations = [ + { + id: "professional_1", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "测试科技有限公司主营企业软件和知识库产品。", + }, + { + id: "public_1", + label: "测试科技有限公司项目中标公告", + source_kind: "联网搜索", + summary: "测试科技有限公司于2026年7月中标某企业知识库建设项目。", + url: "https://official.test/win", + published_at: "2026-07-22T09:00:00.000Z", + }, + ]; + const body = [ + { text: "企业与业务概览:测试科技有限公司主营企业软件和知识库产品。", citation_ids: ["professional_1"] }, + { text: "经营与业务动态:该企业持续经营知识库建设和内容检索业务。", citation_ids: ["professional_1"] }, + { text: "近期公开动态:测试科技有限公司-最新中标结果发布。", citation_ids: ["public_1"] }, + { text: "风险与关注事项:项目交付需确认数据权限和验收范围。", citation_ids: ["professional_1", "public_1"] }, + { text: "销售机会判断:该项目为知识库交付形成了沟通窗口。", citation_ids: ["professional_1", "public_1"] }, + { text: "建议行动:1. 联系项目负责人。\n2. 核验交付范围。\n3. 准备验收方案。", citation_ids: ["professional_1", "public_1"] }, + ]; + + const rejected = service.publicDossierQualityErrors({ body, citations }, company); + assert.ok(rejected.some((item) => item.includes("搜索标题或事件标题残片"))); + + const corrected = structuredClone(body); + corrected[2].text = "近期公开动态:测试科技有限公司于2026年7月中标企业知识库建设项目。"; + assert.deepEqual(service.publicDossierQualityErrors({ body: corrected, citations }, company), []); + + const riskStatement = structuredClone(corrected); + riskStatement[3].text = "风险与关注事项:项目推进前需要确认企业是否具备相应的数据权限和交付条件。"; + assert.deepEqual(service.publicDossierQualityErrors({ body: riskStatement, citations }, company), []); + + const directQuestion = structuredClone(corrected); + directQuestion[3].text = "风险与关注事项:该企业是否具备相应的数据权限和交付条件?"; + assert.ok( + service.publicDossierQualityErrors({ body: directQuestion, citations }, company) + .some((item) => item.includes("问句")), + ); +}); + +test("dossier Agent revises an invalid six-section plan before deterministic compilation", async () => { + const modelCalls = []; + const modelProvider = { + isRunEnabled: () => true, + async callRequiredFunction(input) { + modelCalls.push(structuredClone(input)); + if (input.operation === "sales_dossier_agent_plan") { + const invalid = stagedDossierPlan(input); + invalid.sections.company_overview.text = "企业产品更新公告"; + return { + ok: true, + parsed: invalid, + raw_ref: "model:dossier-invalid-plan", + }; + } + return { + ok: true, + parsed: stagedDossierPlan(input), + raw_ref: "model:dossier-repaired", + }; + }, + }; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + modelProvider, + }); + const evidencePack = { + evidence_hash: "evidence-pack-test", + items: [ + { + id: "evidence_professional", + label: "企业工商数据库", + source_kind_label: "专业数据集", + summary: "测试科技有限公司面向企业客户提供软件产品。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-company", + }, + { + id: "evidence_risk", + label: "企业风险数据库", + source_kind_label: "专业数据集", + summary: "本次查询未发现可直接下结论的重大风险记录,仍需核验来源日期。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-risk", + }, + { + id: "evidence_professional_2", + label: "金融数据库", + source_kind_label: "专业数据集", + summary: "测试科技有限公司持续经营企业软件和知识库产品相关业务,并推进内容检索能力升级。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-market", + }, + { + id: "evidence_public", + label: "企业产品更新公告", + source_kind_label: "联网搜索", + summary: "测试科技有限公司近期发布了产品更新公告。", + provider: "web_search", + url: "https://news.test/company-update", + quality_tier: 2, + independence_key: "news.test", + }, + { + id: "evidence_public_2", + label: "企业交付计划公告", + source_kind_label: "联网搜索", + summary: "测试科技有限公司近期披露产品交付计划,明确将分阶段推进知识库协作能力上线。", + provider: "web_search", + url: "https://official.test/company-delivery", + quality_tier: 2, + independence_key: "official.test", + }, + { + id: "evidence_internal", + label: "客户需求确认会", + source_kind_label: "内部资料", + summary: "客户希望先验证知识库问答,并要求明确数据权限边界。", + provider: "openviking", + uri: "viking://resources/workspaces/test/companies/company_1/materials/material_1", + quality_tier: 2, + independence_key: "internal-material-1", + }, + ], + }; + + const dossier = await service.generateDossierWithModel( + service.data.companies.company_1, + evidencePack, + [], + ); + + assert.equal(modelCalls.length, 2); + assert.equal(modelCalls[0].operation, "sales_dossier_agent_plan"); + assert.equal(modelCalls[0].payload.allowed_citation_ids, undefined); + assert.equal(modelCalls[0].payload.citations, undefined); + assert.ok(modelCalls[0].payload.evidence_by_section); + assert.equal( + JSON.stringify(modelCalls[0].payload.evidence_by_section).includes("evidence_internal"), + false, + ); + assert.equal(modelCalls[0].functionName, "plan_sales_dossier"); + assert.match(modelCalls[0].system, /最终引用全部由服务端根据 Evidence Atom 确定性派生/); + assert.match( + modelCalls[0].system, + /企业与业务概览用于交代主体、主营方向、业务定位和来源能够直接支持的业务应用场景.*不得在本章写采购场景、采购需求、采购计划或采购意向/u, + ); + assert.equal(modelCalls[0].parameters.properties.sections.required.length, 6); + assert.deepEqual( + Object.keys( + modelCalls[0].parameters.properties.sections.properties.company_overview.properties, + ), + ["text", "evidence_ids"], + ); + assert.equal(modelCalls[1].operation, "sales_dossier_agent_replan"); + assert.ok(modelCalls[1].payload.planning_errors.some((item) => item.includes("完整句子"))); + assert.doesNotMatch(JSON.stringify(dossier), /关键字段存在来源差异|来源冲突/); + assert.equal(dossier.body.length, 6); + assert.deepEqual(dossier.body[0].citation_ids, ["evidence_professional"]); + assert.deepEqual(dossier.body[3].citation_ids, ["evidence_public_2"]); + assert.equal(dossier.raw_ref, "model:dossier-repaired"); +}); + +test("dossier Agent retries one incomplete planning response", async () => { + const modelCalls = []; + const modelProvider = { + isRunEnabled: () => true, + async callRequiredFunction(input) { + modelCalls.push(structuredClone(input)); + if (modelCalls.length === 1) { + return { + ok: false, + error: { + code: "incomplete_response", + message: "The function response reached its output budget.", + retryable: true, + }, + raw_ref: "model:dossier-incomplete", + }; + } + return { + ok: true, + parsed: stagedDossierPlan(input), + usage: { prompt_tokens: 160, completion_tokens: 80, total_tokens: 240 }, + raw_ref: "model:dossier-retry", + }; + }, + }; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + modelProvider, + }); + const evidencePack = { + evidence_hash: "evidence-pack-json-retry", + items: [ + { + id: "evidence_professional", + label: "企业工商数据库", + source_kind_label: "专业数据集", + summary: "测试科技有限公司面向企业客户提供软件产品。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-company", + }, + { + id: "evidence_professional_2", + label: "金融数据库", + source_kind_label: "专业数据集", + summary: "测试科技有限公司持续经营企业软件与知识库产品相关业务。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-market", + }, + { + id: "evidence_public", + label: "企业产品更新公告", + source_kind_label: "联网搜索", + summary: "测试科技有限公司近期发布了产品更新公告。", + provider: "web_search", + url: "https://news.test/company-update", + quality_tier: 2, + independence_key: "news.test", + }, + { + id: "evidence_public_2", + label: "企业交付计划公告", + source_kind_label: "联网搜索", + summary: "测试科技有限公司近期披露产品交付计划,明确分阶段推进知识库能力上线。", + provider: "web_search", + url: "https://official.test/company-delivery", + quality_tier: 2, + independence_key: "official.test", + }, + ], + }; + + const dossier = await service.generateDossierWithModel( + service.data.companies.company_1, + evidencePack, + [], + ); + + assert.equal(modelCalls.length, 2); + assert.equal(modelCalls[0].operation, "sales_dossier_agent_plan"); + assert.equal(modelCalls[0].maxTokens, 2400); + assert.equal(modelCalls[1].operation, "sales_dossier_agent_plan"); + assert.equal(modelCalls[1].maxTokens, 2400); + assert.equal(dossier.body.length, 6); + assert.equal(dossier.raw_ref, "model:dossier-retry"); +}); + +test("dossier Agent fails closed after three incomplete planning responses", async () => { + const modelCalls = []; + const modelProvider = { + isRunEnabled: () => true, + async callRequiredFunction(input) { + modelCalls.push(structuredClone(input)); + return { + ok: false, + error: { + code: "incomplete_response", + message: "Model returned an incomplete function call.", + retryable: true, + }, + raw_ref: `model:incomplete-function-${modelCalls.length}`, + }; + }, + }; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + modelProvider, + }); + const evidencePack = { + evidence_hash: "evidence-pack-json-reconstruction", + items: [ + { + id: "evidence_company", + label: "企业工商数据库", + source_kind_label: "专业数据集", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件、知识库与内容检索产品。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-company", + }, + { + id: "evidence_market", + label: "科研学术数据搜索服务", + source_kind_label: "专业数据集", + summary: "测试科技有限公司持续开展企业知识库、内容检索和智能协作相关技术研发。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-research", + }, + { + id: "evidence_product_update", + label: "测试科技有限公司产品升级公告", + source_kind_label: "联网搜索", + summary: "测试科技有限公司于2026年7月发布销售知识库产品升级公告,新增内容检索和协作管理能力。", + provider: "web_search", + url: "https://news.test/product-update", + published_at: "2026-07-20T09:00:00.000Z", + quality_tier: 2, + independence_key: "news.test", + }, + { + id: "evidence_procurement", + label: "测试科技有限公司项目采购结果公告", + source_kind_label: "联网搜索", + summary: "公开采购结果显示测试科技有限公司参与企业知识库建设项目,项目范围包括内容治理与检索能力交付。", + provider: "web_search", + url: "https://procurement.test/project-result", + published_at: "2026-07-21T09:00:00.000Z", + quality_tier: 2, + independence_key: "procurement.test", + }, + ], + }; + + await assert.rejects( + () => service.generateDossierWithModel( + service.data.companies.company_1, + evidencePack, + [], + ), + (error) => ( + error.status === 503 + && error.code === "model_unavailable" + && error.details.reason === "incomplete_response" + ), + ); + + assert.equal(modelCalls.length, 3); + assert.equal(modelCalls[0].operation, "sales_dossier_agent_plan"); + assert.equal(modelCalls[1].operation, "sales_dossier_agent_plan"); + assert.equal(modelCalls[2].operation, "sales_dossier_agent_plan"); +}); + +test("QA derives paragraph citations from allowed evidence and records model usage", async () => { + const fixture = createWorkflowService(); + await fixture.service.createDossier("company_1"); + const result = await fixture.service.askQuestion("company_1", { question: "客户最关注什么,下一步怎么推进?" }); + + assert.ok(result.job_id); + assert.ok(result.provider_run_id); + assert.equal(result.message.paragraphs.length, 2); + assert.equal(result.message.citations.length, 2); + assert.ok(result.message.paragraphs.every((paragraph) => paragraph.citation_ids.length > 0)); + assert.ok(result.message.citation_ids.every((id) => result.message.citations.some((citation) => citation.id === id))); + const qaCall = fixture.modelCalls.find((call) => call.operation === "sales_qa"); + assert.deepEqual( + [...new Set(qaCall.payload.evidence.map((item) => item.source_kind))].sort(), + ["企业档案", "云文档"].sort(), + ); + const materialEvidence = qaCall.payload.evidence.find((item) => item.source_kind === "云文档"); + assert.equal(materialEvidence.label, "客户需求确认会"); + assert.doesNotMatch(JSON.stringify(qaCall.payload.evidence), /overview|company_dp_should_not_be_visible/i); + assert.match(qaCall.system, /正式展示标题/); + assert.match(qaCall.system, /不得输出 evidence\.uri/); + assert.match(qaCall.system, /不得自行增加“补充”/); + + const run = await fixture.service.getProviderRun(result.provider_run_id); + const modelStep = run.steps.find((step) => step.provider === "model"); + assert.equal(run.job_id, result.job_id); + assert.equal(modelStep.usage.total_tokens, 180); + assert.equal((await fixture.service.getJob(result.job_id)).status, "succeeded"); +}); + +test("QA hydrates the full OpenViking resource before chunking and reranking", async () => { + const fixture = createWorkflowService(); + fixture.service.openVikingProvider.readTextResource = async () => ({ + ok: true, + content: [ + `${"一般会议背景。".repeat(180)}\n\n预算窗口:客户计划在第四季度确认预算,首批试点覆盖两个业务部门。`, + "", + ].join("\n"), + }); + await fixture.service.createDossier("company_1"); + await fixture.service.askQuestion("company_1", { question: "客户的预算窗口和试点范围是什么?" }); + + const qaCall = fixture.modelCalls.find((call) => call.operation === "sales_qa"); + assert.ok(qaCall.payload.evidence.some((item) => ( + item.source_kind === "云文档" + && item.summary.includes("第四季度确认预算") + && item.summary.includes("两个业务部门") + ))); + assert.doesNotMatch(JSON.stringify(qaCall.payload.evidence), /sales-workbench-material-v1|cHJpdmF0ZS1zeW5jLXNuYXBzaG90/); + assert.ok(qaCall.payload.retrieval_plan.answerability.supported); +}); + +test("QA sends bounded prior turns to the model for follow-up questions", async () => { + const fixture = createWorkflowService(); + await fixture.service.createDossier("company_1"); + await fixture.service.askQuestion("company_1", { question: "客户最关注什么?" }); + await fixture.service.askQuestion("company_1", { question: "那下一步怎么推进?" }); + + const qaCalls = fixture.modelCalls.filter((call) => call.operation === "sales_qa"); + assert.equal(qaCalls.length, 2); + assert.deepEqual( + qaCalls[0].payload.conversation_history, + [], + ); + assert.equal(qaCalls[1].payload.question, "那下一步怎么推进?"); + assert.equal(qaCalls[1].payload.conversation_history.length, 2); + assert.equal(qaCalls[1].payload.conversation_history[0].role, "user"); + assert.equal(qaCalls[1].payload.conversation_history[0].text, "客户最关注什么?"); + assert.equal(qaCalls[1].payload.conversation_history[1].role, "assistant"); + assert.match(qaCalls[1].payload.conversation_history[1].text, /知识库产品|数据权限边界/); + assert.equal( + qaCalls[1].payload.conversation_history.some((message) => message.text === "那下一步怎么推进?"), + false, + ); +}); + +test("QA restores recent turns and citations from OpenViking after a process restart", async () => { + const sharedSessionMessages = new Map(); + const firstRuntime = createWorkflowService({ sharedSessionMessages }); + await firstRuntime.service.createDossier("company_1"); + await firstRuntime.service.askQuestion("company_1", { question: "客户最关注什么?" }); + + const persistedSeed = structuredClone(firstRuntime.service.data); + persistedSeed.qa_messages = { company_1: [] }; + const restartedRuntime = createWorkflowService({ + sharedSessionMessages, + seedData: persistedSeed, + }); + + const restored = await restartedRuntime.service.getQa("company_1"); + assert.equal(restored.messages.length, 2); + assert.equal(restored.messages[0].role, "user"); + assert.equal(restored.messages[0].text, "客户最关注什么?"); + assert.equal(restored.messages[1].role, "assistant"); + assert.equal(restored.messages[1].citations.length, 2); + + await restartedRuntime.service.askQuestion("company_1", { question: "那下一步怎么推进?" }); + const qaCall = restartedRuntime.modelCalls.find((call) => call.operation === "sales_qa"); + assert.equal(qaCall.payload.conversation_history.length, 2); + assert.equal(qaCall.payload.conversation_history[0].text, "客户最关注什么?"); + assert.match(qaCall.payload.conversation_history[1].text, /知识库产品|数据权限边界/); +}); + +test("QA hides legacy dossier-memory answers and excludes them from follow-up context", async () => { + const fixture = createWorkflowService(); + fixture.service.data.qa_messages.company_1.push( + { + id: "qa_user_legacy", + role: "user", + text: "旧问题", + created_at: "2026-07-19T08:00:00.000Z", + }, + { + id: "qa_assistant_legacy", + role: "assistant", + text: "旧版回答", + citations: [{ + id: "legacy_dossier_memory", + source_kind: "内部资料", + label: "旧档案记忆", + uri: "viking://resources/workspaces/test/companies/company_1/dossiers/legacy.md", + }], + citation_ids: ["legacy_dossier_memory"], + created_at: "2026-07-19T08:01:00.000Z", + }, + ); + + assert.deepEqual((await fixture.service.getQa("company_1")).messages, []); + + await fixture.service.createDossier("company_1"); + await fixture.service.askQuestion("company_1", { question: "当前重点是什么?" }); + const qaCall = fixture.modelCalls.find((call) => call.operation === "sales_qa"); + assert.deepEqual(qaCall.payload.conversation_history, []); + assert.equal((await fixture.service.getQa("company_1")).messages.length, 2); +}); + +test("business responses do not expose OpenViking or provider raw references", async () => { + const fixture = createWorkflowService(); + const dossier = await fixture.service.createDossier("company_1"); + const qa = await fixture.service.askQuestion("company_1", { question: "客户最关注什么?" }); + const materials = fixture.service.listMaterials("company_1"); + + const publicPayload = JSON.stringify({ dossier: dossier.detail, qa: qa.message, materials }); + assert.doesNotMatch(publicPayload, /viking:\/\//i); + assert.doesNotMatch(publicPayload, /model:/i); + assert.equal(Object.hasOwn(materials[0], "openviking_uri"), false); + assert.equal(materials[0].memory_ready, true); + assert.equal(Object.hasOwn(dossier.detail, "raw_ref"), false); + assert.equal(Object.hasOwn(dossier.detail, "evidence_pack"), false); + assert.equal(Object.hasOwn(dossier.detail, "provider_run_id"), false); + assert.equal(Object.hasOwn(dossier.detail, "memory_summary"), false); +}); + +test("QA public view hides legacy internal paths and resource identifiers", () => { + const fixture = createWorkflowService(); + const publicMessage = fixture.service.publicQaMessage({ + id: "qa_internal_leak", + role: "assistant", + text: "资料位于 company_dp_1234567890 的 /materials/private 目录。", + paragraphs: [{ + text: "OpenViking URI 是 viking://resources/private/materials/one。", + citation_ids: [], + }], + citations: [], + citation_ids: [], + }); + + assert.match(publicMessage.text, /已隐藏/); + assert.match(publicMessage.paragraphs[0].text, /已隐藏/); + assert.doesNotMatch(JSON.stringify(publicMessage), /company_dp_|\/materials\/|viking:\/\//i); +}); + +test("QA public view merges retrieval chunks from the same Feishu material", () => { + const fixture = createWorkflowService(); + const publicMessage = fixture.service.publicQaMessage({ + id: "qa_duplicate_material_chunks", + role: "assistant", + text: "会议纪要显示当前仍处于方案验证阶段。", + paragraphs: [ + { + text: "客户首先关注数据权限边界。", + citation_ids: ["chunk_1", "chunk_2"], + }, + { + text: "下一步需要确认试点范围和负责人。", + citation_ids: ["chunk_3", "chunk_4"], + }, + ], + citations: [1, 2, 3, 4].map((index) => ({ + id: `chunk_${index}`, + material_id: "material_1", + source_kind: "飞书云文档", + label: "客户需求确认会", + uri: `viking://resources/workspace-test/companies/company_1/materials/material_1/chunks/${index}`, + })), + citation_ids: ["chunk_1", "chunk_2", "chunk_3", "chunk_4"], + }); + + assert.equal(publicMessage.citations.length, 1); + assert.deepEqual(publicMessage.citation_ids, ["1"]); + assert.deepEqual(publicMessage.paragraphs[0].citation_ids, ["1"]); + assert.deepEqual(publicMessage.paragraphs[1].citation_ids, ["1"]); + assert.equal(publicMessage.citations[0].label, "客户需求确认会"); +}); + +test("QA public view keeps different dossier sections as separate verifiable citations", () => { + const fixture = createWorkflowService(); + const publicMessage = fixture.service.publicQaMessage({ + id: "qa_dossier_sections", + role: "assistant", + text: "近期动态与风险分别有对应档案章节。", + paragraphs: [{ + text: "近期动态如下。", + citation_ids: ["recent_section"], + }, { + text: "风险与关注事项如下。", + citation_ids: ["risk_section"], + }], + citations: [{ + id: "recent_section", + source_kind: "企业档案", + label: "测试企业 销售情报报告 V2 · 近期公开动态", + }, { + id: "risk_section", + source_kind: "企业档案", + label: "测试企业 销售情报报告 V2 · 风险与关注事项", + }], + citation_ids: ["recent_section", "risk_section"], + }); + + assert.equal(publicMessage.citations.length, 2); + assert.deepEqual(publicMessage.paragraphs[0].citation_ids, ["1"]); + assert.deepEqual(publicMessage.paragraphs[1].citation_ids, ["2"]); + assert.match(publicMessage.citations[0].label, /近期公开动态/u); + assert.match(publicMessage.citations[1].label, /风险与关注事项/u); +}); + +test("QA removes legacy answers that only cite generic internal materials", async () => { + const fixture = createWorkflowService(); + fixture.service.data.qa_messages.company_1.push( + { + id: "qa_user_generic_internal", + role: "user", + text: "旧版资料标题是什么?", + created_at: "2026-07-19T09:00:00.000Z", + }, + { + id: "qa_assistant_generic_internal", + role: "assistant", + text: "这是旧版本根据正文推测出的标题。", + citations: [{ + id: "material_1", + source_kind: "内部资料", + label: "内部资料", + uri: "viking://resources/workspace-test/companies/company_1/materials/material_1.md", + }], + citation_ids: ["material_1"], + created_at: "2026-07-19T09:01:00.000Z", + }, + ); + + assert.deepEqual((await fixture.service.getQa("company_1")).messages, []); + + await fixture.service.createDossier("company_1"); + await fixture.service.askQuestion("company_1", { question: "请使用正式标题回答。" }); + const qaCall = fixture.modelCalls.find((call) => call.operation === "sales_qa"); + assert.deepEqual(qaCall.payload.conversation_history, []); +}); + +test("legacy four-section dossiers are hidden instead of being synthesized into a formal report", () => { + const fixture = createWorkflowService(); + const publicDossier = fixture.service.publicDossier({ + id: "legacy_dossier_1", + company_id: "company_1", + title: "测试科技有限公司最近档案", + summary: "企业资料已更新。", + version_no: 1, + body: [ + { + text: "企业情况:企业ID(关联主键):254716 | 企业ID(关联主键):58059066。", + citation_ids: ["professional_1"], + }, + { + text: "近期动态:企业近期发布产品更新公告。", + citation_ids: ["public_1"], + }, + { + text: "销售判断:可继续跟进。", + citation_ids: ["professional_1", "public_1"], + }, + { + text: "下一步建议:确认业务场景。", + citation_ids: ["public_1"], + }, + ], + citations: [ + { + id: "professional_1", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司;经营范围:企业软件与知识库产品。", + conflict_fields: ["registered_capital"], + }, + { + id: "public_1", + label: "测试科技有限公司产品更新公告", + source_kind: "联网搜索", + summary: "测试科技有限公司近期发布产品更新公告。", + url: "https://news.test/company-update", + }, + ], + }); + + assert.equal(publicDossier.title, "测试科技有限公司 销售情报报告"); + assert.deepEqual(publicDossier.body, []); + assert.deepEqual(publicDossier.citations, []); + assert.equal(publicDossier.summary, ""); + assert.doesNotMatch(JSON.stringify(publicDossier), /企业ID|关联主键|内部资料|OpenViking/i); + assert.doesNotMatch(JSON.stringify(publicDossier), /关键字段存在来源差异|conflict_label/i); +}); + +test("public dossiers with retrieval diagnostics are rejected instead of template-rewritten", () => { + const fixture = createWorkflowService(); + const publicDossier = fixture.service.publicDossier({ + id: "diagnostic_dossier_1", + company_id: "company_1", + title: "测试科技有限公司销售情报报告", + summary: "企业近期发布了产品更新公告。", + body: [ + { text: "企业与业务概览:该企业经营范围包括企业软件、知识库建设和内容检索服务。", citation_ids: ["professional_1"] }, + { text: "经营与业务动态:本次未检索到可核验的经营变化,专业数据仅覆盖工商注册记录。", citation_ids: ["professional_1", "public_1"] }, + { text: "近期公开动态:企业于2026年7月发布产品更新公告,新增销售知识库协作功能。", citation_ids: ["public_1"] }, + { text: "风险与关注事项:资料缺口包括供应链交付明细,多个公开来源的经营数字口径冲突,因此不作为确定事实。", citation_ids: ["professional_1", "risk_public_1"] }, + { text: "销售机会判断:产品更新为销售知识库问答和协作检索试点提供了明确切入场景。", citation_ids: ["professional_1", "public_1"] }, + { text: "建议行动:1. 联系销售运营负责人。\n2. 核实知识库范围。\n3. 准备试点方案。", citation_ids: ["professional_1", "public_1"] }, + ], + citations: [ + { + id: "professional_1", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "测试科技有限公司经营范围包括企业软件、知识库建设和内容检索服务。", + conflict_fields: ["revenue"], + }, + { + id: "public_1", + label: "测试科技有限公司产品更新公告", + source_kind: "联网搜索", + summary: "企业于2026年7月发布产品更新公告,新增销售知识库协作功能。", + url: "https://news.test/company-update", + }, + { + id: "risk_public_1", + label: "测试科技有限公司供应链交付公告", + source_kind: "联网搜索", + summary: "企业公告披露部分核心组件交付周期延长,可能影响重点项目的实施排期。", + url: "https://news.test/company-risk", + }, + ], + }); + + const serialized = JSON.stringify(publicDossier); + assert.equal(publicDossier.body.length, 0); + assert.equal(publicDossier.summary, ""); + assert.equal(publicDossier.citations.length, 0); + assert.doesNotMatch( + serialized, + /本次未检索到|资料缺口|关键字段存在来源差异|来源冲突|口径冲突|不作为确定事实|conflict_label/i, + ); +}); + +test("public dossier summary is rebuilt only from visible citation-backed sections", () => { + const { service } = createWorkflowService(); + const longOpportunityDetail = "后续沟通仍需依次确认知识库覆盖范围、数据权限边界、部署方式、接口责任、项目排期、验收标准、运维安排、采购主体、预算审批路径、合同责任、业务牵头部门、技术评审角色、信息安全要求、试点成功标准、扩容触发条件、服务响应边界和最终决策链,在这些事项得到对方明确回复以前,不能把公开产品动作写成已经成立的采购需求、预算计划、签约安排或交付承诺。"; + const trailingOpportunityDetail = "书面确认记录还应覆盖试点负责人、双方沟通节奏、需求变更方式、交付依赖条件和最终验收责任,再据此决定是否继续投入售前资源。"; + const overflowOpportunityDetail = "最终复盘清单需要明确记录已经核验的事实、仍待确认的问题和下一次沟通的负责人。"; + const view = service.publicDossier({ + id: "dossier_grounded_summary", + company_id: "company_1", + title: "测试科技有限公司销售情报报告", + summary: "未经正文和最终引用支撑的合作、诉讼与展会结论。", + version_no: 1, + created_at: "2026-07-25T10:00:00.000Z", + body: [ + { text: "企业与业务概览:测试科技有限公司面向企业客户提供软件与知识库产品。", citation_ids: ["p1"] }, + { text: "经营与业务动态:专业数据反映该企业持续推进内容检索与协作管理能力。", citation_ids: ["p2"] }, + { text: "近期公开动态:测试科技有限公司于2026年7月发布知识库产品升级公告。", citation_ids: ["w1"] }, + { text: "风险与关注事项:项目推进需在商务报价前确认数据权限、合同责任和交付排期。", citation_ids: ["p1", "w2"] }, + { text: `销售机会判断:产品升级形成试点窗口,但不代表企业已经形成采购意向。${longOpportunityDetail}${trailingOpportunityDetail}${overflowOpportunityDetail}`, citation_ids: ["p2", "w1"] }, + { text: "建议行动:1. 联系产品负责人核验范围。\n2. 确认数据权限边界。\n3. 准备试点方案。", citation_ids: ["p1", "w2"] }, + ], + citations: [ + { + id: "p1", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "测试科技有限公司经营范围包括企业软件与知识库产品。", + independence_key: "datapro-business", + }, + { + id: "p2", + label: "金融数据库", + source_kind: "专业数据集", + summary: "测试科技有限公司持续推进内容检索与协作管理相关业务。", + independence_key: "datapro-market", + }, + { + id: "w1", + label: "测试科技有限公司发布知识库产品升级公告", + source_kind: "联网搜索", + summary: "测试科技有限公司持续提供企业软件服务。测试科技有限公司于2026年7月发布知识库产品升级公告。", + url: "https://news.test/company-update", + independence_key: "news.test", + }, + { + id: "w2", + label: "测试科技有限公司披露产品交付安排", + source_kind: "联网搜索", + summary: "测试科技有限公司披露知识库产品的分阶段交付安排。", + url: "https://official.test/company-delivery", + independence_key: "official.test", + }, + ], + }); + + assert.doesNotMatch(view.summary, /未经正文|诉讼|展会/); + assert.match(view.summary, /知识库产品升级公告/); + assert.match(view.summary, /试点窗口/); + assert.ok(view.summary.length <= 300); + assert.match(view.summary, /[。!?]$/u); + assert.doesNotMatch(view.summary, /最终复盘清单/); + assert.equal(view.body.length, 6); + assert.match( + view.citations.find((item) => item.label.includes("知识库产品升级公告"))?.summary || "", + /持续提供企业软件服务.*发布知识库产品升级公告/u, + ); +}); + +test("public dossier keeps bounded source detail needed to verify late evidence spans", () => { + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + }); + const longAction = "销售人员应联系产品负责人,依次确认内容检索场景、知识库覆盖范围、数据权限边界、部署方式、接口责任、试点排期、验收标准、运维安排、采购主体、预算审批路径、合同责任、业务牵头部门、技术评审角色、信息安全要求、试点成功标准、扩容触发条件、服务响应边界、故障升级路径、双方沟通节奏、需求变更方式、交付依赖条件、数据迁移范围、旧系统衔接方案、最终验收责任和最终决策链,再据此准备与已确认范围一致的试点方案。书面确认记录还应覆盖试点负责人、双方沟通节奏、需求变更方式、交付依赖条件和最终验收责任,再决定是否继续投入售前资源。最终复盘清单需要明确记录已经核验的事实、仍待确认的问题、下一次沟通的负责人和对应截止时间。"; + assert.ok(longAction.length > 260); + const claims = [ + "测试科技有限公司的登记经营范围包括企业软件与知识库产品。", + "测试科技有限公司持续开展内容检索能力研发。", + "测试科技有限公司发布知识库产品升级公告。", + "测试科技有限公司的项目交付排期需要持续核验。", + "现有产品升级动作显示可从内容检索场景切入销售沟通。", + longAction, + ]; + const sectionTitles = [ + "企业与业务概览", + "经营与业务动态", + "近期公开动态", + "风险与关注事项", + "销售机会判断", + "建议行动", + ]; + const sourceSummary = [ + "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件、知识库与内容检索产品。", + "业务资料说明。".repeat(140), + ...claims, + ].join(" "); + const body = sectionTitles.map((title, index) => ({ + text: `${title}:${claims[index]}`, + citation_ids: ["long-professional-source"], + segments: [{ + text: claims[index], + citation_ids: ["long-professional-source"], + }], + })); + + const publicView = service.publicDossier({ + id: "dossier-long-professional-source", + company_id: "company_1", + title: "测试科技有限公司 销售情报报告", + summary: claims[2], + body, + citations: [{ + id: "long-professional-source", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: sourceSummary, + entity_match: "verified", + quality_tier: 1, + independence_key: "internal:long-professional-source", + }], + created_at: "2026-08-03T00:00:00.000Z", + }); + + assert.equal(publicView.body.length, 6); + assert.equal(publicView.citations.length, 1); + assert.match(publicView.body[5].text, /信息安全要求.*最终决策链.*试点方案/u); + assert.match(publicView.citations[0].summary, /销售人员应联系产品负责人/); + assert.equal( + Object.prototype.propertyIsEnumerable.call(publicView, "_validation_citations"), + false, + ); + assert.doesNotMatch(JSON.stringify(publicView), /internal:long-professional-source/u); + assert.deepEqual(service.publicDossierQualityErrors( + publicView, + service.data.companies.company_1, + ), []); + + const overreachingBody = body.map((item, index) => (index === 3 ? { + ...item, + text: "风险与关注事项:某个公开项目金额为87.6392万元,说明其订单结构以中小额分散采购为主。", + segments: [{ + text: "某个公开项目金额为87.6392万元,说明其订单结构以中小额分散采购为主。", + citation_ids: ["long-professional-source"], + }], + } : item)); + assert.ok(service.publicDossierQualityErrors({ + ...publicView, + body: overreachingBody, + }, service.data.companies.company_1).includes( + "风险与关注事项不能把个别项目或单条公开信息外推为企业整体结构性结论", + )); +}); + +test("company identity fields stay bound to the exact business registry entity", () => { + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + }); + const company = service.data.companies.company_1; + const sectionTitles = [ + "企业与业务概览", + "经营与业务动态", + "近期公开动态", + "风险与关注事项", + "销售机会判断", + "建议行动", + ]; + const citations = [ + { + id: "target-registry", + label: "企业工商数据库 · 记录 1", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;注册号:110108028740260;注册地址:北京市海淀区测试路4号;成立日期:2020-05-11T08:00:00;经营范围:企业软件与知识库产品。", + }, + { + id: "branch-registry", + label: "企业工商数据库 · 记录 2", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司山东分公司;统一社会信用代码:TEST0002;注册号:370102300099154;注册地址:山东省青岛市测试路168号;成立日期:2021-05-17T08:00:00;经营范围:企业软件服务。", + }, + ]; + const completeBody = [ + "测试科技有限公司成立于2021年5月17日,注册地址为北京市海淀区测试路4号。", + "测试科技有限公司经营企业软件与知识库产品。", + "测试科技有限公司持续提供企业软件服务。", + "测试科技有限公司的项目范围和交付责任需要在商务沟通中核验。", + "测试科技有限公司的企业软件业务可作为销售沟通的应用场景。", + "销售人员应联系业务负责人确认企业软件服务范围。", + ].map((text, index) => ({ + text: `${sectionTitles[index]}:${text}`, + citation_ids: index === 0 ? ["target-registry", "branch-registry"] : ["target-registry"], + segments: [{ + text, + citation_ids: index === 0 ? ["target-registry", "branch-registry"] : ["target-registry"], + }], + })); + + const invalidErrors = service.publicDossierQualityErrors({ + company_id: company.id, + summary: completeBody[2].text, + body: completeBody, + citations, + }, company); + assert.ok(invalidErrors.some((error) => ( + error.includes("日期 2021-05-17") + && error.includes("测试科技有限公司") + && error.includes("对应工商记录不支持该归属") + ))); + + const correctlyScopedBody = structuredClone(completeBody); + correctlyScopedBody[0] = { + text: "企业与业务概览:测试科技有限公司成立于2020年5月11日。测试科技有限公司山东分公司成立于2021年5月17日。", + citation_ids: ["target-registry", "branch-registry"], + segments: [{ + text: "测试科技有限公司成立于2020年5月11日。测试科技有限公司山东分公司成立于2021年5月17日。", + citation_ids: ["target-registry", "branch-registry"], + }], + }; + const scopedErrors = service.publicDossierQualityErrors({ + company_id: company.id, + summary: correctlyScopedBody[2].text, + body: correctlyScopedBody, + citations, + }, company); + assert.equal( + scopedErrors.some((error) => error.includes("对应工商记录不支持该归属")), + false, + JSON.stringify(scopedErrors), + ); + + const uncitedBranchBody = structuredClone(correctlyScopedBody); + uncitedBranchBody[0].citation_ids = ["target-registry"]; + uncitedBranchBody[0].segments[0].citation_ids = ["target-registry"]; + const uncitedBranchErrors = service.publicDossierQualityErrors({ + company_id: company.id, + summary: uncitedBranchBody[2].text, + body: uncitedBranchBody, + citations, + }, company); + assert.ok(uncitedBranchErrors.some((error) => ( + error.includes("测试科技有限公司山东分公司") + && error.includes("没有引用该分支机构自己的工商记录") + ))); + + const trajectoryBody = structuredClone(completeBody); + trajectoryBody[1].text = "经营与业务动态:少量项目显示其业务已从单一软件服务扩展到综合知识库能力供给。"; + trajectoryBody[1].segments[0].text = "少量项目显示其业务已从单一软件服务扩展到综合知识库能力供给。"; + assert.ok(service.publicDossierQualityErrors({ + company_id: company.id, + summary: trajectoryBody[2].text, + body: trajectoryBody, + citations, + }, company).includes("企业概览或经营动态不能把静态经营范围或少量项目外推为业务转型或能力扩展")); + + const inferredExpansionBody = structuredClone(completeBody); + inferredExpansionBody[1].text = "经营与业务动态:公司的业务布局延伸至能源管理和数据中心基础设施。"; + inferredExpansionBody[1].segments[0].text = "公司的业务布局延伸至能源管理和数据中心基础设施。"; + assert.ok(service.publicDossierQualityErrors({ + company_id: company.id, + summary: inferredExpansionBody[2].text, + body: inferredExpansionBody, + citations, + }, company).includes("企业概览或经营动态不能把静态经营范围或少量项目外推为业务转型或能力扩展")); + + const overviewExpansionBody = structuredClone(completeBody); + overviewExpansionBody[0].text = "企业与业务概览:该企业的登记业务布局延伸至知识库产品。"; + overviewExpansionBody[0].segments[0].text = "该企业的登记业务布局延伸至知识库产品。"; + assert.ok(service.publicDossierQualityErrors({ + company_id: company.id, + summary: overviewExpansionBody[2].text, + body: overviewExpansionBody, + citations, + }, company).includes("企业概览或经营动态不能把静态经营范围或少量项目外推为业务转型或能力扩展")); + + const registryPositionBody = structuredClone(completeBody); + registryPositionBody[0] = { + text: "企业与业务概览:登记范围包括企业软件与知识库产品,形成软件与知识管理并行的业务定位。", + citation_ids: ["target-registry"], + segments: [{ + text: "登记范围包括企业软件与知识库产品,形成软件与知识管理并行的业务定位。", + citation_ids: ["target-registry"], + }], + }; + assert.ok(service.publicDossierQualityErrors({ + company_id: company.id, + summary: registryPositionBody[2].text, + body: registryPositionBody, + citations, + }, company).includes("企业与业务概览只能把工商信息表述为登记范围,不能提升为实际主营、制造主体或现实业务定位")); + + const registryOpportunityBody = structuredClone(completeBody); + registryOpportunityBody[4] = { + text: "销售机会判断:该主体同时承担企业软件与知识库产品业务,可从相关场景切入。", + citation_ids: ["target-registry"], + segments: [{ + text: "该主体同时承担企业软件与知识库产品业务,可从相关场景切入。", + citation_ids: ["target-registry"], + }], + }; + assert.ok(service.publicDossierQualityErrors({ + company_id: company.id, + summary: registryOpportunityBody[2].text, + body: registryOpportunityBody, + citations, + }, company).includes("销售机会判断可以把登记范围作为对接方向,但不能写成企业已承担该业务或已具备现实能力")); + + const demandBody = structuredClone(completeBody); + demandBody[2].text = "近期公开动态:近期项目节奏说明其配套采购需求正处于活跃期。"; + demandBody[2].segments[0].text = "近期项目节奏说明其配套采购需求正处于活跃期。"; + assert.ok(service.publicDossierQualityErrors({ + company_id: company.id, + summary: demandBody[2].text, + body: demandBody, + citations, + }, company).includes("近期公开动态不能把中标或公告节奏写成来源未披露的采购需求或采购意向")); +}); + +test("registry rows stay entity-scoped even when a specialized DataPro query mislabels them", () => { + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + }); + const company = service.data.companies.company_1; + const citations = [ + { + id: "target-registry", + label: "企业工商数据库 · 记录 1", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;注册地址:北京市海淀区测试路4号;经营范围:企业软件与知识库产品。", + }, + { + id: "mislabeled-branch-registry", + label: "科研学术数据搜索服务 · 记录 2", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司山东分公司;统一社会信用代码:TEST0002;注册地址:山东省青岛市测试路168号;经营范围:企业软件服务。", + }, + { + id: "public-event", + label: "测试科技有限公司产品升级公告", + source_kind: "联网搜索", + summary: "2026年7月,测试科技有限公司发布企业知识库产品升级公告。", + url: "https://test-company.test/news/product-update", + published_at: "2026-07-20T09:00:00.000Z", + }, + ]; + const body = [ + ["企业与业务概览:测试科技有限公司登记经营范围包括企业软件与知识库产品。", ["target-registry"]], + ["经营与业务动态:测试科技有限公司登记业务覆盖企业软件服务。", ["mislabeled-branch-registry"]], + ["近期公开动态:2026年7月,测试科技有限公司发布企业知识库产品升级公告。", ["public-event"]], + ["风险与关注事项:对接前应核验产品升级的实施范围。", ["public-event"]], + ["销售机会判断:产品升级可作为销售沟通的切入场景,但不代表已经形成采购意向。", ["public-event"]], + ["建议行动:联系产品负责人核验升级范围并准备能力说明材料。", ["public-event"]], + ].map(([text, citationIds]) => ({ + text, + citation_ids: citationIds, + segments: [{ text: text.replace(/^[^:]+:/u, ""), citation_ids: citationIds }], + })); + + const invalidErrors = service.publicDossierQualityErrors({ + company_id: company.id, + summary: body[2].text, + body, + citations, + }, company); + assert.ok(invalidErrors.some((error) => ( + error.includes("经营与业务动态") + && error.includes("测试科技有限公司山东分公司") + && error.includes("其他主体工商记录") + )), JSON.stringify(invalidErrors)); + assert.equal( + invalidErrors.includes("经营与业务动态必须优先引用语义匹配的专业数据库"), + false, + JSON.stringify(invalidErrors), + ); + + const explicitlyScopedBody = structuredClone(body); + explicitlyScopedBody[1].text = "经营与业务动态:测试科技有限公司山东分公司登记经营范围包括企业软件服务。"; + explicitlyScopedBody[1].segments[0].text = "测试科技有限公司山东分公司登记经营范围包括企业软件服务。"; + const scopedErrors = service.publicDossierQualityErrors({ + company_id: company.id, + summary: explicitlyScopedBody[2].text, + body: explicitlyScopedBody, + citations, + }, company); + assert.equal( + scopedErrors.some((error) => error.includes("测试科技有限公司山东分公司")), + false, + JSON.stringify(scopedErrors), + ); + + const overreachingRegistryBody = structuredClone(body); + overreachingRegistryBody[1] = { + text: "经营与业务动态:公司业务动作聚焦于企业软件服务,构成独立产品线,并具备直接开展跨境业务的经营条件。", + citation_ids: ["target-registry"], + segments: [{ + text: "公司业务动作聚焦于企业软件服务,构成独立产品线,并具备直接开展跨境业务的经营条件。", + citation_ids: ["target-registry"], + }], + }; + const overreachingErrors = service.publicDossierQualityErrors({ + company_id: company.id, + summary: overreachingRegistryBody[2].text, + body: overreachingRegistryBody, + citations, + }, company); + assert.ok(overreachingErrors.includes( + "经营与业务动态不能把静态工商登记范围提升为当前业务动作、独立产品线或现实经营能力", + )); +}); + +test("dossier Agent normalizes duplicate registry rows and excludes non-target entities", async () => { + const modelCalls = []; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callRequiredFunction(input) { + modelCalls.push(structuredClone(input)); + return { + ok: false, + error: { code: "test_stop", message: "context captured", retryable: false }, + }; + }, + }, + }); + await service.generateDossierWithModel(service.data.companies.company_1, { + evidence_hash: "mismatched-registry-dataset", + items: [ + { + id: "target-registry", + label: "企业工商数据库 · 记录 1", + source_kind_label: "专业数据集", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件与知识库产品。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-target-registry", + }, + { + id: "mislabeled-target-registry", + label: "科研学术数据搜索服务 · 记录 1", + source_kind_label: "专业数据集", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件与知识库产品。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-mislabeled-target", + }, + { + id: "mislabeled-branch-registry", + label: "科研学术数据搜索服务 · 记录 2", + source_kind_label: "专业数据集", + summary: "公司名称:测试科技有限公司山东分公司;统一社会信用代码:TEST0002;经营范围:企业软件服务。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-mislabeled-branch", + }, + { + id: "business-branch-registry", + label: "企业工商数据库 · 记录 2", + source_kind_label: "专业数据集", + summary: "公司名称:测试科技有限公司南京分公司;统一社会信用代码:TEST0003;经营范围:企业软件服务。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-business-branch", + }, + { + id: "research-evidence", + label: "科研学术数据搜索服务 · 记录 3", + source_kind_label: "专业数据集", + summary: "测试科技有限公司持续开展企业知识库、内容检索和智能协作相关技术研发。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-research", + }, + { + id: "public-event", + label: "测试科技有限公司产品升级公告", + source_kind_label: "联网搜索", + summary: "2026年7月,测试科技有限公司发布企业知识库产品升级公告。", + provider: "web_search", + url: "https://test-company.test/news/product-update", + published_at: "2026-07-20T09:00:00.000Z", + quality_tier: 2, + independence_key: "test-company.test", + }, + ], + }, []); + + assert.equal(modelCalls.length, 1); + const serializedContext = JSON.stringify(modelCalls[0].payload); + assert.doesNotMatch( + serializedContext, + /mislabeled-target-registry|mislabeled-branch-registry|business-branch-registry|测试科技有限公司山东分公司|测试科技有限公司南京分公司/u, + ); + assert.match(serializedContext, /research-evidence|智能协作相关技术研发/u); + assert.equal( + modelCalls[0].payload.source_selection_policy.market_database_ids.includes( + "mislabeled-branch-registry", + ), + false, + ); + assert.deepEqual( + modelCalls[0].payload.source_selection_policy.business_dynamics_ids, + ["research-evidence"], + ); +}); + +test("public dossiers are rejected when a section depends on a discarded placeholder citation", () => { + const fixture = createWorkflowService(); + const publicDossier = fixture.service.publicDossier({ + id: "punctuation_dossier_1", + company_id: "company_1", + title: "测试科技有限公司销售情报报告", + summary: "企业近期发布产品升级公告。", + body: [ + { + text: "企业与业务概览:测试科技有限公司(简称:“测试科技”,TEST.SZ)主营企业软件,面向销售团队提供知识库产品;", + citation_ids: ["professional_main"], + }, + { + text: "经营与业务动态:公司于2026年7月发布产品升级公告,产品使用率达到25%,587Ah 规格已进入交付阶段,相关收入为2,769.17万元;", + citation_ids: ["public_business", "public_untitled"], + }, + { + text: "近期公开动态:公司官网于2026年7月披露合作计划,将推进客户服务场景落地;", + citation_ids: ["public_latest"], + }, + { + text: "风险与关注事项:公开公告提示部分项目交付周期可能延长,需核实实施排期;", + citation_ids: ["public_risk"], + }, + { + text: "销售机会判断:产品升级形成明确切入场景,可优先确认试点部门与预算窗口;", + citation_ids: ["professional_main", "public_business"], + }, + { + text: "建议行动:1. 联系销售运营负责人; 2. 核实试点范围; 3. 准备交付计划;", + citation_ids: ["professional_main", "public_business"], + }, + ], + citations: [ + { + id: "professional_main", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "测试科技有限公司主营企业软件,面向销售团队提供知识库产品。", + }, + { + id: "public_business", + label: "测试科技有限公司产品升级公告", + source_kind: "联网搜索", + summary: "公司于2026年7月发布产品升级公告,产品使用率达到25%,587Ah 规格已进入交付阶段,相关收入为2,769.17万元。", + url: "https://news.test/product-update", + }, + { + id: "public_latest", + label: "测试科技有限公司合作计划", + source_kind: "联网搜索", + summary: "公司官网于2026年7月披露合作计划,将推进客户服务场景落地。", + url: "https://news.test/cooperation", + }, + { + id: "public_risk", + label: "测试科技有限公司项目交付公告", + source_kind: "联网搜索", + summary: "公开公告提示部分项目交付周期可能延长,需核实实施排期。", + url: "https://news.test/delivery", + }, + { + id: "public_untitled", + label: "Untitled", + source_kind: "联网搜索", + summary: "无有效标题的搜索结果。", + url: "https://news.test/untitled", + }, + ], + }); + + assert.deepEqual(publicDossier.body, []); + assert.deepEqual(publicDossier.citations, []); + assert.equal(publicDossier.summary, ""); +}); + +test("strict dossier runtime rejects evidence that cannot anchor the target legal entity", async () => { + let modelCalls = 0; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callRequiredFunction() { + modelCalls += 1; + return { ok: false, error: { code: "should_not_be_called" } }; + }, + }, + }); + + await assert.rejects( + () => service.generateDossierWithModel(service.data.companies.company_1, { + evidence_hash: "bad-public-evidence", + items: [ + { + id: "professional_1", + label: "企业工商数据库", + source_kind_label: "专业数据集", + summary: "该记录描述企业软件与知识库产品,但没有返回可核对的法定名称或统一社会信用代码。", + independence_key: "datapro-business", + }, + { + id: "professional_2", + label: "金融数据库", + source_kind_label: "专业数据集", + summary: "该记录描述内容检索与协作管理业务,但没有返回可核对的法定主体。", + independence_key: "datapro-market", + }, + { + id: "public_1", + label: "测试科技有限公司安全验证", + source_kind_label: "联网搜索", + summary: "请完成人机验证后查看更多相关内容。", + url: "https://blocked.test/verify", + independence_key: "blocked.test", + }, + { + id: "public_2", + label: "测试科技有限公司网站建设案例", + source_kind_label: "联网搜索", + summary: "网站建设服务商展示测试科技有限公司官网改版案例。", + url: "https://agency.test/case", + independence_key: "agency.test", + }, + ], + }, []), + (error) => error.status === 422 + && error.code === "evidence_quality_insufficient" + && error.details.validation_errors.some((item) => item.includes("目标法定主体")), + ); + assert.equal(modelCalls, 0); +}); + +test("public dossiers are rejected when a section cites a removed website-production case study", () => { + const fixture = createWorkflowService(); + const publicDossier = fixture.service.publicDossier({ + id: "source_quality_dossier_1", + company_id: "company_1", + title: "测试科技有限公司销售情报报告", + summary: "测试科技有限公司近期披露客户服务产品合作计划。", + body: [ + { + text: "企业与业务概览:测试科技有限公司主营企业软件与知识库产品。", + citation_ids: ["professional_main"], + }, + { + text: "经营与业务动态:测试科技有限公司持续推进企业软件与客户服务产品。", + citation_ids: ["professional_main"], + }, + { + text: "近期公开动态:经过项目团队数月建设,测试科技有限公司全新品牌官网上线;测试科技有限公司于2026年7月签署客户服务产品合作协议。", + citation_ids: ["website_case", "official_cooperation"], + }, + { + text: "风险与关注事项:商务推进应确认数据合规、合同责任与交付排期。", + citation_ids: ["professional_main"], + }, + { + text: "销售机会判断:客户服务产品合作形成了可继续核验的业务切入点。", + citation_ids: ["professional_main", "official_cooperation"], + }, + { + text: "建议行动:1. 确认合作项目牵头部门。\n2. 核验采购范围与预算窗口。\n3. 准备客户服务产品方案。", + citation_ids: ["professional_main", "official_cooperation"], + }, + ], + citations: [ + { + id: "professional_main", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司;经营范围:企业软件与知识库产品。", + }, + { + id: "website_case", + label: "测试科技有限公司网站建设|企业官网全面焕新", + source_kind: "联网搜索", + summary: "经过项目团队数月建设,测试科技有限公司全新品牌官网上线,这是网站建设服务商的客户案例。", + url: "https://agency.test/cases/test-company", + published_at: "2026-07-18T09:00:00.000Z", + }, + { + id: "generic_homepage", + label: "测试科技有限公司 · TEST", + source_kind: "联网搜索", + summary: "测试科技有限公司面向企业客户提供软件与知识库产品。", + url: "https://test-company.test/", + }, + { + id: "official_product", + label: "测试科技有限公司发布企业知识库产品升级公告", + source_kind: "联网搜索", + summary: "测试科技有限公司于2026年7月发布企业知识库产品升级公告,新增面向销售团队的协作能力。", + url: "https://test-company.test/news/product-update", + published_at: "2026-07-20T09:00:00.000Z", + auth_level: 3, + }, + { + id: "official_cooperation", + label: "测试科技有限公司客户服务产品合作公告", + source_kind: "联网搜索", + summary: "测试科技有限公司于2026年7月签署客户服务产品合作协议,双方将推进知识库产品在客户服务场景落地。", + url: "https://test-company.test/news/cooperation", + published_at: "2026-07-21T09:00:00.000Z", + auth_level: 3, + }, + ], + }); + + const serialized = JSON.stringify(publicDossier); + assert.doesNotMatch(serialized, /网站建设|官网全面焕新|项目团队数月建设|网站建设服务商/); + assert.deepEqual(publicDossier.body, []); + assert.deepEqual(publicDossier.citations, []); + assert.equal(publicDossier.summary, ""); +}); + +test("public dossiers discard strongly sensationalized self-media sources", () => { + const { service } = createWorkflowService(); + const body = [ + "企业与业务概览:测试科技有限公司的登记范围包括企业软件与知识库产品。", + "经营与业务动态:登记信息可作为业务对接范围的核验起点。", + "近期公开动态:自媒体声称测试科技有限公司涉及一项市场事件。", + "风险与关注事项:对接前应确认登记主体、业务范围和责任边界。", + "销售机会判断:登记范围可作为企业软件场景的待确认对接方向。", + "建议行动:联系相关负责人确认业务范围、项目边界和下一步安排。", + ].map((text, index) => ({ + text, + citation_ids: [index === 2 ? "sensational" : "registry"], + })); + const view = service.publicDossier({ + id: "sensational-source-dossier", + company_id: "company_1", + summary: body[2].text, + body, + citations: [ + { + id: "registry", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件与知识库产品。", + }, + { + id: "sensational", + label: "杀人诛心!一句话让对方下不来台", + source_kind: "联网搜索", + summary: "自媒体声称测试科技有限公司涉及一项市场事件。", + url: "https://self-media.test/story", + }, + ], + }); + + assert.deepEqual(view.body, []); + assert.deepEqual(view.citations, []); +}); + +test("public dossiers reject similarly named legal entities without relationship evidence", () => { + const fixture = createWorkflowService(); + const publicDossier = fixture.service.publicDossier({ + id: "similar_entity_dossier_1", + company_id: "company_1", + title: "测试科技有限公司销售情报报告", + summary: "测试科技有限公司近期披露产品升级进展。", + body: [ + { text: "企业与业务概览:测试科技有限公司从事企业软件开发。", citation_ids: ["target_business"] }, + { text: "经营与业务动态:山西测试科技有限公司开展网络建设业务。", citation_ids: ["similar_business"] }, + { text: "近期公开动态:2026年7月,测试科技有限公司披露产品升级进展。", citation_ids: ["public_event"] }, + { text: "风险与关注事项:对接前应核验产品升级的实施范围。", citation_ids: ["public_event"] }, + { text: "销售机会判断:产品升级为技术交流提供切入点,但不代表已有采购意向。", citation_ids: ["public_event"] }, + { text: "建议行动:联系产品负责人核验升级范围并准备能力说明材料。", citation_ids: ["public_event"] }, + ], + citations: [ + { + id: "target_business", + label: "企业工商数据库 · 记录 1", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司;经营范围:企业软件开发。", + }, + { + id: "similar_business", + label: "企业工商数据库 · 记录 2", + source_kind: "专业数据集", + summary: "公司名称:山西测试科技有限公司;经营范围:网络建设。", + }, + { + id: "public_event", + label: "测试科技有限公司产品升级公告", + source_kind: "联网搜索", + summary: "2026年7月,测试科技有限公司披露产品升级进展。", + url: "https://test-company.test/news/upgrade", + published_at: "2026-07-20T09:00:00.000Z", + }, + ], + }); + + assert.deepEqual(publicDossier.body, []); + assert.deepEqual(publicDossier.citations, []); + assert.equal(publicDossier.summary, ""); +}); + +test("public dossiers reject malformed snippets instead of reconstructing them from other sources", () => { + const fixture = createWorkflowService(); + const publicDossier = fixture.service.publicDossier({ + id: "malformed_dossier_1", + company_id: "company_1", + title: "测试科技有限公司销售情报报告", + summary: "企业近期发布产品升级公告。", + body: [ + { text: "企业与业务概览:测试科技有限公司持续经营企业软件业务。", citation_ids: ["professional_main"] }, + { text: "经营与业务动态:测试科技有限公司(简称:“测试科技”,TEST.SZ)发布产品升级公告,将面向销售团队推出知识库协作功能;", citation_ids: ["public_business", "public_untitled"] }, + { text: "近期公开动态:媒体 作者 7月25日 测试科技有限公司(简称“测试科技”。", citation_ids: ["public_business"] }, + { text: "风险与关注事项:公开摘要显示净利润432。", citation_ids: ["public_risk"] }, + { text: "销售机会判断:可围绕企业软件产品升级验证销售知识库场景。", citation_ids: ["professional_main", "public_business"] }, + { text: "建议行动:1. 联系产品负责人。2. 核实试点范围。3. 准备交付计划。", citation_ids: ["professional_main", "public_business"] }, + ], + citations: [ + { + id: "professional_main", + label: "企业工商数据库 · 记录 1", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;法定代表人:张三;注册地址:北京市海淀区;成立日期:2020-01-01。", + }, + { + id: "professional_branch", + label: "企业工商数据库 · 记录 2", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司上海分公司;统一社会信用代码:BRANCH0001;法定代表人:李四;注册地址:上海市徐汇区;成立日期:2023-01-01。", + }, + { + id: "public_business", + label: "测试科技有限公司于2026年7月发布销售知识库产品升级公告_产业观察", + source_kind: "联网搜索", + summary: "媒体 作者 7月25日 测试科技有限公司(简称“测试科技”,立即注册查看更多相关信息。", + url: "https://news.test/product-update", + }, + { + id: "public_risk", + label: "测试科技有限公司核心组件交付延期公告", + source_kind: "联网搜索", + summary: "公司公告披露部分核心组件交付周期延长,可能影响重点项目的实施排期。", + url: "https://news.test/delivery-risk", + }, + { + id: "public_untitled", + label: "Untitled", + source_kind: "联网搜索", + summary: "无有效标题的搜索结果。", + url: "https://news.test/untitled", + }, + ], + }); + + const serialized = JSON.stringify(publicDossier); + assert.doesNotMatch(serialized, /立即注册|查看更多|净利润432|上海分公司|BRANCH0001|Untitled/); + assert.deepEqual(publicDossier.body, []); + assert.deepEqual(publicDossier.citations, []); + assert.equal(publicDossier.summary, ""); +}); + +test("public dossiers reject repeated sections and risks attributed to another company", () => { + const fixture = createWorkflowService(); + fixture.service.data.companies.company_1 = { + ...fixture.service.data.companies.company_1, + name: "宁德时代新能源科技股份有限公司", + aliases: ["宁德时代"], + industry: "新能源", + }; + const repeatedBusinessPoint = "宁德时代于2026年7月披露储能合作和产线建设进展,相关项目处于持续推进阶段。"; + const publicDossier = fixture.service.publicDossier({ + id: "cross_entity_risk_dossier_1", + company_id: "company_1", + title: "宁德时代新能源科技股份有限公司销售情报报告", + summary: "宁德时代近期披露多项储能合作和产线建设进展。", + body: [ + { + text: "企业与业务概览:宁德时代新能源科技股份有限公司主营动力电池、储能电池及相关系统产品。", + citation_ids: ["professional_main"], + }, + { + text: `经营与业务动态:近期公开披露的业务动作包括:${repeatedBusinessPoint}`, + citation_ids: ["public_business"], + }, + { + text: `近期公开动态:${repeatedBusinessPoint}`, + citation_ids: ["public_business", "public_metadata"], + }, + { + text: "风险与关注事项:北京永勤律师事务所律师表示,相关投资者可以请求赔偿。", + citation_ids: ["public_wrong_risk", "professional_main"], + }, + { + text: "销售机会判断:储能合作和产线建设为设备、系统集成和供应链协同提供了跟进场景。", + citation_ids: ["professional_main", "public_business"], + }, + { + text: "建议行动:1. 核验项目阶段。2. 联系采购负责人。3. 准备供应方案。", + citation_ids: ["professional_main", "public_business"], + }, + ], + citations: [ + { + id: "professional_main", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "公司名称:宁德时代新能源科技股份有限公司;统一社会信用代码:TESTCATL001;经营范围:动力电池、储能电池及相关系统产品。", + }, + { + id: "public_business", + label: "宁德时代披露储能合作和产线建设进展", + source_kind: "联网搜索", + summary: repeatedBusinessPoint, + url: "https://news.test/catl-business", + }, + { + id: "public_metadata", + label: "1000Wh时代!宁德时代即将迈入", + source_kind: "联网搜索", + summary: "1000Wh时代!宁德时代即将迈入 2026年06月28日 23:53 市场资讯 (来源:连线新能源 NELinked) 近日,宁德时代发布新一代储能电池产品。", + url: "https://news.test/catl-storage", + }, + { + id: "public_wrong_risk", + label: "1200亿“画饼”宁德时代被罚,容百科技投资者可以索赔了!", + source_kind: "联网搜索", + summary: "文章标题提到宁德时代被罚,但北京永勤律师事务所金融律师表示,实际索赔对象为容百科技部分投资者。", + url: "https://news.test/other-company-risk", + }, + ], + }); + + const serialized = JSON.stringify(publicDossier); + assert.equal(publicDossier.body.length, 0); + assert.equal(publicDossier.summary, ""); + assert.equal(publicDossier.citations.length, 0); + assert.doesNotMatch(serialized, /市场资讯|来源:连线新能源|北京永勤|容百科技|请求赔偿/); +}); + +test("dossier Agent does not persist repetitive low-quality plans", async () => { + const modelCalls = []; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callRequiredFunction(input) { + modelCalls.push(input); + const invalid = stagedDossierPlan(input); + const duplicateText = "企业近期发布产品升级公告并需要销售团队继续关注。"; + Object.values(invalid.sections).forEach((section) => { + section.text = duplicateText; + }); + return { + ok: true, + parsed: invalid, + usage: { prompt_tokens: 30, completion_tokens: 20, total_tokens: 50 }, + raw_ref: `model:invalid-${modelCalls.length}`, + }; + }, + }, + }); + const company = service.data.companies.company_1; + const dossier = await service.generateDossierWithModel(company, { + professional: [ + { + label: "企业工商数据库", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件与知识库产品。", + }, + { + label: "企业风险数据库", + summary: "风险状态需要结合公开公告持续关注。", + }, + ], + public_sources: [ + { + label: "测试科技有限公司产品升级公告", + summary: "测试科技有限公司于2026年7月发布销售知识库产品升级公告。", + url: "https://news.test/product-update", + published_at: "2026-07-20T09:00:00.000Z", + }, + { + label: "测试科技有限公司核心组件交付延期公告", + summary: "企业公告披露部分核心组件交付周期延长,可能影响重点项目的实施排期。", + url: "https://news.test/delivery-risk", + published_at: "2026-07-21T09:00:00.000Z", + }, + ], + }, []); + + assert.equal(modelCalls.length, 3); + assert.equal(dossier, null); + assert.equal(modelCalls[0].operation, "sales_dossier_agent_plan"); + assert.equal(modelCalls[1].operation, "sales_dossier_agent_replan"); + assert.equal(modelCalls[2].operation, "sales_dossier_agent_replan"); + assert.ok(modelCalls[1].payload.planning_errors.length > 0); +}); + +test("dossier Agent fails closed when all bounded revision calls fail", async () => { + const modelCalls = []; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callRequiredFunction(input) { + modelCalls.push(input); + if (input.operation === "sales_dossier_agent_plan") { + const invalid = stagedDossierPlan(input); + invalid.sections.company_overview.text = "搜索标题"; + return { + ok: true, + parsed: invalid, + raw_ref: "model:invalid-plan", + }; + } + return { + ok: false, + error: { + code: "incomplete_response", + message: "The revision response was truncated.", + retryable: true, + }, + }; + }, + }, + }); + const company = { + ...service.data.companies.company_1, + name: "宁德时代新能源科技股份有限公司", + aliases: ["宁德时代"], + industry: "新能源", + }; + await assert.rejects( + () => service.generateDossierWithModel(company, { + professional: [ + { + label: "企业工商数据库", + summary: "公司名称:宁德时代新能源科技股份有限公司;统一社会信用代码:TESTCATL001;经营范围:动力电池、储能电池及相关系统产品。", + }, + { + label: "金融数据库", + summary: "宁德时代新能源科技股份有限公司持续开展动力电池、储能系统及相关产业链业务。", + }, + { + label: "企业风险数据库", + summary: "宁德时代新能源科技股份有限公司的供应链履约、项目交付与合同责任需要持续核验。", + }, + ], + public_sources: [ + { + label: "宁德时代与大连德泰签署战略合作协议", + summary: "宁德时代新能源科技股份有限公司与大连德泰有限公司签署战略合作协议,双方将推进储能项目建设与运营。", + url: "https://news.test/catl-deta-cooperation", + published_at: "2026-07-23T09:00:00.000Z", + }, + { + label: "宁德时代披露储能项目交付进展", + summary: "宁德时代新能源科技股份有限公司披露储能项目交付进展,并说明后续建设与运营计划。", + url: "https://official.test/catl-storage-delivery", + published_at: "2026-07-24T09:00:00.000Z", + }, + ], + }, []), + (error) => error.status === 503 && error.code === "model_unavailable", + ); + + assert.equal(modelCalls.length, 3); + assert.equal(modelCalls[1].operation, "sales_dossier_agent_replan"); + assert.equal(modelCalls[2].operation, "sales_dossier_agent_replan"); + assert.ok(modelCalls[1].payload.planning_errors.length > 0); +}); + +test("dossier Agent ignores empty specialized databases when enforcing section sources", async () => { + const modelCalls = []; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callRequiredFunction(input) { + modelCalls.push(structuredClone(input)); + if ( + input.operation === "sales_dossier_agent_plan" + || input.operation === "sales_dossier_agent_replan" + ) { + const planned = stagedDossierPlan(input); + const recentEvidence = input.payload.evidence_by_section.recent_public_updates.allowed_evidence; + const distinctRecent = recentEvidence.find((item) => ( + item.id !== planned.sections.business_dynamics.evidence_ids[0] + )); + if (distinctRecent) { + planned.sections.recent_public_updates = { + text: /[。!?]$/u.test(distinctRecent.quote) + ? distinctRecent.quote + : `${distinctRecent.quote}。`, + evidence_ids: [distinctRecent.id], + }; + } + const riskEvidence = input.payload.evidence_by_section.risk_attention.allowed_evidence; + planned.sections.risk_attention = { + text: "公开公告显示部分核心组件交付周期延长,项目实施排期需要提前确认。", + evidence_ids: [riskEvidence[0].id], + }; + planned.sections.recommended_actions = { + text: "销售人员应联系项目负责人确认核心组件交付排期。", + evidence_ids: [riskEvidence[0].id], + }; + return { + ok: true, + parsed: planned, + raw_ref: "model:specialized-plan", + }; + } + throw new Error("deterministic compilation must not request a writer call"); + }, + }, + }); + const company = service.data.companies.company_1; + const dossier = await service.generateDossierWithModel(company, { + professional: [ + { + label: "企业工商数据库", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件与知识库产品。", + }, + { + label: "企业风险数据库", + summary: "本次未检索到可核验的司法、处罚或失信记录。", + }, + { + label: "金融数据库", + summary: "企业ID(关联主键):254716。", + }, + ], + public_sources: [ + { + label: "测试科技有限公司产品升级公告", + summary: "测试科技有限公司于2026年7月发布销售知识库产品升级公告。", + url: "https://news.test/product-update", + published_at: "2026-07-20T09:00:00.000Z", + }, + { + label: "测试科技有限公司核心组件交付延期公告", + summary: "企业公告披露部分核心组件交付周期延长,可能影响重点项目的实施排期。", + url: "https://news.test/delivery-risk", + published_at: "2026-07-21T09:00:00.000Z", + }, + ], + }, []); + + assert.ok(dossier, JSON.stringify(modelCalls.map((call) => ({ + operation: call.operation, + planning_errors: call.payload?.planning_errors || [], + source_selection_policy: call.payload?.source_selection_policy || {}, + })))); + assert.equal(dossier.body.length, 6); + assert.deepEqual(modelCalls[0].payload.source_selection_policy.risk_database_ids, []); + assert.deepEqual(modelCalls[0].payload.source_selection_policy.market_database_ids, []); + assert.ok(modelCalls[0].payload.source_selection_policy.business_dynamics_ids.length > 0); + assert.ok(modelCalls[0].payload.source_selection_policy.business_dynamics_ids.every((id) => ( + modelCalls[0].payload.source_selection_policy.web_search_ids.includes(id) + ))); + assert.ok(dossier.body[1].citation_ids.every((id) => ( + dossier.citations.find((citation) => citation.id === id)?.source_kind === "联网搜索" + ))); + assert.ok(modelCalls.every((call) => ( + call.operation === "sales_dossier_agent_plan" + || call.operation === "sales_dossier_agent_replan" + ))); + assert.doesNotMatch(JSON.stringify(dossier.body), /企业ID|本次未检索到/); + assert.ok(dossier.body.every((paragraph) => ( + paragraph.text + .split(/\n+/u) + .filter(Boolean) + .every((line) => /[。!?]$/u.test(line)) + ))); +}); + +test("runtime rejects a QA answer that fabricates citation identifiers", async () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callJson() { + return { + ok: true, + parsed: { + paragraphs: [{ text: "这是一个没有真实来源的结论。", citation_ids: ["invented-source"] }], + insufficient: false, + }, + }; + }, + }, + }); + + await assert.rejects( + () => service.generateQaAnswer( + service.data.companies.company_1, + "测试问题", + null, + [], + [{ id: "evidence_real", label: "真实来源", source_kind: "专业数据集", summary: "真实内容" }], + ), + (error) => error.status === 503 + && error.code === "model_unavailable" + && error.details.validation_errors.some((item) => item.includes("无效引用")), + ); +}); + +test("runtime repairs a malformed QA JSON response from the original model output", async () => { + const calls = []; + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callJson(input) { + calls.push(structuredClone(input)); + if (calls.length === 1) { + return { + ok: false, + error: { + code: "invalid_json", + message: "Unterminated string in JSON response.", + }, + invalid_content: "{\"paragraphs\":[{\"text\":\"结论:企业正在推进扩产计划", + }; + } + return { + ok: true, + parsed: { + paragraphs: [ + { + text: "结论:现有资料显示企业正在推进扩产计划。", + citation_ids: ["evidence_real"], + }, + { + text: "下一步:核验采购时间表和预算窗口。", + citation_ids: ["evidence_real"], + }, + ], + insufficient: false, + }, + usage: { prompt_tokens: 160, completion_tokens: 80, total_tokens: 240 }, + raw_ref: "model:qa-retry", + }; + }, + }, + }); + + const answer = await service.generateQaAnswer( + service.data.companies.company_1, + "扩产计划和下一步行动是什么?", + null, + [], + [{ + id: "evidence_real", + label: "企业档案", + source_kind: "企业档案", + summary: "企业正在推进扩产计划,下一步需核验采购时间表和预算窗口。", + }], + ); + + assert.equal(calls.length, 2); + assert.equal(calls[0].operation, "sales_qa"); + assert.equal(calls[0].maxTokens, 1600); + assert.equal(calls[1].operation, "sales_qa_json_repair"); + assert.equal(calls[1].maxTokens, 2200); + assert.equal( + calls[1].payload.invalid_json_content, + "{\"paragraphs\":[{\"text\":\"结论:企业正在推进扩产计划", + ); + assert.equal(answer.insufficient, false); + assert.match(answer.text, /扩产计划/); + assert.deepEqual(answer.citation_ids, ["evidence_real"]); +}); + +test("runtime retries a QA answer that omits explicit table items", async () => { + const calls = []; + const evidence = [{ + id: "evidence_capabilities", + label: "个人投资助手 CookBook", + source_kind: "飞书云文档", + retrieval_score: 0.9, + summary: [ + "| 能力点 | 说明 |", + "|-|-|", + "| 语言模型 | 完成需求理解 |", + "| Claude code/ Agent 能力 | 负责任务编排 |", + "| 联网搜索 | 补充公开动态 |", + "| Data MCP:股票金融数据/国内企业工商数据 | 查询专业数据 |", + "| 多工具兼容 | 支持多个 Agent 平台 |", + "| 消耗统一计量 | 控制台查看消耗 |", + ].join(" "), + }]; + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callJson(input) { + calls.push(structuredClone(input)); + const complete = input.operation === "sales_qa_quality_retry"; + return { + ok: true, + parsed: { + paragraphs: [{ + text: complete + ? "文档列出的能力包括语言模型、Claude Code/Agent 能力、联网搜索、Data MCP、多工具兼容和消耗统一计量。" + : "文档列出的能力包括语言模型、Claude Code、联网搜索和 Data MCP。", + citation_ids: ["evidence_capabilities"], + }], + insufficient: false, + }, + usage: { prompt_tokens: 160, completion_tokens: 80, total_tokens: 240 }, + raw_ref: complete ? "model:qa-quality-retry" : "model:qa-incomplete", + }; + }, + }, + }); + + const answer = await service.generateQaAnswer( + service.data.companies.company_1, + "这份文档明确使用了哪些核心能力?", + null, + [], + evidence, + ); + + assert.equal(calls.length, 2); + assert.equal(calls[0].operation, "sales_qa"); + assert.equal(calls[1].operation, "sales_qa_quality_retry"); + assert.deepEqual( + calls[0].payload.enumeration_requirements.map((item) => item.label), + ["语言模型", "Claude code/ Agent 能力", "联网搜索", "Data MCP:股票金融数据/国内企业工商数据", "多工具兼容", "消耗统一计量"], + ); + assert.ok(calls[1].payload.validation_feedback.some((item) => item.includes("多工具兼容"))); + assert.match(answer.text, /消耗统一计量/); +}); + +test("runtime retries a QA answer with invalid citations and keeps fail-closed validation", async () => { + const calls = []; + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callJson(input) { + calls.push(structuredClone(input)); + const corrected = input.operation === "sales_qa_quality_retry"; + return { + ok: true, + parsed: { + paragraphs: [{ + text: corrected + ? "Trace 通过唯一 Trace ID 串联一次完整调用,Span 表示其中的单个执行节点。" + : "Trace 通过唯一 Trace ID 串联一次完整调用,Span 表示其中的单个执行节点。", + citation_ids: [corrected ? "evidence_trace" : "1"], + }], + insufficient: false, + }, + usage: { prompt_tokens: 160, completion_tokens: 80, total_tokens: 240 }, + raw_ref: corrected ? "model:qa-citation-retry" : "model:qa-invalid-citation", + }; + }, + }, + }); + + const answer = await service.generateQaAnswer( + service.data.companies.company_1, + "Trace 和 Span 分别承担什么作用?", + null, + [], + [{ + id: "evidence_trace", + label: "方舟全链路数据体系建设研讨会", + source_kind: "飞书云文档", + summary: "Trace 通过唯一 Trace ID 串联一次完整调用;每个执行节点对应一个 Span。", + }], + ); + + assert.equal(calls.length, 2); + assert.equal(calls[0].operation, "sales_qa"); + assert.equal(calls[1].operation, "sales_qa_quality_retry"); + assert.ok(calls[1].payload.validation_feedback.some((item) => item.includes("无效引用"))); + assert.deepEqual(answer.citation_ids, ["evidence_trace"]); + assert.equal(answer.citations[0].label, "方舟全链路数据体系建设研讨会"); +}); + +test("QA workflow preserves bounded citation validation diagnostics in the failed provider run", async () => { + const fixture = createWorkflowService(); + fixture.service.modelProvider = { + isRunEnabled: () => true, + async callJson() { + return { + ok: true, + parsed: { + paragraphs: [{ + text: "客户希望先验证知识库问答,并确认数据权限边界。", + citation_ids: ["invented-source"], + }], + insufficient: false, + }, + }; + }, + }; + const generateQaAnswer = fixture.service.generateQaAnswer.bind(fixture.service); + fixture.service.generateQaAnswer = async (...args) => { + const previousPolicy = fixture.service.runtimePolicy; + fixture.service.runtimePolicy = { ...previousPolicy, fail_closed: true }; + try { + return await generateQaAnswer(...args); + } finally { + fixture.service.runtimePolicy = previousPolicy; + } + }; + + await assert.rejects( + () => fixture.service.askQuestion("company_1", { question: "客户希望先验证什么?" }), + (error) => error.code === "model_unavailable", + ); + + const [run] = await fixture.service.listProviderRuns({ + operation: "sales_qa", + entity_id: "company_1", + }); + assert.equal(run.status, "failed"); + assert.ok(run.error.validation_errors.some((item) => item.includes("无效引用"))); + assert.equal((await fixture.service.getJob(run.job_id)).status, "failed"); +}); + +test("cancelled jobs remain cancelled when a late workflow completion arrives", async () => { + const fixture = createWorkflowService(); + const job = await fixture.service.startJob({ + job_type: "sales_dossier_generation", + entity_type: "target_enterprise", + entity_id: "company_1", + max_attempts: 3, + request: {}, + }); + + const cancelled = await fixture.service.cancelJob(job.id); + assert.equal(cancelled.status, "cancelled"); + assert.ok(cancelled.cancel_requested_at); + + await fixture.service.completeJob(job.id, { result_ref: "late-result" }); + await fixture.service.failJob(job.id, { code: "late-error", message: "late error" }); + const afterLateWrites = await fixture.service.getJob(job.id); + assert.equal(afterLateWrites.status, "cancelled"); + assert.equal(afterLateWrites.result_ref, null); + assert.equal(afterLateWrites.error, null); +}); + +test("failed jobs remain failed when a late workflow completion arrives", async () => { + const fixture = createWorkflowService(); + const job = await fixture.service.startJob({ + job_type: "sales_dossier_generation", + entity_type: "target_enterprise", + entity_id: "company_1", + max_attempts: 3, + request: {}, + }); + + await fixture.service.failJob(job.id, { + code: "provider_timeout", + message: "provider timeout", + retryable: true, + }); + await fixture.service.completeJob(job.id, { result_ref: "late-result" }); + + const afterLateCompletion = await fixture.service.getJob(job.id); + assert.equal(afterLateCompletion.status, "failed"); + assert.equal(afterLateCompletion.result_ref, null); + assert.equal(afterLateCompletion.error.code, "provider_timeout"); +}); + +test("manual retry reuses a failed dossier job and increments its attempt", async () => { + const fixture = createWorkflowService(); + const job = await fixture.service.startJob({ + job_type: "sales_dossier_generation", + entity_type: "target_enterprise", + entity_id: "company_1", + max_attempts: 3, + request: {}, + }); + await fixture.service.failJob(job.id, { + code: "temporary_provider_error", + message: "temporary provider error", + retryable: true, + }); + + const result = await fixture.service.retryJob(job.id); + const retried = await fixture.service.getJob(job.id); + assert.equal(result.job_id, job.id); + assert.equal(result.action, "created"); + assert.equal(retried.status, "succeeded"); + assert.equal(retried.attempt_count, 2); + assert.equal(retried.error, null); +}); + +test("manual retry rejects terminal success and exhausted attempts", async () => { + const fixture = createWorkflowService(); + const succeeded = await fixture.service.startJob({ + job_type: "sales_dossier_generation", + entity_type: "target_enterprise", + entity_id: "company_1", + max_attempts: 3, + }); + await fixture.service.completeJob(succeeded.id); + await assert.rejects( + () => fixture.service.retryJob(succeeded.id), + (error) => error.status === 409 && error.code === "job_not_retryable", + ); + + const exhausted = await fixture.service.startJob({ + job_type: "sales_dossier_generation", + entity_type: "target_enterprise", + entity_id: "company_1", + max_attempts: 1, + }); + await fixture.service.failJob(exhausted.id, { code: "failed", message: "failed" }); + await assert.rejects( + () => fixture.service.retryJob(exhausted.id), + (error) => error.status === 409 && error.code === "job_attempts_exhausted", + ); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/setupSupabasePolicy.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/setupSupabasePolicy.test.mjs new file mode 100644 index 00000000..61fe223c --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/setupSupabasePolicy.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const backendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const rootDir = path.resolve(backendDir, ".."); +const sourcePath = path.join(rootDir, "skills", "sales-intelligence-workbench", "scripts", "setup-supabase.mjs"); +const source = await fs.readFile(sourcePath, "utf8").catch((error) => { + if (error?.code === "ENOENT") return ""; + throw error; +}); +const sourceOnly = { skip: source ? false : "Skill policy is outside the standalone runtime package." }; + +test("Supabase setup rejects ordinary pay-as-you-go workspaces", sourceOnly, () => { + assert.match(source, /"projects", "list"/); + assert.match(source, /"--detail"/); + assert.match(source, /workspace\?\.is_agent_plan/); + assert.match(source, /workspace\?\.is_agent_plan_instance/); + assert.match(source, /目标不是 AI Native 应用开发底座(Supabase)的 Agent Plan Workspace/); +}); + +test("Supabase setup supports an explicit CLI profile without leaking static credentials", sourceOnly, () => { + assert.match(source, /SUPABASE_CLI_PROFILE/); + assert.match(source, /delete environment\.VOLCENGINE_ACCESS_KEY/); + assert.match(source, /delete environment\.VOLCENGINE_SECRET_KEY/); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/staticFrontend.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/staticFrontend.test.mjs new file mode 100644 index 00000000..868ebedf --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/staticFrontend.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createStaticFrontend } from "../src/frontend/staticFrontend.js"; + +function createResponse() { + return { + body: null, + headers: {}, + statusCode: null, + setHeader(name, value) { + this.headers[name.toLowerCase()] = value; + }, + writeHead(statusCode) { + this.statusCode = statusCode; + }, + end(body = null) { + this.body = body; + }, + }; +} + +async function withFrontend(run) { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "sales-frontend-")); + try { + await writeFile(path.join(rootDir, "index.html"), "Sales"); + await writeFile(path.join(rootDir, "app.js"), "window.sales = true;"); + await run(createStaticFrontend({ rootDir })); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } +} + +test("serves the workbench index from the root path", async () => { + await withFrontend(async (serve) => { + const response = createResponse(); + assert.equal(await serve({ method: "GET" }, response, "/"), true); + assert.equal(response.statusCode, 200); + assert.equal(response.headers["content-type"], "text/html; charset=utf-8"); + assert.equal(response.headers["cache-control"], "no-store"); + assert.match(response.body.toString(), /Sales/); + }); +}); + +test("serves assets without returning a response body for HEAD", async () => { + await withFrontend(async (serve) => { + const response = createResponse(); + assert.equal(await serve({ method: "HEAD" }, response, "/app.js"), true); + assert.equal(response.statusCode, 200); + assert.equal(response.headers["content-type"], "text/javascript; charset=utf-8"); + assert.equal(response.headers["cache-control"], "no-store"); + assert.equal(response.body, null); + }); +}); + +test("does not handle API paths", async () => { + await withFrontend(async (serve) => { + const response = createResponse(); + assert.equal(await serve({ method: "GET" }, response, "/api/health"), false); + assert.equal(response.statusCode, null); + }); +}); + +test("rejects encoded parent-directory traversal", async () => { + await withFrontend(async (serve) => { + const response = createResponse(); + assert.equal(await serve({ method: "GET" }, response, "/%2e%2e/secret.txt"), false); + assert.equal(response.statusCode, null); + }); +}); + +test("returns control to the API router for missing files", async () => { + await withFrontend(async (serve) => { + const response = createResponse(); + assert.equal(await serve({ method: "GET" }, response, "/missing.js"), false); + assert.equal(response.statusCode, null); + }); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/supabaseBackup.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/supabaseBackup.test.mjs new file mode 100644 index 00000000..b69894ac --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/supabaseBackup.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + BACKUP_FORMAT_VERSION, + prepareRowsForRestore, + validateBackupPackage, +} from "../src/backup/supabaseBackup.js"; + +test("restore preparation remaps tenancy and removes environment-bound fields", () => { + const rows = prepareRowsForRestore("sales_companies", [{ + id: "company-1", + workspace_id: "source-workspace", + name: "Example", + normalized_name: "example", + created_by: "source-user", + updated_by: "source-user", + }], "target-workspace"); + + assert.equal(rows[0].workspace_id, "target-workspace"); + assert.equal(rows[0].created_by, null); + assert.equal(rows[0].updated_by, null); + assert.equal(Object.hasOwn(rows[0], "normalized_name"), false); +}); + +test("restore preparation never carries provider secret references", () => { + const rows = prepareRowsForRestore("provider_connections", [{ + id: "provider-1", + workspace_id: "source-workspace", + status: "configured", + secret_ref: "secret://source/provider", + }], "target-workspace"); + + assert.equal(rows[0].secret_ref, null); + assert.equal(rows[0].status, "needs_reconfiguration"); +}); + +test("backup validation checks row counts and file hashes", () => { + const directory = mkdtempSync(join(tmpdir(), "sales-backup-test-")); + const dataPath = join(directory, "data.json"); + const data = { + format_version: BACKUP_FORMAT_VERSION, + backup_id: "backup-1", + tables: { sales_goals: [{ id: "goal-1" }] }, + }; + const content = `${JSON.stringify(data)}\n`; + writeFileSync(dataPath, content); + const manifest = { + format_version: BACKUP_FORMAT_VERSION, + backup_id: "backup-1", + row_counts: { sales_goals: 1 }, + files: [{ + path: "data.json", + sha256: createHash("sha256").update(content).digest("hex"), + }], + }; + + assert.equal(validateBackupPackage(directory, manifest, data), true); + manifest.row_counts.sales_goals = 2; + assert.throws(() => validateBackupPackage(directory, manifest, data), /row count mismatch/i); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/supabaseDataProvider.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/supabaseDataProvider.test.mjs new file mode 100644 index 00000000..0006fd09 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/supabaseDataProvider.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SupabaseDataProvider } from "../src/providers/supabaseDataProvider.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + number(name, fallback) { + const value = Number(this.value(name, fallback)); + return Number.isFinite(value) ? value : fallback; + }, + }; +} + +test("Supabase live probe checks the runtime Data API without control-plane credentials", async () => { + const calls = []; + const provider = new SupabaseDataProvider({ + env: envReader({ + SUPABASE_API_URL: "https://database.example.test", + SUPABASE_SERVICE_ROLE_KEY: "test-service-role", + SUPABASE_RUN_ENABLED: "true", + }), + fetchImpl: async (url, options) => { + calls.push({ url: String(url), options }); + return new Response(JSON.stringify([{ id: "workspace-1" }]), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + const result = await provider.probe(); + + assert.deepEqual(result, { ok: true, row_count: 1 }); + assert.equal(provider.isConfigured(), true); + assert.equal(provider.isRunEnabled(), true); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://database.example.test/rest/v1/app_workspaces?select=id&limit=1"); + assert.equal(calls[0].options.method, "GET"); + assert.equal(calls[0].options.headers.apikey, "test-service-role"); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/supabaseDataRepository.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/supabaseDataRepository.test.mjs new file mode 100644 index 00000000..cd96e792 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/supabaseDataRepository.test.mjs @@ -0,0 +1,256 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SupabaseDataRepository } from "../src/repositories/supabaseDataRepository.js"; + +const workspaceId = "54768bef-53aa-47d0-a9e3-bbca4593cf58"; + +function createProvider(options = {}) { + const calls = []; + return { + calls, + isConfigured: () => true, + async select(table, query) { + calls.push({ method: "select", table, query }); + if (table === "schema_migrations") return [{ version: "202607300001" }]; + if (table === "app_workspaces") return [{ id: workspaceId }]; + return options.select?.(table, query) || []; + }, + async update(table, values, filters) { + calls.push({ method: "update", table, values, filters }); + return options.update?.(table, values, filters) || []; + }, + async insert(table, rows) { + calls.push({ method: "insert", table, rows }); + return Array.isArray(rows) ? rows : [rows]; + }, + async rpc(name, body) { + calls.push({ method: "rpc", name, body }); + return options.rpc?.(name, body) || { ok: true }; + }, + }; +} + +test("Data API state reads scope every sales table to the application workspace", async () => { + const provider = createProvider(); + const repository = new SupabaseDataRepository({ supabaseDataProvider: provider, workspaceId }); + + const state = await repository.getSalesState(); + assert.deepEqual(state, { + goals: [], + companies: {}, + dossiers: {}, + materials: {}, + qa_messages: {}, + sync_sources: {}, + sync_checkpoints: {}, + jobs: {}, + }); + + const businessReads = provider.calls.filter((call) => call.method === "select") + .filter((call) => !["schema_migrations", "app_workspaces"].includes(call.table)); + assert.equal(businessReads.length, 11); + assert.ok(businessReads.every((call) => call.query.filters.workspace_id === `eq.${workspaceId}`)); + assert.equal(businessReads.some((call) => call.table === "sales_qa_messages"), false); +}); + +test("material sync metadata is persisted inside the application workspace", async () => { + const provider = createProvider(); + const repository = new SupabaseDataRepository({ supabaseDataProvider: provider, workspaceId }); + const syncedAt = "2026-07-21T10:00:00.000Z"; + + await repository.persistSyncSource({ + id: "sync-1", + source_type: "feishu_doc", + external_id: "doc-1", + display_name: "测试文档", + status: "active", + last_synced_at: syncedAt, + }); + await repository.persistSyncCheckpoint({ + id: "checkpoint-1", + source_id: "sync-1", + checkpoint_key: "revision_id", + checkpoint_value: "12", + content_hash: "hash-1", + last_success_at: syncedAt, + }); + await repository.persistSalesMaterial({ + id: "material-1", + company_id: "company-1", + title: "测试文档", + source_id: "sync-1", + source_version: "12", + content_hash: "hash-1", + last_synced_at: syncedAt, + }); + + const inserts = provider.calls.filter((call) => call.method === "insert"); + assert.deepEqual(inserts.map((call) => call.table), ["sync_sources", "sync_checkpoints", "sales_materials"]); + assert.ok(inserts.every((call) => call.rows.workspace_id === workspaceId)); + assert.equal(inserts.at(-1).rows.source_id, "sync-1"); + assert.equal(inserts.at(-1).rows.source_version, "12"); + assert.equal(inserts.at(-1).rows.summary, ""); + assert.equal(Object.hasOwn(inserts.at(-1).rows.payload_json, "text"), false); + assert.equal(Object.hasOwn(inserts.at(-1).rows.payload_json, "source_items"), false); +}); + +test("Data API upserts never update an identifier outside the application workspace", async () => { + const provider = createProvider(); + const repository = new SupabaseDataRepository({ supabaseDataProvider: provider, workspaceId }); + const goal = { + id: "goal-data-api", + name: "Data API Goal", + description: "test", + keywords: [], + created_at: "2026-07-21T00:00:00.000Z", + updated_at: "2026-07-21T00:00:00.000Z", + }; + + await repository.persistSalesGoal(goal); + + const update = provider.calls.find((call) => call.method === "update" && call.table === "sales_goals"); + const insert = provider.calls.find((call) => call.method === "insert" && call.table === "sales_goals"); + assert.deepEqual(update.filters, { workspace_id: `eq.${workspaceId}`, id: "eq.goal-data-api" }); + assert.equal(insert.rows.workspace_id, workspaceId); +}); + +test("multi-table writes use RPCs and provider runs retain their persistent job", async () => { + const provider = createProvider(); + const repository = new SupabaseDataRepository({ supabaseDataProvider: provider, workspaceId }); + const dossier = { id: "dossier-1", company_id: "company-1", citations: [] }; + const job = { id: "job-1", job_type: "dossier.generate", status: "running" }; + const run = { id: "run-1", job_id: job.id, operation: "test", status: "running", steps: [] }; + + await repository.persistJob(job); + await repository.persistSalesDossier(dossier); + await repository.persistProviderRun(run); + + const rpcCalls = provider.calls.filter((call) => call.method === "rpc"); + assert.deepEqual(rpcCalls.map((call) => call.name), ["persist_sales_dossier", "persist_provider_run"]); + assert.ok(rpcCalls.every((call) => call.body.p_workspace_id === workspaceId)); + assert.equal(rpcCalls.at(-1).body.p_run.job_id, job.id); +}); + +test("paid workflow reservations and releases use atomic workspace RPCs", async () => { + const provider = createProvider({ + rpc(name, body) { + if (name === "reserve_paid_workflow") { + return { job: body.p_job, budget: { running: 1, used_today: 1 } }; + } + if (name === "get_paid_workflow_usage") return { running: 0, used_today: 1 }; + return body.p_job || { ok: true }; + }, + }); + const repository = new SupabaseDataRepository({ supabaseDataProvider: provider, workspaceId }); + const candidate = { id: "job-budget", job_type: "sales_qa", status: "running", is_paid: true }; + const limits = { max_concurrent: 2, daily_limit: 50, timezone: "Asia/Shanghai", stale_after_seconds: 1800 }; + + const reserved = await repository.reservePaidWorkflow(candidate, "reservation-1", limits); + await repository.finishPaidWorkflow({ ...reserved.job, status: "succeeded" }, "reservation-1"); + const usage = await repository.getPaidWorkflowUsage("Asia/Shanghai"); + + assert.equal(reserved.budget.running, 1); + assert.equal(usage.used_today, 1); + const calls = provider.calls.filter((call) => call.method === "rpc").slice(-3); + assert.deepEqual(calls.map((call) => call.name), [ + "reserve_paid_workflow", + "finish_paid_workflow", + "get_paid_workflow_usage", + ]); + assert.ok(calls.every((call) => call.body.p_workspace_id === workspaceId)); +}); + +test("asynchronous jobs enqueue, claim, heartbeat, cancel safely, release and retry through atomic RPCs", async () => { + const provider = createProvider({ + rpc(name, body) { + if (name === "claim_sales_job") { + return { + ...body, + id: "job-async", + job_type: "sales_dossier_generation", + status: "running", + stage: "starting", + progress: 1, + attempt_count: 1, + max_attempts: 3, + payload_json: { request: {} }, + }; + } + return { + id: "job-async", + job_type: "sales_dossier_generation", + status: name === "enqueue_sales_job" || name === "retry_sales_job" + ? "queued" + : name === "acknowledge_cancel_sales_job" ? "cancelled" : "running", + stage: name === "enqueue_sales_job" || name === "retry_sales_job" + ? "queued" + : name === "request_cancel_sales_job" ? "cancelling" + : name === "acknowledge_cancel_sales_job" ? "cancelled" : "collecting_evidence", + progress: name === "enqueue_sales_job" || name === "retry_sales_job" ? 0 : 20, + attempt_count: name === "enqueue_sales_job" ? 0 : 1, + max_attempts: 3, + payload_json: body.p_job || { request: {} }, + }; + }, + }); + const repository = new SupabaseDataRepository({ supabaseDataProvider: provider, workspaceId }); + const queued = await repository.enqueueJob({ + id: "job-async", + job_type: "sales_dossier_generation", + status: "queued", + request: {}, + }); + const claimed = await repository.claimNextJob("worker-1", ["sales_dossier_generation"], 600); + const heartbeat = await repository.heartbeatJob(claimed.id, "worker-1", "collecting_evidence", 20, 600); + const checkpointed = await repository.saveJobCheckpoint( + claimed.id, + "worker-1", + { + dossier: { + schema_version: 1, + company_id: "company-1", + evidence_collection: { completed_query_keys: ["datapro:business"] }, + }, + }, + { + stage: "collecting_professional", + progress: 24, + detail: { current: 1, total: 2, message: "正在核验专业资料 1/2" }, + lease_seconds: 600, + }, + ); + const cancelling = await repository.requestJobCancellation(claimed.id); + const cancelled = await repository.acknowledgeJobCancellation(claimed.id, "worker-1"); + await repository.releaseJobClaim(claimed.id, "worker-1", { code: "temporary" }, { retry: true, delay_seconds: 5 }); + const retried = await repository.retryQueuedJob(claimed.id); + + assert.equal(queued.status, "queued"); + assert.equal(claimed.status, "running"); + assert.equal(heartbeat.progress, 20); + assert.equal(checkpointed.progress, 20); + assert.equal(cancelling.stage, "cancelling"); + assert.equal(cancelled.status, "cancelled"); + assert.equal(retried.status, "queued"); + const rpcCalls = provider.calls.filter((call) => call.method === "rpc").slice(-8); + assert.deepEqual(rpcCalls.map((call) => call.name), [ + "enqueue_sales_job", + "claim_sales_job", + "heartbeat_sales_job", + "checkpoint_sales_job", + "request_cancel_sales_job", + "acknowledge_cancel_sales_job", + "release_sales_job_claim", + "retry_sales_job", + ]); + assert.ok(rpcCalls.every((call) => call.body.p_workspace_id === workspaceId)); + const checkpointCall = rpcCalls.find((call) => call.name === "checkpoint_sales_job"); + assert.equal(checkpointCall.body.p_worker_id, "worker-1"); + assert.deepEqual(checkpointCall.body.p_progress_detail, { + current: 1, + total: 2, + message: "正在核验专业资料 1/2", + }); + assert.deepEqual(checkpointCall.body.p_checkpoint_patch.dossier.evidence_collection.completed_query_keys, [ + "datapro:business", + ]); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/supabaseProvider.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/supabaseProvider.test.mjs new file mode 100644 index 00000000..e7ef40db --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/supabaseProvider.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SupabaseProvider } from "../src/providers/supabaseProvider.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + number(name, fallback = 0) { + const value = Number(Object.hasOwn(values, name) ? values[name] : fallback); + return Number.isFinite(value) ? value : fallback; + }, + }; +} + +const configured = { + SUPABASE_WORKSPACE_ID: "workspace-test", + SUPABASE_BRANCH_ID: "branch-test", + SUPABASE_CLI_BIN: "fake-supabase-cli", + VOLCENGINE_ACCESS_KEY: "test-access-key", + VOLCENGINE_SECRET_KEY: "test-secret-key", +}; + +test("Supabase provider parses the official CLI rows envelope", async () => { + let invocation = null; + const provider = new SupabaseProvider({ + env: envReader({ ...configured, SUPABASE_READ_ONLY: "false" }), + execFile: async (command, args, options) => { + invocation = { command, args, options }; + return { + stdout: JSON.stringify({ boundary: "test", rows: [{ answer: 42 }], warning: "" }), + stderr: "", + }; + }, + }); + + const result = await provider.executeSql("select 42 as answer;"); + assert.equal(result.ok, true); + assert.deepEqual(result.rows, [{ answer: 42 }]); + assert.equal(invocation.command, "fake-supabase-cli"); + assert.ok(invocation.args.includes("workspace-test")); + assert.ok(invocation.args.includes("branch-test")); + assert.equal(invocation.options.env.VOLCENGINE_ACCESS_KEY, "test-access-key"); +}); + +test("Supabase provider blocks writes locally when read-only mode is enabled", async () => { + let called = false; + const provider = new SupabaseProvider({ + env: envReader({ ...configured, SUPABASE_READ_ONLY: "true" }), + execFile: async () => { + called = true; + return { stdout: "[]", stderr: "" }; + }, + }); + + const result = await provider.executeSql("update public.sales_goals set name = 'blocked';"); + assert.equal(result.ok, false); + assert.equal(result.error.code, "read_only"); + assert.equal(called, false); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/supabaseSecurityBoundary.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/supabaseSecurityBoundary.test.mjs new file mode 100644 index 00000000..0897f2dd --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/supabaseSecurityBoundary.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const migrationPath = path.join( + root, + "supabase", + "migrations", + "202607280002_secure_internal_tables.sql", +); + +test("internal metadata tables are fail-closed for ordinary roles", () => { + const sql = fs.readFileSync(migrationPath, "utf8"); + + assert.match(sql, /alter table public\.schema_migrations enable row level security/i); + assert.match(sql, /revoke all on table public\.schema_migrations from public, anon, authenticated/i); + assert.match(sql, /grant all on table public\.schema_migrations to service_role/i); + assert.doesNotMatch(sql, /alter table public\.health_check/i); + assert.match(sql, /values \('202607280002'/i); + assert.doesNotMatch(sql, /\b(?:drop|truncate|delete)\b/i); +}); + +test("live verifier treats platform-owned health checks as a separate fail-closed boundary", () => { + const verifier = fs.readFileSync( + path.join(root, "backend", "scripts", "verify-supabase-security-boundary.mjs"), + "utf8", + ); + + assert.match(verifier, /platformManagedTables = new Set\(\["health_check"\]\)/); + assert.match(verifier, /platform_managed_tables_fail_closed/); + assert.match(verifier, /project_public_tables_use_rls/); +}); diff --git a/demohouse/sales-intelligence-workbench/backend/tests/workspaceExport.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/workspaceExport.test.mjs new file mode 100644 index 00000000..2d2b5b8d --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/tests/workspaceExport.test.mjs @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SalesService } from "../src/services/salesService.js"; + +const FORBIDDEN_KEYS = new Set([ + "access_token", + "api_key", + "lease_token", + "openviking_ref", + "openviking_uri", + "password", + "prompt", + "raw_ref", + "refresh_token", + "reservation_id", + "secret", + "service_role_key", + "worker_id", +]); + +function privatePaths(value, current = "$", found = []) { + if (Array.isArray(value)) { + value.forEach((item, index) => privatePaths(item, `${current}[${index}]`, found)); + return found; + } + if (!value || typeof value !== "object") return found; + for (const [key, item] of Object.entries(value)) { + const next = `${current}.${key}`; + if (FORBIDDEN_KEYS.has(key.toLowerCase())) found.push(next); + privatePaths(item, next, found); + } + return found; +} + +function envReader() { + return { + value(name, fallback = "") { + return name === "APP_WORKSPACE_ID" ? "workspace-test" : fallback; + }, + }; +} + +test("workspace export retains portable business content and removes runtime internals", () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: { + fail_closed: false, + }, + seed: { + goals: [{ + id: "goal-1", + name: "授权客户跟进", + description: "测试目标", + keywords: ["知识库"], + company_ids: ["company-1"], + candidate_ids: ["company-1"], + created_at: "2026-07-23T08:00:00.000Z", + updated_at: "2026-07-23T08:00:00.000Z", + }], + companies: { + "company-1": { + id: "company-1", + name: "测试科技有限公司", + industry: "企业软件", + identity_status: "verified", + progress: { label: "需求确认", summary: "确认数据边界", evidence: "会议纪要" }, + dossier_ids: ["dossier-1"], + material_ids: ["material-1"], + qa_session_id: "qa-company-1", + }, + }, + dossiers: { + "dossier-1": { + id: "dossier-1", + company_id: "company-1", + title: "测试科技有限公司最新档案", + summary: "已确认企业主体。", + body: [{ text: "企业主体已核验。", citation_ids: ["source-1"] }], + citations: [{ + id: "source-1", + label: "专业数据库", + source_kind: "专业数据集", + raw_ref: "must-not-export", + }], + openviking_uri: "viking://must-not-export", + version_no: 1, + created_at: "2026-07-23T08:10:00.000Z", + }, + }, + materials: { + "material-1": { + id: "material-1", + company_id: "company-1", + title: "获授权会议纪要", + summary: "客户要求明确数据边界。", + text: "客户要求明确数据边界,并确认后续试点范围。", + source_type: "feishu_chat", + source_id: "source-material-1", + source_external_id: "chat-stable-id", + source_version: "v1", + source_items: [{ + id: "message-1", + sender: "授权测试用户", + content: "请先确认数据边界。", + occurred_at: "2026-07-23T08:05:00.000Z", + }], + openviking_uri: "viking://must-not-export/material", + openviking_ref: "must-not-export", + updated_at: "2026-07-23T08:05:00.000Z", + }, + }, + qa_messages: { + "company-1": [{ + id: "qa-1", + role: "assistant", + text: "客户关注数据边界。", + citation_ids: ["material:material-1"], + citations: [], + raw_ref: "must-not-export", + created_at: "2026-07-23T08:20:00.000Z", + }], + }, + sync_sources: { + "source-material-1": { + id: "source-material-1", + source_type: "feishu_chat", + external_id: "chat-stable-id", + display_name: "获授权会议纪要", + status: "active", + secret_ref: "must-not-export", + updated_at: "2026-07-23T08:05:00.000Z", + }, + }, + sync_checkpoints: {}, + jobs: { + "job-private": { + id: "job-private", + worker_id: "must-not-export", + reservation_id: "must-not-export", + }, + }, + }, + }); + + const exported = service.exportWorkspaceData(); + + assert.equal(exported.format_version, 1); + assert.equal(exported.contains_private_business_data, true); + assert.deepEqual(exported.goals[0].target_enterprise_ids, ["company-1"]); + assert.equal(exported.enterprises[0].materials[0].raw_text, "客户要求明确数据边界,并确认后续试点范围。"); + assert.equal(exported.enterprises[0].materials[0].source_items[0].id, "message-1"); + assert.equal(exported.enterprises[0].qa.messages[0].text, "客户关注数据边界。"); + assert.deepEqual(privatePaths(exported), []); + assert.doesNotMatch(JSON.stringify(exported), /viking:\/\/|must-not-export|job-private/); +}); diff --git a/demohouse/sales-intelligence-workbench/docs/README.md b/demohouse/sales-intelligence-workbench/docs/README.md new file mode 100644 index 00000000..447c8a91 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/docs/README.md @@ -0,0 +1,12 @@ +# 文档目录 + +这里仅包含安装、部署和二次开发所需的公开技术文档。 + +- [API 合约](api/api-contract.md):公开接口、认证授权、任务状态和响应边界。 +- [档案 Agent 工程设计](architecture/dossier-agent.md):单 Agent 状态机、严格函数协议、质量门禁、失败边界和验收标准。 +- [Supabase 数据库说明](database/supabase-schema.md):表、RLS、迁移、事务 RPC 和 smoke 检查。 +- [单工作区自托管部署](deployment/self-hosting.md):HTTPS 反向代理、双进程托管、健康检查与回滚边界。 +- [变更记录](../CHANGELOG.md):发布版本的新增能力、安全变化和已知限制。 +- [第三方组件与外部服务说明](../THIRD_PARTY_NOTICES.md):分发依赖、外部服务及许可边界。 + +首次了解项目请从根目录 [README](../README.md) 开始。 diff --git a/demohouse/sales-intelligence-workbench/docs/api/api-contract.md b/demohouse/sales-intelligence-workbench/docs/api/api-contract.md new file mode 100644 index 00000000..32a1216b --- /dev/null +++ b/demohouse/sales-intelligence-workbench/docs/api/api-contract.md @@ -0,0 +1,324 @@ +# 销售智能工作台 API 合约 + +更新时间:2026-07-29 + +本文只记录当前销售智能工作台的公开 API。早期原型接口不属于公开合约,相关路由已删除。 + +## 1. 基础约定 + +### Base URL + +本地安装默认地址: + +```text +http://127.0.0.1:8787/api +``` + +通过 HTTP(S) 打开前端时,前端调用同源 `/api`。对外部署应由 HTTPS 反向代理同时 +代理前端和 API,不应把后端端口直接暴露到公网。 + +### 运行边界 + +项目仅连接真实 Provider 与 Supabase 持久化。配置、安全保护或依赖不完整时失败关闭, +不提供可切换的开发或演示运行方式。`GET /api/health` 返回 `provider_mode` 和 +`runtime_ready`。 + +### 响应格式 + +成功响应: + +```json +{ + "data": {}, + "meta": { + "request_id": "req_...", + "provider_mode": "real" + } +} +``` + +错误响应: + +```json +{ + "error": { + "code": "bad_request", + "message": "请求字段不合法。", + "details": {} + }, + "meta": { + "request_id": "req_..." + } +} +``` + +HTTP API 字段统一使用 `snake_case`,时间统一使用 ISO 8601。 + +## 2. 认证与授权 + +### 浏览器会话 + +浏览器登录成功后使用 HttpOnly、`SameSite=Strict` Cookie。所有经过 Cookie 认证的 +非 GET 请求必须携带 `GET /api/auth/status` 返回的 CSRF Token: + +```http +X-CSRF-Token: +``` + +### CLI 会话 + +CLI 使用 `POST /api/auth/cli-login` 获取本机管理员的 Bearer 会话。不要把 Supabase +Service Role Key 当作登录令牌。推荐使用随 Skill 分发的登录脚本,将会话保存为 +仅当前用户可读的 `0600` 文件。 + +```http +Authorization: Bearer +``` + +Bearer 请求不依赖浏览器 Cookie,因此不要求 CSRF Header。 + +### 访问边界 + +当前版本只有一个本机管理员。首次使用通过 bootstrap 设置用户名和密码,之后 bootstrap +永久关闭;设置成功后浏览器保存长期会话,并在短期访问令牌过期时自动续期。产品只提供 +首次设置、登录和退出,不提供注册、邮箱确认或成员管理。数据库中的账号归属记录只用于 +鉴权与数据隔离,不构成额外的用户账号能力。 + +## 3. 接口清单 + +### 公共与认证 + +| Method | Path | 用途 | +| --- | --- | --- | +| GET | `/api/health` | 健康状态与运行就绪度 | +| GET | `/api/auth/status` | 登录状态、bootstrap 状态和 CSRF Token | +| POST | `/api/auth/bootstrap` | 首次设置本机管理员用户名和密码;完成后不再开放 | +| POST | `/api/auth/login` | 浏览器用户名密码登录 | +| POST | `/api/auth/refresh` | 刷新浏览器会话 | +| POST | `/api/auth/logout` | 退出浏览器会话 | +| POST | `/api/auth/cli-login` | 创建 CLI 管理员会话 | +| POST | `/api/auth/cli-refresh` | 刷新 CLI 用户会话 | + +### 运维管理 + +| Method | Path | 身份 | 用途 | +| --- | --- | --- | --- | +| GET | `/api/admin/status` | 本机管理员 | 脱敏的系统与持久化状态 | +| GET | `/api/admin/usage-budget` | 本机管理员 | 当前并发和当日付费工作流尝试次数 | +| GET | `/api/admin/audit-events` | 本机管理员 | 查询关键操作和敏感导出的脱敏审计记录 | +| GET | `/api/admin/workspace-export` | 本机管理员 | 导出排除运行时内部字段的私密业务数据包 | +| GET | `/api/providers/status` | 本机管理员 | 只读配置状态,不调用外部服务 | +| GET | `/api/provider-runs` | 本机管理员 | 按条件查询 Provider 运行记录 | +| GET | `/api/provider-runs/:provider_run_id` | 本机管理员 | 查询单次运行与步骤 | + +以下探针均要求本机管理员登录,并可能调用外部服务、产生额度或费用: + +```text +POST /api/providers/web-search/probe +POST /api/providers/datapro/probe +POST /api/providers/model/probe +POST /api/providers/openviking/probe +POST /api/providers/supabase/probe +``` + +### 销售业务 + +| Method | Path | 身份 | 用途 | +| --- | --- | --- | --- | +| GET | `/api/sales-goals` | 本机管理员 | 查询销售目标 | +| POST | `/api/sales-goals` | 本机管理员 | 创建销售目标 | +| GET | `/api/sales-goals/:goal_id/target-enterprises` | 本机管理员 | 查询目标企业池 | +| POST | `/api/sales-goals/:goal_id/company-search` | 本机管理员 | 通过专业数据和公开来源检索企业 | +| POST | `/api/sales-goals/:goal_id/target-enterprises` | 本机管理员 | 将已核验候选企业加入目标池 | +| GET | `/api/target-enterprises/:enterprise_id` | 本机管理员 | 企业、档案、资料和问答聚合详情 | +| GET | `/api/target-enterprises/:enterprise_id/progress` | 本机管理员 | 查询销售进展 | +| GET | `/api/target-enterprises/:enterprise_id/dossiers` | 本机管理员 | 查询企业档案版本 | +| POST | `/api/target-enterprises/:enterprise_id/dossiers` | 本机管理员 | 创建异步档案 Agent 任务;严格函数提交和服务端质量门禁均通过后才保存新版本 | +| GET | `/api/dossiers/:dossier_id` | 本机管理员 | 查询档案正文和公开引用 | +| GET | `/api/target-enterprises/:enterprise_id/materials` | 本机管理员 | 查询已导入资料 | +| GET | `/api/target-enterprises/:enterprise_id/materials/sources` | 本机管理员 | 查询资料同步源 | +| GET | `/api/target-enterprises/:enterprise_id/materials/sync-state` | 本机管理员 | 查询同步游标与索引状态 | +| POST | `/api/target-enterprises/:enterprise_id/materials/import` | 本机管理员 | 导入一份获授权资料 | +| GET | `/api/feishu-import/status` | 本机管理员 | 查询本机飞书 CLI 导入能力状态 | +| POST | `/api/target-enterprises/:enterprise_id/materials/feishu-import` | 本机管理员 | 创建受控的飞书会话或云文档导入任务 | +| GET | `/api/target-enterprises/:enterprise_id/materials/feishu-import/:task_id` | 本机管理员 | 查询当前后端进程中的导入任务进度 | +| POST | `/api/target-enterprises/:enterprise_id/materials/source-action` | 本机管理员 | 暂停、恢复或删除同步源 | +| POST | `/api/target-enterprises/:enterprise_id/materials/sync-openviking` | 本机管理员 | 创建异步 OpenViking 同步任务 | +| GET | `/api/target-enterprises/:enterprise_id/qa` | 本机管理员 | 查询企业问答历史 | +| POST | `/api/target-enterprises/:enterprise_id/qa` | 本机管理员 | 基于已保存证据问答 | +| POST | `/api/target-enterprises/:enterprise_id/qa/commit-memory` | 本机管理员 | 将当前问答会话提交到长期记忆 | + +### 后台任务 + +| Method | Path | 身份 | 用途 | +| --- | --- | --- | --- | +| GET | `/api/jobs` | 本机管理员 | 按 `job_type`、`status`、`entity_id` 查询任务 | +| GET | `/api/jobs/:job_id` | 本机管理员 | 查询公开任务状态 | +| POST | `/api/jobs/:job_id/cancel` | 本机管理员 | 请求安全取消 | +| POST | `/api/jobs/:job_id/retry` | 本机管理员 | 显式重试允许重试的任务 | + +## 4. 主要请求体 + +创建销售目标: + +```json +{ + "name": "华东新能源客户拓展", + "description": "跟进已授权范围内的目标企业", + "keywords": ["新能源汽车", "供应链"] +} +``` + +企业检索与加入: + +```json +{ "query": "企业完整名称" } +``` + +```json +{ "company_id": "company_..." } +``` + +工作台不会根据一个未核验名称虚构企业主体;专业数据未返回可确认主体时,请求失败 +或返回待确认状态,不能把该结果当成真实企业档案。 + +生成档案和同步 OpenViking 可传幂等键: + +```json +{ "idempotency_key": "client-generated-stable-key" } +``` + +导入资料至少需要标题和正文。调用方应同时提供稳定来源标识,保证增量导入和去重: + +```json +{ + "title": "获授权的客户沟通纪要", + "source_type": "feishu_chat", + "source_external_id": "stable-source-id", + "source_version": "source-version", + "source_url": "", + "raw_text": "已获得处理授权的正文", + "occurred_at": "2026-07-23T10:00:00.000Z" +} +``` + +`source_type`、稳定外部 ID 和版本的具体生成方式由飞书导入脚本负责。API 不负责绕过飞书 +权限,也不接受调用方导入无权处理的内容。 + +前端飞书导入只接受两类请求: + +```json +{ "source_kind": "document", "target": "https://example.feishu.cn/wiki/..." } +``` + +```json +{ + "source_kind": "conversation", + "target": "联系人姓名或 oc_ 开头的会话 ID", + "start": "2026-07-01", + "end": "2026-07-26" +} +``` + +云文档目标必须是完整的 `https://` 飞书或 Lark 云文档/知识库链接;会话目标不接受 +Open ID。该任务调用本机已授权的 `lark-cli`,不会接收或返回飞书令牌。启用前必须显式设置 +`FEISHU_CLI_IMPORT_ENABLED=true`。任务进度当前只保存在 API 进程内存中,服务重启后 +无法继续查询旧任务;已经完成的 OpenViking 正文与 Supabase 同步元数据不受影响。 + +资料源操作: + +```json +{ + "action": "pause", + "source_id": "sync_source_..." +} +``` + +`action` 仅允许 `pause`、`resume` 或 `delete`。删除会同时尝试删除对应 OpenViking +资源;长期记忆删除失败时整体失败关闭。 + +资料问答: + +```json +{ "question": "根据已保存资料,客户最近关注的事项是什么?" } +``` + +问答只使用 Supabase 中当前企业已保存的档案,以及 OpenViking 中该企业的飞书资料召回结果和 Session 上下文,不在问答阶段新增联网事实。问答正文不重复写入 Supabase。 + +## 5. 异步任务语义 + +档案生成和 OpenViking 批量同步返回 `202 Accepted` 和公开 Job DTO: + +```json +{ + "id": "job_...", + "job_type": "sales_dossier_generation", + "status": "running", + "stage": "collecting_evidence", + "stage_label": "正在收集可信资料", + "stage_detail": "正在核验专业资料 2/4", + "progress": 8, + "entity_id": "company_...", + "attempt_count": 1, + "max_attempts": 3, + "retryable": false, + "error": null, + "result": null +} +``` + +任务由独立 Worker 原子领取并持有租约。运行中取消先进入 `cancelling`;只有 Worker 在 +当前外部调用返回后的安全检查点确认,任务才变为 `cancelled` 并释放付费并发名额。 +任务只对超时、限流、网络和上游临时故障执行有界退避重试。档案任务逐项保存已完成的只读 +查询和证据包,重试时只继续未完成查询,不重复已经成功的 Provider 调用;鉴权、配置、请求 +校验和内容门禁错误不会自动重放。`stage_detail` 只包含用户可理解的当前动作和完成计数,不 +暴露 Provider 请求、检查点、Worker、租约或错误栈。 + +`PAID_WORKFLOW_MAX_CONCURRENCY` 和 `PAID_WORKFLOW_DAILY_LIMIT` 控制的是工作流并发和 +尝试次数,不等于 AFP、Token 或金额预算。 + +## 6. 引用与隐私边界 + +档案和问答以段落关联 `citation_ids`。公开 `citations` 只包含可展示的来源标题、链接、 +日期和质量标签;以下字段不得出现在业务 DTO: + +- Agent Plan Key、Supabase Service Role Key 或 Authorization Header; +- Provider 原始响应和完整提示词; +- OpenViking 内部 URI、资源引用和 namespace; +- Worker ID、租约、付费预约编号; +- 仅供运维使用的 `raw_ref` 和内部冲突明细。 + +档案生成要求专业/官方证据、可追溯公开链接和时效证据。注册资本、营收、净利润、融资、 +估值以及明确司法或处罚事实必须满足高风险双来源规则;证据不足或相互冲突时任务失败, +不能生成无引用结论。 + +## 7. 常用错误 + +| HTTP | `error.code` | 含义 | +| --- | --- | --- | +| 400 | `bad_request` | JSON 或字段不合法 | +| 401 | `authentication_required` | 未登录或 CLI 会话过期 | +| 403 | `insufficient_role` / `csrf_failed` | 管理员身份状态异常或 CSRF 校验失败 | +| 404 | `not_found` / `*_not_found` | 路由或当前工作区对象不存在 | +| 409 | `already_exists` | 状态冲突 | +| 422 | `job_type_unsupported` | 不支持的任务或动作 | +| 429 | `paid_workflow_*` / `*_rate_limit_exceeded` | 工作流保护或请求限流 | +| 503 | `runtime_not_ready` / `*_unavailable` | 生产配置、队列或 Provider 不可用 | +| 500 | `internal_error` | 未预期服务错误 | + +客户端应记录 `meta.request_id`,并优先根据 HTTP 状态和 `error.code` 处理,不应解析错误文案。 + +## 8. 数据导出边界 + +`GET /api/admin/workspace-export` 只允许本机管理员调用,并通过 `Cache-Control: no-store` +返回当前工作区的目标、企业、公开档案、资料正文、资料消息项、同步游标和问答。 +响应不包含身份令牌、Provider 原文、完整提示词、OpenViking 内部 URI、Job、Worker、 +租约或付费预约。该数据包仍包含客户沟通等私密业务内容,应使用 Skill 的 +`export-workspace.mjs` 写入 `0600` 私有文件,禁止提交到公开仓库。 + +## 9. 审计边界 + +`GET /api/admin/audit-events` 只允许本机管理员调用,支持按 `action`、 +`entity_type`、`entity_id` 精确筛选和 `limit` 限制。关键业务写操作、Provider 探测 +和工作区导出会记录操作者、动作、业务实体、请求编号与结果状态;审计事件不记录请求 +正文、密码、Token、API Key、Provider 原文或 OpenViking 内部引用。 diff --git a/demohouse/sales-intelligence-workbench/docs/architecture/dossier-agent.md b/demohouse/sales-intelligence-workbench/docs/architecture/dossier-agent.md new file mode 100644 index 00000000..319306f6 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/docs/architecture/dossier-agent.md @@ -0,0 +1,185 @@ +# 档案 Agent 工程设计 + +## 目标与边界 + +档案生成属于需要理解非结构化证据、处理来源冲突并给出销售判断的复合任务,适合由单 Agent +负责证据综合。企业搜索、数据读取、证据清洗、任务队列、权限和最终校验仍由确定性代码控制, +不交给模型自由决定。 + +本项目不创建云端高代码 Agent,也不引入多 Agent 编排。档案 Agent 是后端内一个有界运行单元: + +```text +初始分主题检索 + -> 持久化检查点:每个已完成查询立即保存,重试时只继续未完成查询 + -> 覆盖评估:主体 / 经营 / 近期事件 / 风险 / 招采项目 / 来源独立性 + -> 有缺口:按缺失主题进行最多 4 次有界补充检索 + -> 已核验证据 + -> 事实规划:plan_sales_dossier + -> 规划门禁:六章节 / 完整句子 / 本章 Evidence ID 白名单 / 去重 + -> 后端确定性组装六章正文并派生引用 + -> 内容门禁:章节完整 / 可读性 / 主体 / 高风险事实 + -> 通过:公开视图复验 / 报告指纹判重 / 原子保存新版本 + -> 不通过:把有限错误返回同一规划函数,最多定点修订两次 + -> 仍不通过或调用失败:任务失败,不保存档案 +``` + +这种设计遵循单 Agent 优先、明确工具、清晰指令、有界循环、输出护栏和可评测运行轨迹的工程原则: + +- [火山方舟 Responses API 工具调用](https://www.volcengine.com/docs/82379/1958524?lang=zh) +- [OpenAI:A practical guide to building agents](https://openai.com/business/guides-and-resources/a-practical-guide-to-building-ai-agents/) +- [Anthropic:Effective context engineering for AI agents](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents) +- [OpenAI:Working with evals](https://developers.openai.com/api/docs/guides/evals) +- [OpenAI:Trace grading](https://developers.openai.com/api/docs/guides/trace-grading) +- [Open Deep Research:有界研究循环与完成信号](https://github.com/langchain-ai/open_deep_research) +- [STORM:先生成提纲、再按章节检索与写作、最后润色去重](https://github.com/stanford-oval/storm) +- [GPT Researcher:规划、研究执行、审阅与发布分工](https://github.com/assafelovic/gpt-researcher) +- [OpenSanctions:多属性实体匹配与弱别名边界](https://www.opensanctions.org/docs/api/matching/) +- [RAGChecker:原子事实级检索与生成评测](https://github.com/amazon-science/RAGChecker) +- [RefChecker:声明抽取、逐声明核验与聚合](https://github.com/amazon-science/RefChecker) +- [DeepResearch Bench:事实—引用支持和有效引用评测](https://github.com/Ayanami0730/deep_research_bench) + +## 上下文与输出预算 + +上下文窗口和输出窗口都是有限资源。持久化证据包保留完整来源,供服务端引用校验、审计和版本比较; +模型只接收确定性的高信号投影: + +- 最多 10 条证据,专业数据集与联网公开来源各最多 5 条; +- 工商、风险和经营类专业来源按章节要求优先进入上下文; +- 每条专业摘要最多 700 字符,公开摘要最多 500 字符; +- 不传输 `raw_ref`、检索参数、内部 URI、Provider 响应或其他与生成决策无关的字段; +- 摘要最多 160 字符、每章公开正文最多 440 字符、单次规划文本最多 260 字符、记忆摘要最多 200 字符; +- 事实规划采用固定预算:每章恰好 1 个完整段落,六章合计 6 个段落;每章默认选择一个最短直接 Evidence Atom, + 只有高风险事实、关键数字或确需跨来源组合时才增加其他 Atom; +- 来源覆盖不是固定数字门槛:系统按 `independence_key` 统计可用来源并把统计量提供给 Agent, + 但不会把整份报告必须引用多少个来源作为通过条件。每个事实只要求最少且直接的支撑;证据确实少时 + 缩短报告,不会因企业规模、章节数量或报告长度补入弱来源; +- 不设置每章最低字数,也不按企业规模推断信息量;一条证据只能支持一句完整事实时就保持简短,禁止用套话补长度; +- 整个 Agent 最多调用模型 3 次:首次提交完整六章节规划,必要时最多两次定点修订。首次通过即停止; + 模型不再执行独立成稿或成稿返修,避免后一步随机改写已通过的事实、章节和引用。 + +名称形如“品牌(中国)投资有限公司”时,系统会把括号前品牌名作为 `alias_scoped` 检索别名, +同时保留法定主体查询。品牌新闻只能以集团或品牌相关动态使用,不能自动归属于该法定主体;正文必须 +明确主体边界。 + +证据投影只减少模型上下文,不改变服务端证据白名单。最终引用的直接支撑关系、主体归属和关键数字 +冲突仍按完整证据包校验,不能通过裁剪上下文绕过质量门禁。 + +## 单函数规划与确定性组装协议 + +档案 Agent 只使用一个严格函数,不创建自由协作的多 Agent,也不把同一份事实再次交给模型自由成稿: + +1. `plan_sales_dossier` 固定提交六章节,每章只允许 `text + evidence_ids`。每段可以包含 + 1–3 个紧密相关的完整句子,但只能围绕本章一个主题。模型不能提交 `quote`、 + `citation_id`、URL、引用位置或来源元数据。服务端根据本章 Evidence ID 白名单找到对应 Atom, + 再从 Atom 中确定性派生连续 `quote`、`citation_id`、`evidence_spans` 和分段引用,避免模型伪造或改写证据。 + `source_usage_requirements` 只报告本次可用的独立专业来源和公开来源数量,帮助把直接相关来源 + 分配到最匹配章节;它不设置整份报告引用数量门槛。相同站点或相同专业数据库组的重复记录不会 + 被当成多个独立来源。 +2. 服务端把每个已通过的章节规划按固定顺序组装进正文,保留完整句子,并根据 + 已选 Atom 派生章节和分段引用。组装器无法删除章节、清空章节、写入“暂无” + 占位内容,也不会增加、替换或借用引用。 +3. 组装结果继续通过完整的正文、来源职责、主体、高风险事实和公开视图门禁。若失败,有限错误与 + 被拒绝的完整规划返回 `plan_sales_dossier`,只允许修订点名条目;六章仍必须全部存在。 + +每次请求都使用: + +- `tools` 中唯一的 Function; +- `strict: true`; +- `tool_choice: "required"`; +- `store: false`; +- 固定六章节对象; +- 规划阶段每章的 `evidence_ids` 枚举为本次证据包中该章允许的实际 Atom ID; +- 单次函数输出预算为 2400 Token;固定六个段落后不再允许模型扩张成 12 个条目,降低等待时间和 + 六章 JSON 被截断的概率。 + +只有 `status=completed`、且恰好存在一次同名函数调用时才解析参数。`incomplete`、`failed`、 +缺少函数调用、错误函数名、多次调用或参数无法解析都按结构化失败处理,不能从普通文本猜测结果。 +事实规划失败时不能生成报告;确定性组装器不得增加规划外的企业事实、数字、事件或采购意向。 + +## 服务端质量门禁 + +函数参数符合 Schema 只是第一层,不代表内容正确。保存前必须同时满足: + +1. 固定六章节完整且顺序正确,事实规划符合严格 Schema,确定性组装结果符合固定正文结构; +2. 前五章至少具有一个完整事实或判断,“建议行动”至少有一项具体动作,但不以最低字数或来源条数凑内容; +3. 每条规划声明及其组装后正文的引用 ID 全部来自本次白名单; +4. 服务端派生的每个 `quote` 必须是对应来源摘要中的连续原文;事实新增的完整日期、实质数值、英文实体、 + 机构名称和事件类型必须能在该条声明实际选择的有界来源摘要中找到支持。`quote` 用于证明引用 + 选择和原文连续性,不把最多 120 字的展示短摘录误当成完整证据块; +5. 正文只能使用本章已批准条目,最终引用由服务端派生;组装器不新增日期、数值或命名实体; +6. 不使用飞书或 OpenViking 内部资料支撑外部事实; +7. 法定主体锚点、品牌/集团边界、专业库职责和公开来源时效符合证据策略; +8. 高风险事实及关键数字满足独立双来源规则;带明确日期的处罚、诉讼、失信、限高或经营异常同样按高风险事实处理; +9. 不含搜索标题残片、名词堆叠、检索状态、接口字段、底层服务名、访问拦截页、残缺数字、通用模板句或重复段落; +10. 规划和最终正文不使用弱相关来源凑数;每个事实只绑定直接支撑它的最少证据; +11. 静态登记经营范围不得写成“延伸至”“扩展至”等时序变化;只允许中性表述为“经营范围包括”或“登记业务覆盖”; +12. 最终公开 DTO 不包含提示词、函数参数、内部 URI、原始响应、运行诊断或仅供服务端校验的来源元数据。 + +已核验法定名称中的“投资”“建设”等构词不单独视为融资或交付事件;只有名称以外的事实关系词 +才需要在所选来源摘要中出现。每章只有一个完整段落,段落不合格时必须由 Agent 定点修订或让任务 +失败,不能删除章节或用模板补齐。 + +若选中来源明确给出月日、但模型擅自补全年份,服务端可以把完整日期降为来源明确提供的月日。 +该处理只删除不受支持的时间精度,不会推断年份、 +替换来源或修改数值、机构和事件关系;来源连月日也未提供时继续失败。 + +规划中存在可确定处理的纯展示层错误时,服务端只执行单调收敛:连续原文超过 120 字时截短而不改写; +同一条内容至少保留一个有效直接证据时,删除抄写不连续的附加片段;建议行动中的无证据英文缩写 +降为“相关业务”。这些处理不会给外部事实补充日期、数字、机构或事件,也不会在唯一证据无效时 +生成替代内容。风险、机会和建议行动三个分析章节如果仅因“合作、交付、签约、合同、部署、上线、落地”等动作措辞被识别成无证据事件,服务端可以把它们降为“对接、项目推进、事项确认、商务事项、应用、实施”等非事实动作词;只有实际校验错误减少时才接受,事实章节不执行这种改写。 + +模型提交通过后还要执行一次最终公开视图门禁。系统先把引用集合收敛为六章节实际使用的来源, +未被正文引用的证据不能替代主体锚点或事实支撑。最终用户视图必须仍实际引用能够以法定名称或统一 +社会信用代码确认目标主体的专业来源;品牌、集团或简称来源只能支撑明确写出主体边界的事实。来源 +中的总公司、分公司和子公司必须按完整登记名称分别绑定成立日期、注册地址、注册号、统一社会信用代码等身份字段;正文未逐字点名分支机构时,分支机构字段不能作为目标法定主体字段使用。 +正文点名分公司或子公司时还必须实际引用该分支机构自己的工商记录,不能用总公司记录补写分支布局或区域覆盖。少量中标或公告只能支撑具体项目事实,不能外推企业整体业务转型;近期公开动态也不能把中标密度写成来源未披露的采购需求或采购意向,这类内容只能在机会章节中明确标注为保守判断。 +只因名称相似命中的另一法定主体工商记录会在 Agent 上下文前整体排除;没有直接关联证据时,不得用相似名称推断总分公司、子公司或集团关系。 +普通事实允许由一个直接、高质量来源支撑,高风险事实和关键数字仍执行独立双来源规则;整份报告 +只有在存在更多直接可用的独立来源时才提高覆盖目标。最终复验使用与 Agent 输入一致的已清洗、有界来源摘要,不得在复验前再次压缩成 +单一要点而丢失正文实际使用的后半段证据。报告摘要从最终可见的“近期公开动态”和“销售机会判断” +重新构建,并只按完整句子收敛到展示上限,不能在姓名、金额、项目或动作中间硬截断,也不能保留正文或最终引用已经移除的事实。风险章节只能写来源直接披露的风险,或把明确事实转为具体待核验事项;不得从单个项目、单笔金额或少量公告外推企业整体的订单、客户、收入或业务结构。历史记录如果不再满足该门槛,在列表和按 ID 详情接口中都不会向用户展示, +也不会再由规则代码改写、补齐或包装成新的六章节报告。 +验证码、建站案例、主体不匹配、残缺内容及会在公开展示中被移除的来源不能进入 Agent 上下文。 + +“资料截至”只根据最终实际引用计算:优先使用来源发布时间或更新时间;联网来源摘要包含更晚且不晚于 +本次生成时间的完整事件日期时,以该事件日期为准。这样可以处理搜索聚合页元数据早于其摘要内事件的 +情况,也不会拿报告生成时间冒充资料时间。 + +规划或确定性组装校验失败时,Agent 会收到有限校验错误和被拒绝的结构化规划,只允许定点修复 +服务端会先在本章白名单 Evidence Atom 中确定性重选能够直接支持正文的最小证据组合,只接受可以减少实际校验错误的组合;仍不合格时,错误点名条目的文本、类型或证据片段,并清空被点名章节的旧正文和证据 ID,避免模型复制已知错误,同时继续保留其他章节。整个运行最多 3 次模型调用, +不存在无限循环、模型二次自由成稿、文本 JSON 修补或本地规则报告兜底。 + +## 任务、恢复与可观测性 + +- 档案任务先持久化入队,Worker 原子领取、续租和心跳后才执行。 +- 付费调用前使用工作区原子预约;取消请求在安全检查点确认,避免并行重复调用。 +- 每个完成的 DataPro/联网搜索查询和最终证据包都会写入内部检查点。Worker、API 或网络发生 + 可重试故障后,任务按退避等待重新入队,只继续未完成查询;检查点过期或用户发起新的刷新任务时 + 重新采集,避免把旧资料冒充最新结果。 +- 每次规划或修订尝试作为 Provider Run 步骤记录状态、请求 ID、Token、耗时和脱敏错误,不记录 + 完整证据、模型正文、函数参数或密钥。 +- DataPro、联网搜索和严格函数模型调用都只对超时、限流、网络错误和上游临时故障执行有界重试; + 鉴权、配置和内容门禁错误不做盲目重试。模型传输层默认重试一次,语义修订仍受 Agent 三次调用预算控制。 +- API/Worker 中断后的任务与 Provider Run 由数据库迁移和恢复逻辑闭合;只有成功通过最终门禁的 + 档案才写入版本链。 +- 证据哈希不变时跳过模型;证据哈希变化但最终正文及实际使用来源的报告指纹不变时保留原版本, + 不把同一份公开报告保存成新的版本号。 + +## 发布验收 + +离线门禁必须覆盖: + +- 严格函数请求协议及响应状态分类; +- 事实规划的六章节 Schema、逐字证据片段、确定性六章组装与服务端引用派生; +- 专业/公开来源均衡选择、上下文字段白名单和序列化体积上限; +- 覆盖缺口触发有界补充检索、同源去重和自适应来源覆盖; +- 首次规划成功、一次或两次定点修订成功、三次连续失败、响应不完整和错误函数参数; +- 引用白名单、法定主体锚点、品牌边界、高风险事实、正文质量和公开 DTO 脱敏; +- 搜索标题残片、无谓语片段、模板话术、重复事实和清洗后公开视图的实际引用与摘要接地校验; +- 来源元数据与摘要日期不一致、未支持日期/金额/机构、规划引入无证据实体等回归样例; +- 不合格旧档案隐藏且不得被模板合成正式报告; +- 任务取消、传输层瞬时失败重试、Worker 恢复、证据幂等、报告指纹幂等和失败不落库。 + +真实链路验收必须使用已获授权的企业,核对 DataPro、豆包搜索、三次以内的 Agent 运行、 +Supabase 回读、引用打开、版本号和前端展示。HTTP 成功、任务成功或返回六章节都不能单独作为 +质量通过;必须逐段核对语句完整性、事实与引用是否一致,以及销售判断是否确实由前述事实推出。 diff --git a/demohouse/sales-intelligence-workbench/docs/database/supabase-schema.md b/demohouse/sales-intelligence-workbench/docs/database/supabase-schema.md new file mode 100644 index 00000000..8ce0a986 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/docs/database/supabase-schema.md @@ -0,0 +1,109 @@ +# Supabase Schema 说明 + +更新时间:2026-07-26 + +正式 Schema 的唯一事实来源是: + +```text +supabase/migrations/ +``` + +不要手工拼接历史 SQL 文件。安装、升级和回滚检查均应使用 Skill 中的版本化迁移脚本。 + +## 数据域 + +| 数据域 | 表 | +| --- | --- | +| 身份与数据归属 | `app_workspaces`、`app_users`、`app_workspace_members`(底层表名沿用历史命名,仅用于单管理员鉴权与隔离) | +| Provider 配置 | `provider_connections` | +| 销售业务 | `sales_goals`、`sales_companies`、`sales_target_enterprises`、`sales_company_search_results`、`sales_progress_snapshots` | +| 档案与引用 | `sales_dossier_records`、`sales_dossier_citations` | +| 资料同步索引 | `sales_materials`、`sales_openviking_refs` | +| 隔离迁移归档 | `sales_qa_messages_legacy`(不参与运行、备份或恢复) | +| 任务与调用记录 | `jobs`、`paid_workflow_reservations`、`provider_runs`、`provider_run_steps` | +| 同步与审计 | `sync_sources`、`sync_checkpoints`、`audit_events` | +| 迁移历史 | `schema_migrations` | + +## 工作区边界 + +- 每张业务表都有 `workspace_id`。 +- Repository 的每次读取、更新和删除都强制带 `workspace_id`。 +- 数据库启用并强制执行 RLS;本机管理员只能访问所属工作区。 +- `service_role` 只允许后端使用,永远不能进入浏览器构建产物。 +- 文本主键虽然全局唯一,跨工作区写入仍会显式检查 ID 冲突,避免 service role 误覆盖其他租户。 + +## 事务写入 + +以下 RPC 由 `service_role` 调用,均在单个数据库事务中完成: + +- `persist_sales_dossier(workspace_id, dossier)`:写入档案及全部引用。 +- `persist_provider_run(workspace_id, run)`:写入 Provider 运行记录、关联 Job 及全部步骤。 +- `reserve_paid_workflow(...)`:在工作区级数据库锁内清理过期预约、校验并发/每日次数并创建任务。 +- `finish_paid_workflow(...)`:在一个事务中结束任务并释放对应并发名额。 +- `get_paid_workflow_usage(...)`:返回当前并发、当日尝试次数和任务类型分布,不包含密钥或 Provider 原文。 +- `enqueue_sales_job(...)`:按工作区和幂等键创建等待任务,不提前占用付费并发。 +- `claim_sales_job(...)`:使用 `FOR UPDATE SKIP LOCKED` 原子领取任务并建立 Worker 租约。 +- `heartbeat_sales_job(...)`:更新业务阶段和进度,同时延长 Worker 租约及已有付费预约。 +- `release_sales_job_claim(...)`:仅在尚未建立付费预约时自动重排;预约后的中断必须显式确认重试。 +- `request_cancel_sales_job(...)`:等待任务立即结束;运行任务只记录取消请求并保留 Worker 租约和付费预约。 +- `acknowledge_cancel_sales_job(...)`:由持有租约的 Worker 在安全检查点确认取消,并原子释放付费预约。 +- `retry_sales_job(...)`:把符合条件的失败/取消任务重新放回队列。 + +非法企业、跨工作区 ID 冲突或子记录错误会使整笔事务回滚,不留下半截档案或半截调用链。 + +档案记录还保存 `version_no`、`previous_dossier_id`、`evidence_hash`、`dossier_fingerprint`、`change_status`、`data_as_of`、`generated_at` 和 `evidence_pack_json`。同一企业的有效版本号在工作区内唯一,上一版本只能指向同一工作区中的档案。 + +## Supabase 与 OpenViking 边界 + +- Supabase 保存企业、销售目标、档案、公开引用、任务、权限、审计,以及飞书来源/游标/内容指纹/OpenViking URI 等同步元数据。 +- 飞书会话与云文档正文只保存在 OpenViking;`sales_materials` 不重复保存正文。 +- 资料问答消息和长期上下文由 OpenViking Session 保存;Supabase 只保存会话索引与 Provider Run。 +- `202607280001` 会把旧版 `sales_qa_messages` 改名为只允许 `service_role` 访问的 + `sales_qa_messages_legacy`。迁移不删除历史数据,但当前运行、备份和恢复均不会读取或写入该归档。 +- `202607280002` 为项目自有的 `schema_migrations` 启用 RLS,并撤销普通角色权限。 +- `202607290001` 在 Job 失败或取消时同步结束仍处于运行中的 Provider Run 与步骤,并自动修复旧的悬挂记录。 +- `202607300001` 为 Job 增加内部检查点与安全进度明细,允许可重试故障退避重排并只继续未完成的档案采集查询。 +- `health_check` 是火山引擎 Supabase 的平台保留表,不属于项目迁移;验收脚本只核验它未向 + `anon` 或 `authenticated` 开放,不尝试修改其所有权或 RLS。 + +## 迁移历史 + +| 版本 | 作用 | +| --- | --- | +| `202607210001` | 多租户核心表、约束、索引和更新时间触发器 | +| `202607210002` | RLS、成员权限函数与 Data API grants | +| `202607210003` | 复合外键删除行为及 Provider 运行引用修正 | +| `202607210004` | Data API 事务持久化 RPC | +| `202607210005` | 修复档案引用 RPC 的列映射 | +| `202607210006` | 补齐 public schema 外键覆盖索引 | +| `202607210007` | 资料同步来源、内容版本和 OpenViking 映射工程化 | +| `202607210008` | 档案证据版本字段、版本约束和 Job 关联的原子调用记录 | +| `202607230001` | 付费工作流原子预约、并发保护、每日次数保护和过期名额回收 | +| `202607230002` | 持久化异步队列、Worker 领取/租约/心跳、进度与安全重试 | +| `202607230003` | 运行任务安全取消检查点、取消期间租约续期与付费预约原子释放 | +| `202607280001` | 隔离旧问答正文表,确立 OpenViking 为资料问答内容的唯一运行时存储 | +| `202607280002` | 项目迁移元数据表启用 RLS,并仅授权后端 `service_role` | +| `202607290001` | Job 终止时自动闭合 Provider Run 与运行中步骤,并修复历史悬挂记录 | +| `202607300001` | 持久化 Job 检查点与进度明细,可重试故障退避重排且保留已完成采集结果 | + +迁移器会读取远端 `schema_migrations`,只执行未应用文件;任何已应用迁移都不应被就地改写,应新增后续修正迁移。 + +运行前必须把迁移应用到 `202607300001`。后端找不到该迁移时会返回 `503`,不会在缺少任务检查点、重试与调用记录一致性保护时继续处理业务。 + +应用迁移后可执行 `smoke-paid-workflow.mjs`。该检查会在数据库事务中调用预约和释放 RPC, +验证 Job/预约状态后回滚,不留下测试记录,也不调用 Agent Plan 外部能力。 + +再执行 `smoke-async-job-queue.mjs`,在事务内验证入队、领取、心跳、预约前安全重排、付费预约、完成释放,以及“请求取消时不提前释放、Worker 确认后释放”;检查结束同样回滚且 Provider 调用数为零。 + +## 备份与恢复 + +项目的标准备份与恢复流程不依赖数据库直连权限或 `db dump`,统一采用“版本化迁移 + +Data API JSON 数据包”方案: + +- `npm run db:backup` 导出工作区数据、已应用迁移、行数和 SHA-256。 +- `npm run db:restore -- --backup-dir ` 默认只验证。 +- 实际恢复只能指向另一套空云工作区,且不会迁移 Auth 用户或 Provider 密钥值。 + +具体命令见根目录 `README.md` 和 +`skills/sales-intelligence-workbench/SKILL.md`。恢复必须指向隔离的空工作区,禁止覆盖 +正在运行的生产库。 diff --git a/demohouse/sales-intelligence-workbench/docs/deployment/self-hosting.md b/demohouse/sales-intelligence-workbench/docs/deployment/self-hosting.md new file mode 100644 index 00000000..36927da6 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/docs/deployment/self-hosting.md @@ -0,0 +1,121 @@ +# 单工作区自托管部署 + +本文说明 `0.10.0` 自托管开源版的支持边界和推荐部署方式。当前版本支持单工作区、单管理员,以及本机或受控内网部署;不提供公网托管 SaaS、多人协作或 SLA。公网开放前必须完成正式域名、HTTPS 和真实链路验收。 + +## 1. 进程与网络边界 + +应用包含两个长期进程: + +- API:同源提供前端和 `/api`。 +- Worker:领取 Supabase 持久化任务并调用 Provider。 + +推荐让 API 只监听 `127.0.0.1:8787`,由 Nginx、Caddy 或云负载均衡器终止 TLS。不要把 Node.js HTTP 端口直接暴露到公网。Worker 不开放网络端口。 + +## 2. 公网配置 + +私密配置默认位于: + +```text +~/.config/sales-intelligence-workbench/credentials.env +~/.config/sales-intelligence-workbench/runtime.env +``` + +公网反向代理至少需要在 `runtime.env` 中设置: + +```dotenv +HOST="127.0.0.1" +PORT="8787" +HTTP_AUTH_ENABLED="true" +AUTH_COOKIE_SECURE="true" +ALLOWED_ORIGINS="https://sales.example.com" +TRUST_PROXY="true" +ASYNC_JOBS_ENABLED="true" +JOB_WORKER_LEASE_SECONDS="600" +PROVIDER_CIRCUIT_BREAKER_ENABLED="true" +PROVIDER_CIRCUIT_BREAKER_FAILURE_THRESHOLD="5" +PROVIDER_CIRCUIT_BREAKER_COOLDOWN_SECONDS="60" +``` + +工作台会拒绝非 HTTPS 来源或缺少明确来源白名单的代理部署配置。 + +## 3. Nginx 示例 + +证书路径和域名由部署者替换: + +```nginx +server { + listen 443 ssl http2; + server_name sales.example.com; + + ssl_certificate /etc/letsencrypt/live/sales.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/sales.example.com/privkey.pem; + client_max_body_size 1m; + + location / { + proxy_pass http://127.0.0.1:8787; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 120s; + } +} +``` + +同时关闭公网对 `8787` 端口的访问,只允许反向代理访问回环地址。 + +## 4. 进程托管 + +本机体验可使用 Skill 的 `start.mjs` 和 `stop.mjs`。长期运行建议由 systemd、容器编排器或等价进程管理器分别托管 API 和 Worker,并加载同一组 `runtime.env` 与 `credentials.env`。 + +以下为 API 单元的核心结构,路径和运行用户必须替换为部署机实际值: + +```ini +[Unit] +Description=Sales Intelligence Workbench API +After=network-online.target + +[Service] +Type=simple +User=sales +WorkingDirectory=/home/sales/.local/share/sales-intelligence-workbench/app/backend +Environment=NODE_ENV=production +EnvironmentFile=/home/sales/.config/sales-intelligence-workbench/runtime.env +EnvironmentFile=/home/sales/.config/sales-intelligence-workbench/credentials.env +ExecStart=/usr/bin/node src/server.js +Restart=on-failure +RestartSec=5 +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +``` + +Worker 单元使用相同配置,把 `ExecStart` 改为: + +```ini +ExecStart=/usr/bin/node src/worker.js +``` + +不要同时使用 systemd 和 `start.mjs` 启动同一套进程,否则会出现端口或任务领取冲突。 + +## 5. 健康与发布 + +部署后依次检查: + +```bash +node skills/sales-intelligence-workbench/scripts/doctor.mjs +node skills/sales-intelligence-workbench/scripts/doctor.mjs --live +curl --fail --silent https://sales.example.com/api/health +``` + +`doctor --live` 会真实访问 Provider,可能产生少量用量;应在管理员知情时执行。健康接口只表示 API 进程可响应,不能代替 Worker、数据库迁移和完整业务验收。 + +升级顺序为:备份、只读迁移计划、应用向后兼容迁移、队列 smoke、停止旧进程、替换应用、启动 API 与 Worker、真实只读检查。若新版本失败,先停止 Worker,再恢复上一应用版本;数据库仅使用项目提供的前向迁移或经过验证的独立恢复包,不执行破坏性手工回滚。 + +## 6. 当前不支持 + +- 本项目当前不提供多租户 SaaS 托管边界。 +- 未提供内置 TLS、企业 SSO、MFA 或高可用 Worker。 +- 未完成容量压测和告警配置前,不应对外承诺生产 SLA。 diff --git a/demohouse/sales-intelligence-workbench/frontend/app.js b/demohouse/sales-intelligence-workbench/frontend/app.js new file mode 100644 index 00000000..5bb7e585 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/frontend/app.js @@ -0,0 +1,1996 @@ +(function () { + const $ = (selector, root = document) => root.querySelector(selector); + const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector)); + const defaultApiBase = ["http:", "https:"].includes(window.location.protocol) + ? `${window.location.origin}/api` + : "http://127.0.0.1:8787/api"; + const API_BASE = (window.SALES_WORKBENCH_API_BASE || defaultApiBase).replace(/\/$/, ""); + const TARGET_STATUS_FILTERS = ["全部", "新商机", "初步接触", "需求确认", "商务推进", "成交归档"]; + const MATERIAL_FILTERS = ["全部", "档案", "飞书会话", "云文档"]; + const DOSSIER_SECTION_TITLES = [ + "企业与业务概览", + "经营与业务动态", + "近期公开动态", + "风险与关注事项", + "销售机会判断", + "建议行动", + ]; + const QA_SECTION_HEADING_SOURCE = "结论|依据(?:[((][^))]+[))])?|当前情况|关键发现|风险|建议|下一步|行动(?:项)?|资料缺口|补充说明"; + const QA_SECTION_HEADING_PATTERN = new RegExp(`^(${QA_SECTION_HEADING_SOURCE})[::]\\s*([\\s\\S]+)$`); + const { + collapseRepeatedCitationRuns, + dedupeCitationEntries, + normalizeChineseTypography, + splitReadableBlocks, + } = window.SalesTextFormat; + + let goals = []; + let companies = {}; + + const state = { + activeGoalId: "", + activeCompanyId: "", + selectedDossierId: "", + targetStatusFilter: "全部", + materialFilter: "全部", + supportView: "library", + query: "", + hasSearched: false, + showNewGoal: false, + bootLoading: true, + bootError: "", + auth: { + checked: false, + enabled: false, + authenticated: false, + bootstrapRequired: false, + user: null, + }, + authBusy: "", + authError: "", + authNotice: "", + feishuImportOpen: false, + feishuImportAvailable: null, + feishuImportKind: "conversation", + feishuImportDraft: { target: "", start: "", end: "" }, + feishuImportTask: null, + feishuImportError: "", + busy: "", + qaPendingCompanyId: "", + notice: "", + sidebarNotice: "", + jobsByCompany: {}, + mobileNavigationOpen: false, + qaMessages: [], + qaMessagesByCompany: {}, + }; + let bootGeneration = 0; + let feishuImportPollToken = 0; + const jobPollTokens = new Map(); + + function resetConnectedState() { + goals = []; + companies = {}; + state.activeGoalId = ""; + state.activeCompanyId = ""; + state.selectedDossierId = ""; + state.qaMessages = []; + state.qaMessagesByCompany = {}; + state.jobsByCompany = {}; + state.feishuImportOpen = false; + state.feishuImportAvailable = null; + state.feishuImportTask = null; + state.feishuImportError = ""; + } + + function cookieValue(name) { + const prefix = `${name}=`; + for (const item of String(document.cookie || "").split(";")) { + const trimmed = item.trim(); + if (!trimmed.startsWith(prefix)) continue; + try { + return decodeURIComponent(trimmed.slice(prefix.length)); + } catch { + return trimmed.slice(prefix.length); + } + } + return ""; + } + + async function api(path, options = {}) { + const method = options.method || "GET"; + const headers = { ...(options.headers || {}) }; + if (options.body) headers["Content-Type"] = "application/json"; + if (!["GET", "HEAD"].includes(method)) { + const csrfToken = cookieValue("siw_csrf"); + if (csrfToken) headers["X-CSRF-Token"] = csrfToken; + } + const response = await fetch(`${API_BASE}${path}`, { + method, + credentials: "same-origin", + headers: Object.keys(headers).length ? headers : undefined, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const error = new Error(payload.error?.message || `请求失败:${response.status}`); + error.status = response.status; + error.code = payload.error?.code || "api_error"; + error.details = payload.error?.details || null; + error.requestId = payload.meta?.request_id || ""; + if (response.status === 401 && !options.skipAuthRedirect) { + state.auth.checked = true; + state.auth.enabled = true; + state.auth.authenticated = false; + state.auth.user = null; + queueMicrotask(render); + } + throw error; + } + return payload.data; + } + + function goalPlaceholder(goal) { + const keyword = (goal.keywords || [])[0] || "行业、区域或公司"; + return `输入${keyword}关键词`; + } + + function mapCompanyFromApi(item) { + if (!item) return null; + return { + id: item.id, + name: item.name, + initial: item.initial || item.name?.slice(0, 1) || "企", + location: item.location || "", + industry: item.industry || "企业", + tags: item.tags || [item.industry, item.location].filter(Boolean), + status: item.status, + progress: item.progress, + evidence: item.evidence, + progressLevel: item.progress_level || progressLevelFromStatus(item.status), + updatedAt: formatTime(item.updated_at, item.updatedAt || "尚未更新"), + updates: item.updates || [], + library: item.library || [], + qaAnswer: item.qaAnswer || "", + }; + } + + function mapDossierFromApi(item, options = {}) { + return { + id: item.id, + title: item.title, + summary: item.summary || "", + body: "", + bodyParagraphs: (Array.isArray(item.body) ? item.body : []).map((paragraph) => ({ + text: paragraph.text, + citationIds: paragraph.citation_ids || [], + segments: (paragraph.segments || []).map((segment) => ({ + text: segment.text || "", + citationIds: segment.citation_ids || [], + })), + })), + citations: (item.citations || []).map((source) => ({ + id: source.id, + label: source.label, + kind: source.source_kind, + url: isPlaceholderUrl(source.url) ? "" : source.url || "", + summary: source.summary || source.excerpt || "", + siteName: source.site_name || "", + publishedAt: source.published_at || null, + })), + versionNo: Number(item.version_no || 1), + previousDossierId: item.previous_dossier_id || null, + changeStatus: item.change_status || "initial", + dataAsOf: item.data_as_of ?? null, + generatedAt: item.generated_at || item.created_at || null, + date: formatTime(item.generated_at || item.created_at, item.date || ""), + detailLoadError: Boolean(options.detailLoadError), + }; + } + + function mapMaterialFromApi(item) { + return { + id: item.id, + title: item.title, + summary: item.summary || "", + time: formatTime(item.updated_at, ""), + sourceType: inferMaterialType(item.title, item.source_type), + }; + } + + function mapQaMessage(message) { + const citationEntries = (message.citations || []) + .map((item) => (typeof item === "string" + ? { id: "", label: item } + : { id: String(item.id || ""), label: item.label || "" })) + .filter((item) => item.label); + return { + role: message.role, + text: message.text, + paragraphs: (message.paragraphs || []) + .map((paragraph) => ({ + text: paragraph.text || "", + citationIds: (paragraph.citation_ids || paragraph.citationIds || []).map(String), + })) + .filter((paragraph) => paragraph.text), + citations: citationEntries.map((item) => item.label), + citationEntries, + }; + } + + function apiErrorMessage(_error, fallback) { + return fallback || "操作没有完成,请稍后重试。"; + } + + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function isPlaceholderUrl(value) { + return /(^https?:\/\/)?(www\.)?example\.(com|test)\b/i.test(String(value || "")); + } + + function splitDisplayParagraphs(value, maxLength = 180) { + return splitReadableBlocks(value, maxLength); + } + + function formatTime(value, fallback = "") { + if (!value) return fallback; + const text = String(value); + const normalized = text + .replace(" ", "T") + .replace(/([+-]\d{2})$/, "$1:00"); + const date = new Date(normalized); + if (Number.isNaN(date.getTime())) return fallback || text; + return date.toLocaleString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).replace(/\//g, "-"); + } + + function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + function dossierJobForCompany(companyId) { + return state.jobsByCompany[companyId] || null; + } + + function isActiveJob(job) { + return ["queued", "running"].includes(String(job?.status || "")); + } + + function rememberDossierJob(job, companyId = job?.entity_id) { + if (!job?.id || !companyId || job.job_type !== "sales_dossier_generation") return null; + state.jobsByCompany[companyId] = job; + return job; + } + + function makeIdempotencyKey(action, entityId) { + const random = window.crypto?.randomUUID?.() + || `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `${action}:${entityId}:${random}`; + } + + function dossierRequestStorageKey(companyId) { + return `sales-workbench:dossier-request:${companyId}`; + } + + function dossierRequestIdempotencyKey(companyId) { + const storageKey = dossierRequestStorageKey(companyId); + try { + const existing = window.sessionStorage.getItem(storageKey); + if (existing) return existing; + const created = makeIdempotencyKey("dossier", companyId); + window.sessionStorage.setItem(storageKey, created); + return created; + } catch { + return makeIdempotencyKey("dossier", companyId); + } + } + + function clearDossierRequestIdempotencyKey(companyId) { + try { + window.sessionStorage.removeItem(dossierRequestStorageKey(companyId)); + } catch { + // Storage can be unavailable in hardened browser contexts. + } + } + + async function loadLatestDossierJob(companyId) { + if (!companyId) return null; + const jobs = await api(`/jobs?job_type=sales_dossier_generation&entity_id=${encodeURIComponent(companyId)}&limit=1`); + const latest = Array.isArray(jobs) ? jobs[0] || null : null; + if (latest) { + clearDossierRequestIdempotencyKey(companyId); + rememberDossierJob(latest, companyId); + if (isActiveJob(latest)) monitorDossierJob(latest, companyId); + } + return latest; + } + + function stopJobMonitor(jobId) { + const token = jobPollTokens.get(jobId); + if (token) token.active = false; + jobPollTokens.delete(jobId); + } + + function monitorDossierJob(initialJob, companyId) { + if (!initialJob?.id || !isActiveJob(initialJob) || jobPollTokens.has(initialJob.id)) return; + const token = { active: true }; + jobPollTokens.set(initialJob.id, token); + + void (async () => { + let job = initialJob; + let failures = 0; + try { + while (token.active && isActiveJob(job)) { + await wait(1200); + if (!token.active) return; + try { + job = await api(`/jobs/${encodeURIComponent(job.id)}`); + failures = 0; + } catch (error) { + failures += 1; + if (failures < 5) continue; + if (state.activeCompanyId === companyId) { + state.notice = apiErrorMessage(error, "任务仍在后台执行,但暂时无法更新进度。"); + render(); + } + return; + } + rememberDossierJob(job, companyId); + if (state.activeCompanyId === companyId) render(); + } + + if (!token.active) return; + if (job.status === "succeeded") { + await hydrateCompany(companyId, { loadJob: false }).catch(() => null); + if (state.activeCompanyId === companyId) { + if (job.result?.dossier_id) state.selectedDossierId = job.result.dossier_id; + state.notice = job.result?.action === "no_material_change" + ? "证据未变化,保留当前版本" + : job.result?.version_no + ? `已生成档案 V${job.result.version_no}` + : "已生成最新档案"; + render(); + } + return; + } + if (state.activeCompanyId === companyId) { + state.notice = job.status === "cancelled" + ? "档案生成任务已取消" + : "档案生成失败,可在此重试。"; + render(); + } + } finally { + if (jobPollTokens.get(initialJob.id) === token) jobPollTokens.delete(initialJob.id); + } + })(); + } + + function progressLevelFromStatus(status) { + const text = String(status || ""); + if (/签约|成交|已确认|方案|推进/.test(text)) return 78; + if (/需求确认/.test(text)) return 58; + if (/初步|接触/.test(text)) return 34; + if (/暂无|不足/.test(text)) return 12; + if (/新商机/.test(text)) return 22; + return 42; + } + + function salesStatus(status) { + const text = String(status || ""); + if (/签约|成交|归档|已成交/.test(text)) return "成交归档"; + if (/方案|报价|商务|推进/.test(text)) return "商务推进"; + if (/需求确认|需求/.test(text)) return "需求确认"; + if (/初步|接触/.test(text)) return "初步接触"; + return "新商机"; + } + + function conciseProgressText(item) { + const status = salesStatus(item.status); + const text = String(item.progress || "").replace(/\s+/g, " ").trim(); + if (text && text.length <= 28 && !/最近档案|企业情况|近期动态|销售判断|下一步建议|专业数据库|联网搜索|但|需要/.test(text)) { + return text; + } + const fallback = { + 新商机: "已加入目标企业池,当前无历史资料,待生成最新档案。", + 初步接触: "已完成基础信息了解,尚未形成明确采购计划。", + 需求确认: "已识别数据安全与私有化部署需求,待确认预算和排期。", + 商务推进: "已进入方案沟通阶段,待确认商务条件和决策流程。", + 成交归档: "已完成合作归档,后续关注续约和扩展机会。", + }; + return fallback[status] || "当前进度待补充。"; + } + + function goalStats(count) { + return `${Number(count) || 0} 家企业`; + } + + function sourceRank(source) { + const text = `${source.kind || ""} ${source.label || ""}`; + if (/专业数据|专业数据库|工商|招投标/.test(text)) return 0; + if (/联网搜索|公开|新闻|公告|媒体|官网/.test(text)) return 1; + return 2; + } + + function displaySourceKind(kind) { + return /专业数据|专业数据库|工商|招投标/.test(String(kind || "")) + ? "专业数据集(DataPro)" + : /联网搜索|公开|新闻|公告|媒体|官网/.test(String(kind || "")) + ? "联网搜索" + : kind || "来源"; + } + + function sourceSiteName(source) { + if (source.siteName) return String(source.siteName).trim(); + try { + return new URL(source.url).hostname.replace(/^www\./i, ""); + } catch { + return "公开网页"; + } + } + + function sourcePublishLabel(source) { + const publishedAt = formatTime(source.publishedAt, ""); + return publishedAt ? `发布于 ${publishedAt}` : "未标注发布时间"; + } + + function professionalSourceDetails(source) { + const knownFieldPattern = /^(?:公司名称|企业名称|统一社会信用代码|注册号|法定代表人|法人姓名|公司组织类型|企业类型|注册地址|成立日期|注册资本|实缴资本|经营状态|登记状态|经营范围|所属行业|参保人数|核准日期|营业期限|自身风险|关联风险|司法案件|涉诉关系|立案信息|开庭公告|法院公告|行政处罚|经营异常|失信被执行人|被执行人|知识产权|专利|商标|著作权|分支机构|股东|主要人员)$/; + const details = []; + const parts = String(source.summary || "") + .split(/[;;]\s*/) + .map((item) => item.trim()) + .filter(Boolean); + for (const item of parts) { + const match = item.match(/^([^::]{1,28})[::]\s*(.+)$/); + const label = match?.[1]?.trim() || ""; + if (match && knownFieldPattern.test(label)) { + details.push({ label, value: match[2].trim() }); + } else if (details.length) { + details[details.length - 1].value += `;${item}`; + } else { + details.push({ label: "数据项", value: item }); + } + } + return details.map((item) => { + const cleanValue = item.value.replace(/[((]\s*$/, "").trim(); + return { + ...item, + value: /日期|时间/.test(item.label) ? formatTime(cleanValue, cleanValue) : cleanValue, + }; + }); + } + + function inferMaterialType(title, explicitType = "") { + const explicit = String(explicitType || "").trim(); + const identity = `${explicit} ${title || ""}`.toLowerCase(); + if (/feishu_(?:p2p|chat|search)|单聊|群聊|消息|会话|沟通|摘录/.test(identity)) { + return "飞书会话"; + } + if (/feishu_doc|云文档|文档|会议|纪要|方案|草案/.test(identity)) { + return "云文档"; + } + return "云文档"; + } + + function normalizeDossierDisplay(sources, paragraphs) { + const orderedSources = [...sources] + .map((source, index) => ({ ...source, oldId: String(source.id || index + 1) })) + .sort((a, b) => sourceRank(a) - sourceRank(b)); + const idMap = new Map(orderedSources.map((source, index) => [source.oldId, String(index + 1)])); + return { + sources: orderedSources.map((source, index) => ({ + ...source, + id: String(index + 1), + kind: displaySourceKind(source.kind), + oldId: undefined, + })), + paragraphs: paragraphs.map((paragraph) => ({ + ...paragraph, + citationIds: (paragraph.citationIds || []) + .map((id) => idMap.get(String(id)) || null) + .filter(Boolean), + segments: (paragraph.segments || []).map((segment) => ({ + ...segment, + citationIds: (segment.citationIds || []) + .map((id) => idMap.get(String(id)) || null) + .filter(Boolean), + })), + })), + }; + } + + function materialRecords(item) { + return item.library || []; + } + + function historicalDossierRecords(item) { + return (item.updates || []).map((dossier) => ({ + id: dossier.id, + title: dossier.title, + time: dossier.date, + sourceType: "档案", + versionNo: dossier.versionNo || 1, + isDossier: true, + })); + } + + async function loadSalesData() { + const apiGoals = await api("/sales-goals"); + const enriched = []; + for (const goal of apiGoals) { + const targets = await api(`/sales-goals/${encodeURIComponent(goal.id)}/target-enterprises`); + targets.forEach((item) => { + const mapped = mapCompanyFromApi(item); + if (mapped) companies[mapped.id] = { ...(companies[mapped.id] || {}), ...mapped }; + }); + enriched.push({ + id: goal.id, + name: goal.name, + stats: goalStats(targets.length), + placeholder: goalPlaceholder(goal), + related: [], + pool: targets.map((item) => item.id), + }); + } + if (enriched.length) goals = enriched; + if (!goals.some((goal) => goal.id === state.activeGoalId)) state.activeGoalId = goals[0]?.id || ""; + await hydrateVisibleCompany(); + } + + async function loadGoalCompanies(goalId, query = "") { + const goal = goals.find((item) => item.id === goalId); + if (!goal) return; + const normalizedQuery = query.trim(); + const [targets, candidates] = await Promise.all([ + api(`/sales-goals/${encodeURIComponent(goalId)}/target-enterprises`), + normalizedQuery + ? api(`/sales-goals/${encodeURIComponent(goalId)}/company-search`, { method: "POST", body: { query: normalizedQuery } }) + : Promise.resolve([]), + ]); + [...targets, ...candidates].forEach((item) => { + const mapped = mapCompanyFromApi(item); + if (mapped) companies[mapped.id] = { ...(companies[mapped.id] || {}), ...mapped }; + }); + goal.pool = targets.map((item) => item.id); + goal.related = candidates.map((item) => item.id); + goal.stats = goalStats(goal.pool.length); + } + + async function loadDossierDetail(record, attempts = 3) { + let lastError = null; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + return mapDossierFromApi(await api(`/dossiers/${encodeURIComponent(record.id)}`)); + } catch (error) { + lastError = error; + if (attempt + 1 < attempts) await wait(350 * (attempt + 1)); + } + } + throw lastError; + } + + async function hydrateCompany(companyId, options = {}) { + if (!companyId) return null; + const detail = await api(`/target-enterprises/${encodeURIComponent(companyId)}`); + const mapped = mapCompanyFromApi(detail); + if (!mapped) return null; + const existingUpdates = companies[companyId]?.updates || []; + let dossierDetailFailures = 0; + const dossierDetails = await Promise.all((detail.dossiers || []).map(async (record) => { + try { + return await loadDossierDetail(record); + } catch (error) { + dossierDetailFailures += 1; + return existingUpdates.find((item) => item.id === record.id && item.bodyParagraphs?.length) + || mapDossierFromApi(record, { detailLoadError: true }); + } + })); + mapped.updates = dossierDetails; + mapped.library = (detail.materials || []).map(mapMaterialFromApi); + mapped.qaAnswer = detail.qa?.messages?.find((message) => message.role === "assistant")?.text || mapped.qaAnswer || ""; + companies[mapped.id] = { ...(companies[mapped.id] || {}), ...mapped }; + rememberCompanyQa(mapped.id, (detail.qa?.messages || qaMessagesForCompany(mapped)).map(mapQaMessage)); + if (state.activeCompanyId === mapped.id + && (!state.selectedDossierId || !mapped.updates.some((item) => item.id === state.selectedDossierId))) { + state.selectedDossierId = mapped.updates[0]?.id || ""; + } + if (state.activeCompanyId === mapped.id && dossierDetailFailures) { + state.notice = "部分档案详情暂时未加载,系统已自动重试;请稍后刷新页面。"; + } + if (options.loadJob !== false) await loadLatestDossierJob(mapped.id).catch(() => null); + return mapped; + } + + async function hydrateVisibleCompany() { + const current = visibleCompany(); + if (current?.id) await hydrateCompany(current.id).catch(() => null); + } + + function activeGoal() { + return goals.find((goal) => goal.id === state.activeGoalId) || goals[0] || { + id: "", + name: "", + stats: "0 家企业", + placeholder: "请先创建销售目标", + related: [], + pool: [], + }; + } + + function company(id) { + return companies[id] || null; + } + + function visibleCompany() { + const goal = activeGoal(); + if (!goal.pool.includes(state.activeCompanyId)) { + state.activeCompanyId = goal.pool[0] || ""; + } + return company(state.activeCompanyId); + } + + function qaMessagesForCompany(item) { + if (!item?.id) return []; + if (!state.qaMessagesByCompany[item.id]) { + state.qaMessagesByCompany[item.id] = []; + } + return state.qaMessagesByCompany[item.id]; + } + + function rememberCompanyQa(companyId, messages) { + if (!companyId) return; + state.qaMessagesByCompany[companyId] = messages || []; + if (state.activeCompanyId === companyId) { + state.qaMessages = state.qaMessagesByCompany[companyId]; + } + } + + function activateCompanyQa(companyId) { + const item = company(companyId); + state.qaMessages = qaMessagesForCompany(item); + } + + function activePool() { + return activeGoal().pool + .map(company) + .filter(Boolean) + .filter((item) => state.targetStatusFilter === "全部" || salesStatus(item.status) === state.targetStatusFilter); + } + + function relatedCompanies() { + const goal = activeGoal(); + const normalizedQuery = state.query.trim().toLowerCase(); + return goal.related + .map(company) + .filter(Boolean) + .filter((item) => { + if (!normalizedQuery) return true; + return [item.name, item.industry, item.location].join(" ").toLowerCase().includes(normalizedQuery); + }); + } + + function render() { + if (state.auth.checked && state.auth.enabled && !state.auth.authenticated) { + $("#app").innerHTML = renderAuthScreen(); + bindAuthEvents(); + return; + } + if (state.bootLoading || state.bootError) { + $("#app").innerHTML = ` +
+ ${renderTopbar()} +
+

${state.bootLoading ? "正在连接销售工作台" : "销售工作台暂不可用"}

+

${escapeHtml(state.bootLoading ? "正在加载工作台数据。" : state.bootError)}

+ ${state.bootError ? `` : ""} +
+
+ `; + bindConnectionEvents(); + return; + } + const goal = activeGoal(); + const selected = visibleCompany(); + $("#app").innerHTML = ` +
+ ${renderTopbar()} +
+ ${renderSidebar(goal)} + ${renderWorkspace(goal, selected)} +
+ ${renderFeishuImportModal(selected)} +
+ `; + bindEvents(); + } + + function renderTopbar() { + const user = state.auth.user; + const displayName = user?.display_name || "本地用户"; + const avatar = String(displayName || "工").slice(0, 1).toUpperCase(); + return ` +
+
+ + 销售智能工作台 +
+
+ ${state.auth.authenticated && !state.bootLoading ? ` + + ` : ""} + ${escapeHtml(avatar)} + ${escapeHtml(displayName)} + ${state.auth.enabled ? `` : ""} +
+
+ `; + } + + function renderAuthScreen() { + const bootstrap = state.auth.bootstrapRequired; + const content = ` +
+

${bootstrap ? "设置本机管理员" : "登录工作台"}

+

${bootstrap ? "首次使用只需设置一个用户名和密码。" : "使用本机管理员账号继续。"}

+
+
+ + + ${state.authError ? `` : ""} + ${state.authNotice ? `

${escapeHtml(state.authNotice)}

` : ""} + +
+ `; + return ` +
+
+ + 销售智能工作台 +
+
+
+ ${content} +
+
+
+ `; + } + + function renderFeishuImportModal(item) { + if (!state.feishuImportOpen || !item?.id) return ""; + const task = state.feishuImportTask; + const active = ["queued", "running"].includes(task?.status); + const completed = task?.status === "succeeded"; + const draft = state.feishuImportDraft; + const conversation = state.feishuImportKind === "conversation"; + return ` +
+ +
+ `; + } + + function renderSidebar(goal) { + return ` + + `; + } + + function renderPageNotice() { + if (state.bootLoading) return `
正在加载销售资料...
`; + if (state.bootError) return `
${escapeHtml(state.bootError)}
`; + return ""; + } + + function renderSideLoading(text) { + return `
${escapeHtml(text)}
`; + } + + function renderSearchResults() { + if (state.busy === "search") return renderSideLoading("正在查找企业"); + if (!state.hasSearched) return `
输入关键词搜索后显示企业
`; + const items = relatedCompanies(); + return items.length + ? items.map(renderRelatedCompany).join("") + : `
没有找到匹配企业
`; + } + + function renderGoalItem(goal) { + const active = goal.id === state.activeGoalId; + return ` + + `; + } + + function renderTargetStatusFilters() { + return ` +
+ ${TARGET_STATUS_FILTERS.map((status) => ` + + `).join("")} +
+ `; + } + + function renderRelatedCompany(item) { + const goal = activeGoal(); + const inPool = goal.pool.includes(item.id); + const adding = state.busy === `add:${item.id}`; + return ` + + `; + } + + function renderTargetCompany(item) { + const selected = item.id === state.activeCompanyId; + return ` + + `; + } + + function renderWorkspace(goal, item) { + if (!item) { + return ` +
+
+

选择一个目标企业

+

先在左侧查找公司并加入目标企业池。

+ +
+
+ `; + } + + return ` +
+ ${renderCompanyHeader(goal, item)} + ${renderProgress(item)} + ${renderRecentDossier(item)} + ${renderSupportArea(item)} +
+ `; + } + + function renderCompanyHeader(goal, item) { + const job = dossierJobForCompany(item.id); + return ` +
+
+ +
+

${escapeHtml(item.name)}

+

目标企业 · ${escapeHtml(goal.name)}

+
+ ${(item.tags || [item.industry, item.location]).map((tag) => `${escapeHtml(tag)}`).join("")} +
+
+
+
+ ${renderDossierJobControl(job)} + ${state.notice ? escapeHtml(state.notice) : `更新于:${escapeHtml(item.updatedAt || "尚未更新")}`} +
+
+ `; + } + + function compactDossierStageLabel(job) { + const detailMessage = String(job?.stage_detail?.message || "").replace(/\s+/g, " ").trim(); + if (detailMessage) return detailMessage; + const labels = { + queued: "正在准备档案", + retry_wait: "正在等待自动重试", + starting: "正在准备档案", + collecting_evidence: "正在查找资料", + collecting_professional: "正在核验专业资料", + collecting_public: "正在检索公开资料", + building_evidence: "正在整理可信资料", + validating_evidence: "正在核验资料", + generating_dossier: "正在整理档案", + validating_dossier: "正在核验档案", + persisting_result: "正在保存结果", + cancelling: "正在取消", + }; + return labels[job?.stage] || "正在生成档案"; + } + + function renderDossierJobControl(job) { + if (!job || job.status === "succeeded") { + return ` + + `; + } + + const active = isActiveJob(job); + const retry = !active && job.retryable + ? `` + : ""; + const cancel = active && job.stage !== "cancelling" + ? `` + : ""; + + if (!active) { + return retry || ` + + `; + } + + return ` +
+ + ${cancel} +
+ `; + } + + function renderProgress(item) { + return ` +
+
+

当前进度

+ ${escapeHtml(salesStatus(item.status))} +
+

${escapeHtml(conciseProgressText(item))}

+
+ `; + } + + function renderRecentDossier(item) { + const updates = item.updates || []; + const selected = selectedDossier(updates); + return ` +
+
+

最近档案

+ ${updates.length ? ` +
+ ${updates.map((update, index) => ` + + `).join("")} +
+ ` : ""} +
+ ${selected ? renderDossierDetail(selected) : `
暂无最近档案。
`} +
+ `; + } + + function renderSupportArea(item) { + const libraryActive = state.supportView !== "qa"; + return ` +
+
+ + +
+
+ ${renderLibrary(item)} +
+
+ ${renderQa(item)} +
+
+ `; + } + + function selectedDossier(updates) { + if (!updates.length) return null; + return updates.find((update) => update.id === state.selectedDossierId) || updates[0]; + } + + function dossierSources(update) { + if (!update) return []; + if (update.citations?.length) return update.citations; + return []; + } + + function renderDossierDetail(update) { + if (!update) return ""; + const sources = dossierSources(update); + const rawParagraphs = update.bodyParagraphs?.length + ? update.bodyParagraphs + : update.body + ? [{ text: update.body, citationIds: [] }] + : []; + const { sources: orderedSources, paragraphs } = normalizeDossierDisplay(sources, rawParagraphs); + return ` +
+
+ 档案详情 · V${escapeHtml(update.versionNo || 1)} + + ${escapeHtml(update.date)} + +
+

${escapeHtml(update.title)}

+

资料截至 ${escapeHtml(formatTime(update.dataAsOf, "未知"))} · 生成于 ${escapeHtml(formatTime(update.generatedAt, update.date || "未知"))}

+
+ ${paragraphs.length + ? paragraphs.map(renderDossierParagraph).join("") + : `
${update.detailLoadError + ? "档案详情暂时无法加载,请稍后刷新页面重试。系统不会用摘要冒充正文。" + : "档案正文暂未加载,请稍后重新打开该企业。"}
`} +
+
+
+ 资料来源 + 正文中的编号对应下列来源 +
+ ${orderedSources.length + ? renderCitationGroups(orderedSources) + : `${update.detailLoadError ? "档案详情尚未加载,暂不能展示引用。" : "暂无可验证的引用来源。"}`} +
+
+ `; + } + + function renderDossierParagraph(paragraph) { + const raw = String(paragraph.text || ""); + const sectionMatch = raw.match(/^([^::\n]{1,24})[::]\s*([\s\S]*)$/); + const heading = normalizeChineseTypography(sectionMatch?.[1] || ""); + const content = sectionMatch?.[2] || raw; + const renderTextWithCitations = (text, citationIds) => { + const citations = (citationIds || []) + .map((id) => `[${escapeHtml(id)}]`) + .join(""); + const displayParagraphs = splitDisplayParagraphs(text); + return displayParagraphs.map((displayText, index) => { + const references = index === displayParagraphs.length - 1 && citations ? ` ${citations}` : ""; + return `

${escapeHtml(displayText)}${references}

`; + }).join(""); + }; + const paragraphHtml = paragraph.segments?.length + ? paragraph.segments + .map((segment) => renderTextWithCitations(segment.text, segment.citationIds)) + .join("") + : renderTextWithCitations(content, paragraph.citationIds); + if (DOSSIER_SECTION_TITLES.includes(heading)) { + return ` +
+

${escapeHtml(heading)}

+
+ ${paragraphHtml} +
+
+ `; + } + return paragraphHtml; + } + + function renderCitationGroups(sources) { + const groups = [ + { + kind: "professional", + title: "专业数据集(DataPro)", + items: sources.filter((source) => displaySourceKind(source.kind) === "专业数据集(DataPro)"), + }, + { + kind: "web", + title: "联网搜索", + items: sources.filter((source) => displaySourceKind(source.kind) === "联网搜索"), + }, + ].filter((group) => group.items.length); + return groups.map((group) => ` +
+
+ ${escapeHtml(group.title)} + ${group.items.length} 条 +
+
+ ${group.items.map((source) => renderCitation(source, group.kind)).join("")} +
+
+ `).join(""); + } + + function renderCitation(source, groupKind) { + const title = source.label || displaySourceKind(source.kind); + if (groupKind === "professional") { + const details = professionalSourceDetails(source); + return ` +
+ [${escapeHtml(source.id)}] +
+ ${escapeHtml(title)} + ${details.length + ? `
+ 查看数据明细 +
+ ${details.map((item) => ` +
+
${escapeHtml(item.label)}
+
${escapeHtml(item.value)}
+
+ `).join("")} +
+
` + : `当前记录没有可展示的字段明细`} +
+
+ `; + } + const siteName = sourceSiteName(source); + const publishLabel = sourcePublishLabel(source); + return ` +
+ [${escapeHtml(source.id)}] +
+ ${source.url && !isPlaceholderUrl(source.url) + ? `${escapeHtml(title)} ↗` + : `${escapeHtml(title)}`} + ${escapeHtml(siteName)} · ${escapeHtml(publishLabel)} +
+
+ `; + } + + function renderLibrary(item) { + const materialRows = materialRecords(item); + const dossierRows = historicalDossierRecords(item); + const allRecords = [...dossierRows, ...materialRows]; + const records = allRecords.filter((record) => { + if (state.materialFilter === "全部") return true; + if (state.materialFilter === "档案") return record.isDossier; + if (record.isDossier) return false; + return inferMaterialType(record.title, record.sourceType).includes(state.materialFilter); + }); + return ` +
+
+
+

历史资料 ${allRecords.length}

+
+
+
+ ${MATERIAL_FILTERS.map((type) => ` + + `).join("")} +
+
+ +
+
+
+ ${ + records.length + ? `
+
资料名称来源更新时间
+ ${records.map((record) => ` +
+ ${record.isDossier + ? `` + : `${escapeHtml(record.title)}`} + ${record.isDossier ? `档案 V${escapeHtml(record.versionNo)}` : escapeHtml(inferMaterialType(record.title, record.sourceType))} + ${escapeHtml(record.time)} +
+ `).join("")} +
` + : `
${state.materialFilter === "档案" ? "暂无历史档案。" : "暂无历史资料。"}
` + } +
+ `; + } + + function renderQa(item) { + const hasMaterials = materialRecords(item).length > 0; + const messages = qaMessagesForCompany(item); + const qaNote = hasMaterials + ? "仅根据当前企业档案和用户导入的飞书资料回答。" + : "当前企业暂无飞书资料;问答仅根据当前企业档案回答。"; + const qaPlaceholder = hasMaterials ? "询问历史沟通、当前进展或资料缺口" : "询问当前进展或资料缺口"; + return ` +
+
+

资料问答

+
+

${escapeHtml(qaNote)}

+
+ ${messages.length ? messages.map(renderMessage).join("") : `
暂无历史问答。
`} + ${state.busy === "qa" && state.qaPendingCompanyId === item.id + ? `
正在检索档案与飞书资料
` + : ""} +
+
+ + +
+
+ `; + } + + function renderMessage(message) { + const rawCitationEntries = message.citationEntries?.length + ? message.citationEntries + : (message.citations || []).map((label) => ({ id: "", label })); + const citationDisplay = dedupeCitationEntries(rawCitationEntries); + const citationEntries = citationDisplay.entries; + const citationNumbers = new Map(Object.entries(citationDisplay.citationNumbers)); + const paragraphs = collapseRepeatedCitationRuns(qaAnswerParagraphs(message)); + return ` +
+ ${message.role === "assistant" + ? `
${paragraphs.map((paragraph) => renderQaAnswerParagraph(paragraph, citationNumbers)).join("")}
` + : `

${escapeHtml(message.text)}

`} + ${citationEntries.length + ? `
${citationEntries.map((item, index) => `[${index + 1}]${escapeHtml(item.label)}`).join("")}
` + : ""} +
+ `; + } + + function qaAnswerParagraphs(message) { + const source = message.paragraphs?.length + ? message.paragraphs + : [{ text: message.text || "", citationIds: [] }]; + return source.flatMap((paragraph, citationGroup) => splitQaAnswerText(paragraph.text).map((text) => ({ + text, + citationIds: paragraph.citationIds || [], + citationGroup, + }))); + } + + function splitQaAnswerText(value) { + const normalized = normalizeChineseTypography(value); + if (!normalized) return []; + const afterSentence = new RegExp(`([。;!?])\\s*(?=(?:${QA_SECTION_HEADING_SOURCE})[::])`, "g"); + const afterWhitespace = new RegExp(`[ \\t\\n]+(?=(?:${QA_SECTION_HEADING_SOURCE})[::])`, "g"); + const structured = normalized + .replace(afterSentence, "$1\n\n") + .replace(afterWhitespace, "\n\n"); + return splitReadableBlocks(structured, 220); + } + + function renderQaAnswerParagraph(paragraph, citationNumbers) { + const match = paragraph.text.match(QA_SECTION_HEADING_PATTERN); + const heading = match?.[1] || ""; + const body = match?.[2] || paragraph.text; + const references = [...new Set( + (paragraph.displayCitationIds || []) + .map((id) => citationNumbers.get(String(id))) + .filter(Boolean), + )]; + return ` +
+ ${heading ? `

${escapeHtml(heading)}

` : ""} +

${escapeHtml(body).replace(/\n/g, "
")}${references.length ? `${references.map((number) => `[${number}]`).join("")}` : ""}

+
+ `; + } + + function scrollQaToBottom() { + queueMicrotask(() => { + const chatArea = $(".chat-area"); + if (chatArea) chatArea.scrollTop = chatArea.scrollHeight; + }); + } + + function bindConnectionEvents() { + $("#retryBoot")?.addEventListener("click", () => { + if (state.bootLoading) return; + boot(); + }); + } + + function bindAuthEvents() { + $("#authForm")?.addEventListener("submit", async (event) => { + event.preventDefault(); + if (state.authBusy) return; + const form = new FormData(event.currentTarget); + const bootstrap = state.auth.bootstrapRequired; + const body = { + username: String(form.get("username") || "").trim(), + password: String(form.get("password") || ""), + }; + state.authBusy = bootstrap ? "bootstrap" : "login"; + state.authError = ""; + state.authNotice = ""; + render(); + try { + const result = await api(bootstrap ? "/auth/bootstrap" : "/auth/login", { + method: "POST", + body, + skipAuthRedirect: true, + }); + state.auth.checked = true; + state.auth.enabled = true; + state.auth.authenticated = true; + state.auth.bootstrapRequired = false; + state.auth.user = result.user || null; + state.authBusy = ""; + await boot(); + } catch (error) { + state.authBusy = ""; + state.authError = apiErrorMessage(error, bootstrap ? "管理员设置失败,请重试。" : "登录失败,请检查用户名和密码。"); + render(); + } + }); + } + + function closeFeishuImport() { + state.feishuImportOpen = false; + state.feishuImportError = ""; + render(); + } + + async function monitorFeishuImport(initialTask, companyId) { + const token = ++feishuImportPollToken; + let task = initialTask; + try { + while (token === feishuImportPollToken && ["queued", "running"].includes(task?.status)) { + await wait(900); + if (token !== feishuImportPollToken) return; + task = await api(`/target-enterprises/${encodeURIComponent(companyId)}/materials/feishu-import/${encodeURIComponent(task.id)}`); + state.feishuImportTask = task; + render(); + } + if (token !== feishuImportPollToken || !task) return; + if (task.status === "succeeded") { + await hydrateCompany(companyId, { loadJob: false }); + state.notice = "飞书资料已导入"; + state.materialFilter = task.source_kind === "document" ? "云文档" : "飞书会话"; + } else { + state.feishuImportError = "飞书资料导入没有完成,请检查输入后重试。"; + } + } catch (error) { + if (token !== feishuImportPollToken) return; + state.feishuImportError = apiErrorMessage(error, "暂时无法获取飞书资料导入进度。"); + } + render(); + } + + function bindEvents() { + const setMobileNavigation = (open) => { + state.mobileNavigationOpen = Boolean(open); + render(); + }; + $("#mobileNavigationToggle")?.addEventListener("click", () => { + setMobileNavigation(!state.mobileNavigationOpen); + }); + $("#emptyOpenMobileNavigation")?.addEventListener("click", () => { + setMobileNavigation(true); + }); + $("#openFeishuImport")?.addEventListener("click", async () => { + const current = visibleCompany(); + if (!current?.id) return; + state.feishuImportOpen = true; + state.feishuImportAvailable = null; + state.feishuImportError = ""; + if (!["queued", "running"].includes(state.feishuImportTask?.status)) { + state.feishuImportTask = null; + } + render(); + try { + const status = await api("/feishu-import/status"); + state.feishuImportAvailable = Boolean(status.available); + } catch (error) { + state.feishuImportAvailable = false; + state.feishuImportError = apiErrorMessage(error, "暂时无法确认飞书资料导入状态。"); + } + render(); + }); + $("#closeFeishuImport")?.addEventListener("click", closeFeishuImport); + $("#cancelFeishuImport")?.addEventListener("click", closeFeishuImport); + $("#feishuImportBackdrop")?.addEventListener("click", (event) => { + if (event.target.id === "feishuImportBackdrop") closeFeishuImport(); + }); + $$("[data-feishu-kind]").forEach((button) => { + button.addEventListener("click", () => { + if (["queued", "running"].includes(state.feishuImportTask?.status)) return; + state.feishuImportKind = button.dataset.feishuKind; + state.feishuImportDraft = { target: "", start: "", end: "" }; + state.feishuImportTask = null; + state.feishuImportError = ""; + render(); + }); + }); + $("#feishuImportForm")?.addEventListener("submit", async (event) => { + event.preventDefault(); + const current = visibleCompany(); + if (!current?.id || ["queued", "running"].includes(state.feishuImportTask?.status)) return; + const form = new FormData(event.currentTarget); + state.feishuImportDraft = { + target: String(form.get("target") || "").trim(), + start: String(form.get("start") || ""), + end: String(form.get("end") || ""), + }; + state.feishuImportError = ""; + if (state.feishuImportKind === "conversation" + && /^ou_[A-Za-z0-9_-]+$/i.test(state.feishuImportDraft.target)) { + state.feishuImportError = "飞书会话请填写联系人姓名或 oc_ 开头的会话 ID,不支持 Open ID。"; + render(); + return; + } + if (state.feishuImportKind === "document" + && !/^https:\/\/\S+$/i.test(state.feishuImportDraft.target)) { + state.feishuImportError = "请粘贴完整的 https:// 飞书云文档链接。"; + render(); + return; + } + state.feishuImportTask = { + status: "queued", + summary: "正在创建导入任务。", + source_kind: state.feishuImportKind, + }; + render(); + try { + const task = await api(`/target-enterprises/${encodeURIComponent(current.id)}/materials/feishu-import`, { + method: "POST", + body: { + source_kind: state.feishuImportKind, + target: state.feishuImportDraft.target, + start: state.feishuImportDraft.start, + end: state.feishuImportDraft.end, + }, + }); + state.feishuImportTask = task; + render(); + monitorFeishuImport(task, current.id); + } catch (error) { + state.feishuImportTask = null; + state.feishuImportError = apiErrorMessage(error, "飞书资料导入任务创建失败。"); + render(); + } + }); + $("#logoutButton")?.addEventListener("click", async () => { + if (state.authBusy) return; + state.authBusy = "logout"; + try { + await api("/auth/logout", { method: "POST", skipAuthRedirect: true }); + } catch { + // Local session is cleared by the server whenever it can be reached. + } + resetConnectedState(); + state.auth = { + checked: true, + enabled: true, + authenticated: false, + bootstrapRequired: false, + user: null, + }; + state.authBusy = ""; + state.authError = ""; + state.authNotice = ""; + render(); + }); + $("#toggleNewGoal")?.addEventListener("click", () => { + if (state.busy) return; + state.showNewGoal = !state.showNewGoal; + render(); + }); + + $("#newGoalForm")?.addEventListener("submit", async (event) => { + event.preventDefault(); + if (state.busy) return; + const name = $("#newGoalInput").value.trim(); + if (!name) return; + state.busy = "createGoal"; + state.notice = ""; + state.sidebarNotice = ""; + render(); + try { + const created = await api("/sales-goals", { method: "POST", body: { name } }); + goals.unshift({ + id: created.id, + name: created.name, + stats: goalStats(0), + placeholder: goalPlaceholder(created), + related: [], + pool: [], + }); + state.activeGoalId = created.id; + state.activeCompanyId = ""; + state.showNewGoal = false; + state.notice = "已新增销售目标"; + } catch (error) { + state.showNewGoal = false; + state.sidebarNotice = apiErrorMessage(error, "暂时没能创建销售目标,请稍后再试。"); + } finally { + state.busy = ""; + } + render(); + }); + + $$("[data-goal]").forEach((button) => { + button.addEventListener("click", async () => { + if (state.busy) return; + state.activeGoalId = button.dataset.goal; + state.activeCompanyId = ""; + state.targetStatusFilter = "全部"; + state.materialFilter = "全部"; + state.query = ""; + state.hasSearched = false; + state.notice = ""; + state.sidebarNotice = ""; + state.busy = `goal:${state.activeGoalId}`; + render(); + try { + await loadGoalCompanies(state.activeGoalId); + await hydrateVisibleCompany(); + } catch { + state.sidebarNotice = "暂时没能加载这个销售目标,请稍后再试。"; + } finally { + state.busy = ""; + } + render(); + }); + }); + + $("#companySearch")?.addEventListener("submit", async (event) => { + event.preventDefault(); + if (state.busy) return; + state.query = $("#companyQuery").value.trim(); + state.hasSearched = Boolean(state.query); + if (!state.query) { + const goal = activeGoal(); + goal.related = []; + state.sidebarNotice = "请输入行业、区域或企业关键词后搜索。"; + render(); + return; + } + state.busy = "search"; + state.sidebarNotice = ""; + state.notice = ""; + render(); + try { + await loadGoalCompanies(state.activeGoalId, state.query); + state.sidebarNotice = state.query ? "已更新相关公司" : ""; + } catch { + state.sidebarNotice = "暂时没能查到相关公司,可以换个关键词再试。"; + } finally { + state.busy = ""; + } + render(); + }); + + $$("[data-add]").forEach((button) => { + button.addEventListener("click", async () => { + if (state.busy) return; + const goal = activeGoal(); + const id = button.dataset.add; + state.busy = `add:${id}`; + state.sidebarNotice = ""; + state.notice = ""; + render(); + try { + const detail = await api(`/sales-goals/${encodeURIComponent(goal.id)}/target-enterprises`, { method: "POST", body: { company_id: id } }); + const mapped = mapCompanyFromApi(detail); + if (mapped) companies[mapped.id] = { ...(companies[mapped.id] || {}), ...mapped }; + if (!goal.pool.includes(id)) goal.pool.push(id); + goal.stats = goalStats(goal.pool.length); + state.activeCompanyId = id; + state.mobileNavigationOpen = false; + await hydrateCompany(id); + state.notice = "已加入目标企业池"; + } catch { + state.sidebarNotice = "暂时没能加入目标企业池,请稍后再试。"; + } finally { + state.busy = ""; + } + render(); + }); + }); + + $$("[data-company]").forEach((button) => { + button.addEventListener("click", async () => { + if (state.busy) return; + state.activeCompanyId = button.dataset.company; + state.mobileNavigationOpen = false; + state.materialFilter = "全部"; + activateCompanyQa(state.activeCompanyId); + state.selectedDossierId = company(state.activeCompanyId)?.updates?.[0]?.id || ""; + state.notice = ""; + state.sidebarNotice = ""; + state.busy = `company:${state.activeCompanyId}`; + render(); + try { + await hydrateCompany(state.activeCompanyId); + } catch { + state.notice = "暂时没能刷新企业资料,已保留当前档案。"; + } finally { + state.busy = ""; + } + render(); + }); + }); + + $$('[data-dossier]').forEach((button) => { + button.addEventListener("click", () => { + if (state.busy) return; + state.selectedDossierId = button.dataset.dossier; + render(); + }); + }); + + $$("[data-status-filter]").forEach((button) => { + button.addEventListener("click", () => { + if (state.busy) return; + state.targetStatusFilter = button.dataset.statusFilter; + const pool = activePool(); + if (pool.length && !pool.some((item) => item.id === state.activeCompanyId)) { + state.activeCompanyId = pool[0].id; + state.selectedDossierId = company(state.activeCompanyId)?.updates?.[0]?.id || ""; + } + render(); + }); + }); + + $$("[data-material-filter]").forEach((button) => { + button.addEventListener("click", () => { + if (state.busy) return; + state.materialFilter = button.dataset.materialFilter; + render(); + }); + }); + + $$("[data-support-view]").forEach((button) => { + button.addEventListener("click", () => { + const nextView = button.dataset.supportView; + if (!["library", "qa"].includes(nextView) || state.supportView === nextView) return; + state.supportView = nextView; + render(); + if (nextView === "qa") scrollQaToBottom(); + }); + }); + + $("#refreshCompany")?.addEventListener("click", async () => { + if (state.busy) return; + const current = visibleCompany(); + if (isActiveJob(dossierJobForCompany(current?.id))) return; + state.busy = "refresh"; + state.notice = ""; + render(); + try { + if (current?.id) { + const created = await api(`/target-enterprises/${encodeURIComponent(current.id)}/dossiers`, { + method: "POST", + body: { idempotency_key: dossierRequestIdempotencyKey(current.id) }, + }); + clearDossierRequestIdempotencyKey(current.id); + if (created?.job_type === "sales_dossier_generation" && created?.id) { + rememberDossierJob(created, current.id); + state.notice = "任务已提交,可继续浏览其他企业。"; + state.busy = ""; + render(); + monitorDossierJob(created, current.id); + return; + } + state.selectedDossierId = created?.record?.id || created?.detail?.id || state.selectedDossierId; + await hydrateCompany(current.id); + const version = created?.record?.version_no || created?.detail?.version_no; + state.notice = created?.action === "no_material_change" + ? "证据未变化,保留当前版本" + : version ? `已生成档案 V${version}` : "已生成最新档案"; + } + } catch (error) { + state.notice = apiErrorMessage(error, "暂时没能获取最新档案,已保留当前档案。"); + } finally { + state.busy = ""; + render(); + } + }); + + $$('[data-cancel-dossier-job]').forEach((button) => { + button.addEventListener("click", async () => { + if (state.busy) return; + const current = visibleCompany(); + const jobId = button.dataset.cancelDossierJob; + if (!current?.id || !jobId) return; + state.busy = `cancel-job:${jobId}`; + render(); + try { + const job = await api(`/jobs/${encodeURIComponent(jobId)}/cancel`, { method: "POST" }); + rememberDossierJob(job, current.id); + if (isActiveJob(job)) { + state.notice = "正在等待当前步骤安全结束后取消"; + monitorDossierJob(job, current.id); + } else { + stopJobMonitor(jobId); + state.notice = "档案生成任务已取消"; + } + } catch (error) { + state.notice = apiErrorMessage(error, "暂时无法取消任务,请稍后重试。"); + } finally { + state.busy = ""; + render(); + } + }); + }); + + $$('[data-retry-dossier-job]').forEach((button) => { + button.addEventListener("click", async () => { + if (state.busy) return; + const current = visibleCompany(); + const jobId = button.dataset.retryDossierJob; + if (!current?.id || !jobId) return; + state.busy = `retry-job:${jobId}`; + state.notice = ""; + render(); + try { + const job = await api(`/jobs/${encodeURIComponent(jobId)}/retry`, { method: "POST" }); + rememberDossierJob(job, current.id); + state.notice = "任务已重新提交"; + state.busy = ""; + render(); + monitorDossierJob(job, current.id); + return; + } catch (error) { + state.notice = apiErrorMessage(error, "暂时无法重试任务,请稍后再试。"); + } finally { + state.busy = ""; + render(); + } + }); + }); + + $("#qaForm")?.addEventListener("submit", async (event) => { + event.preventDefault(); + if (state.busy) return; + const question = $("#qaQuestion").value.trim(); + if (!question) return; + const current = visibleCompany(); + if (!current?.id) return; + const pendingMessages = [ + ...qaMessagesForCompany(current), + { role: "user", text: question }, + ]; + rememberCompanyQa(current.id, pendingMessages); + state.busy = "qa"; + state.qaPendingCompanyId = current.id; + state.notice = ""; + render(); + scrollQaToBottom(); + try { + const result = await api(`/target-enterprises/${encodeURIComponent(current.id)}/qa`, { method: "POST", body: { question } }); + const resolvedMessages = (result.messages || []).map(mapQaMessage); + const includesSubmittedQuestion = resolvedMessages.some( + (message) => message.role === "user" && message.text === question, + ); + rememberCompanyQa( + current.id, + resolvedMessages.length + ? (includesSubmittedQuestion ? resolvedMessages : [...pendingMessages, ...resolvedMessages]) + : pendingMessages, + ); + } catch (error) { + state.notice = apiErrorMessage(error, "问答服务暂不可用,本次问题没有生成回答。"); + } finally { + state.busy = ""; + state.qaPendingCompanyId = ""; + } + render(); + scrollQaToBottom(); + }); + } + + async function boot() { + const generation = ++bootGeneration; + state.bootLoading = true; + state.bootError = ""; + render(); + let settled = false; + const loadTask = (async () => { + const authStatus = await api("/auth/status", { skipAuthRedirect: true }); + if (generation !== bootGeneration) return; + state.auth = { + checked: true, + enabled: Boolean(authStatus.enabled), + authenticated: Boolean(authStatus.authenticated), + bootstrapRequired: Boolean(authStatus.bootstrap_required), + user: authStatus.user || null, + }; + if (state.auth.enabled && !state.auth.authenticated) { + settled = true; + state.bootLoading = false; + state.bootError = ""; + render(); + return; + } + await loadSalesData(); + })() + .then(() => { + if (generation !== bootGeneration) return; + settled = true; + state.bootLoading = false; + state.bootError = ""; + render(); + }) + .catch((error) => { + if (generation !== bootGeneration) return; + settled = true; + state.bootLoading = false; + state.bootError = "工作台暂时无法加载,请确认服务正在运行后重试。"; + render(); + }); + await Promise.race([ + loadTask, + wait(6000).then(() => { + if (settled || generation !== bootGeneration) return; + state.bootLoading = false; + state.bootError = "工作台加载时间较长,请稍后重试。"; + render(); + }), + ]); + } + + boot(); +})(); diff --git a/demohouse/sales-intelligence-workbench/frontend/index.html b/demohouse/sales-intelligence-workbench/frontend/index.html new file mode 100644 index 00000000..532d5d79 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + 销售智能工作台 + + + +
+ + + + diff --git a/demohouse/sales-intelligence-workbench/frontend/styles.css b/demohouse/sales-intelligence-workbench/frontend/styles.css new file mode 100644 index 00000000..a038db50 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/frontend/styles.css @@ -0,0 +1,2834 @@ +:root { + color-scheme: light; + --bg: #fbfcff; + --surface: #ffffff; + --surface-soft: #f7f8ff; + --surface-tint: #f2f0ff; + --line: #dfe4f2; + --line-soft: #edf0f7; + --ink: #111827; + --text: #4b5568; + --muted: #7b8497; + --blue: #2f53ff; + --blue-dark: #1e37c7; + --purple: #6d45f5; + --orange: #f17822; + --orange-soft: #fff3e7; + --green: #22a66b; + --shadow: 0 18px 42px rgba(31, 45, 93, 0.08); + --radius: 8px; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + "PingFang SC", "Microsoft YaHei", sans-serif; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + min-height: 100%; + margin: 0; +} + +body { + overflow-x: hidden; + color: var(--text); + background: var(--bg); +} + +.auth-shell { + min-height: 100vh; + display: grid; + grid-template-rows: 62px 1fr; + background: #f7f8fc; +} + +.auth-brand { + display: flex; + align-items: center; + gap: 12px; + padding: 0 20px; + border-bottom: 1px solid var(--line); + background: var(--surface); + color: var(--ink); +} + +.auth-brand strong { + font-size: 20px; +} + +.auth-main { + display: grid; + place-items: center; + padding: 32px 20px; +} + +.auth-panel { + width: min(420px, 100%); + padding: 30px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); +} + +.auth-heading h1 { + margin-bottom: 8px; + color: var(--ink); + font-size: 22px; + line-height: 1.35; +} + +.auth-heading p { + margin-bottom: 24px; + color: var(--muted); + font-size: 14px; + line-height: 1.6; +} + +.auth-form { + display: grid; + gap: 16px; +} + +.auth-form label { + display: grid; + gap: 7px; + color: var(--ink); + font-size: 13px; + font-weight: 700; +} + +.auth-form input { + width: 100%; + height: 42px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--ink); + background: var(--surface); + outline: none; +} + +.auth-form input:focus { + border-color: var(--blue); + box-shadow: 0 0 0 3px rgba(47, 83, 255, 0.1); +} + +.auth-error { + margin: 0; + padding: 10px 12px; + border: 1px solid #fed7aa; + border-radius: 6px; + color: #9a3412; + background: var(--orange-soft); + font-size: 13px; + line-height: 1.5; +} + +.auth-notice { + margin: 0; + padding: 10px 12px; + border: 1px solid #bbf7d0; + border-radius: 6px; + color: #166534; + background: #f0fdf4; + font-size: 13px; + line-height: 1.5; +} + +.auth-submit { + min-height: 42px; + border: 0; + border-radius: 6px; + color: #fff; + background: var(--blue); + font-weight: 800; +} + +.auth-submit:hover:not(:disabled) { + background: var(--blue-dark); +} + +.connection-state { + min-height: calc(100vh - 74px); + display: grid; + place-content: center; + gap: 10px; + padding: 32px; + text-align: center; + color: #1f2937; + background: #f7f8fc; +} + +.connection-state h1 { + margin: 0; + font-size: 22px; + line-height: 1.35; +} + +.connection-state p { + margin: 0; + color: #697386; +} + +.connection-retry { + width: max-content; + min-width: 112px; + margin: 8px auto 0; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: default; + opacity: 0.58; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +.sales-platform { + min-height: 100vh; + background: var(--bg); +} + +.sales-topbar { + height: 62px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + border-bottom: 1px solid var(--line); + background: rgba(255, 255, 255, 0.96); + position: sticky; + top: 0; + z-index: 10; +} + +.brand, +.topbar-right, +.company-title, +.side-heading, +.related-row, +.target-row, +.header-actions, +.chip-row, +.citation-row { + display: flex; + align-items: center; +} + +.brand { + gap: 12px; +} + +.brand-icon, +.company-logo, +.company-token, +.user-avatar, +.doc-icon { + flex: 0 0 auto; + display: grid; + place-items: center; + color: #fff; + background: linear-gradient(145deg, var(--blue), var(--purple)); + font-weight: 900; +} + +.brand-icon { + width: 32px; + height: 32px; + border-radius: 7px; +} + +.brand strong { + color: var(--ink); + font-size: 22px; + letter-spacing: 0; +} + +.topbar-right { + gap: 12px; + color: var(--ink); + font-size: 14px; +} + +.user-name { + display: grid; + gap: 1px; +} + +.user-name small { + color: var(--muted); + font-size: 11px; + font-weight: 500; +} + +.logout-button { + min-width: 48px; + height: 32px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + background: var(--surface); + font-size: 13px; +} + +.logout-button:hover { + border-color: var(--blue); + color: var(--blue); +} + +.dialog-backdrop { + position: fixed; + inset: 0; + z-index: 40; + display: grid; + place-items: center; + padding: 24px; + background: rgba(17, 24, 39, 0.38); +} + +.dialog-modal { + width: min(920px, 100%); + max-height: min(760px, calc(100vh - 48px)); + overflow: auto; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + box-shadow: 0 28px 72px rgba(17, 24, 39, 0.2); +} + +.dialog-modal-header { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 68px; + padding: 14px 18px; + border-bottom: 1px solid var(--line); + background: var(--surface); +} + +.dialog-modal-header h2, +.dialog-modal-header p { + margin: 0; +} + +.dialog-modal-header h2 { + color: var(--ink); + font-size: 18px; +} + +.dialog-modal-header p { + margin-top: 3px; + color: var(--muted); + font-size: 12px; +} + +.dialog-modal-close { + width: 34px; + height: 34px; + border: 0; + border-radius: 6px; + color: var(--text); + background: transparent; + font-size: 24px; + line-height: 1; +} + +.dialog-modal-close:hover { + background: var(--surface-soft); +} + +.feishu-import-modal { + width: min(580px, 100%); +} + +.feishu-import-form { + display: grid; + gap: 16px; + padding: 20px; +} + +.feishu-import-kind { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.feishu-import-kind button { + height: 38px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + background: var(--surface); + font-weight: 700; +} + +.feishu-import-kind button.is-active { + border-color: var(--blue); + color: var(--blue); + background: var(--surface-tint); +} + +.feishu-import-form label { + display: grid; + gap: 7px; + color: var(--ink); + font-size: 13px; + font-weight: 700; +} + +.feishu-import-form label small { + color: var(--muted); + font-size: 12px; + font-weight: 500; + line-height: 1.6; +} + +.feishu-import-form input { + width: 100%; + height: 42px; + padding: 0 11px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--ink); + background: var(--surface); +} + +.feishu-import-dates { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.feishu-import-status { + margin: 0; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + background: var(--surface-soft); + font-size: 13px; + line-height: 1.5; +} + +.feishu-import-status.is-success { + border-color: #bbf7d0; + color: #166534; + background: #f0fdf4; +} + +.feishu-import-status.is-error { + border-color: #fed7aa; + color: #9a3412; + background: var(--orange-soft); +} + +.feishu-import-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.feishu-import-actions button { + min-width: 92px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.logout-icon, +.mobile-navigation-toggle, +.mobile-navigation-empty-action { + display: none; +} + +.runtime-status { + min-height: 28px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 4px 9px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + background: var(--surface-soft); + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.runtime-status i { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--muted); +} + +.runtime-status.ready i { + background: var(--green); +} + +.runtime-status.warning i { + background: var(--orange); +} + +.icon-button { + width: 34px; + height: 34px; + border: 0; + border-radius: 50%; + color: var(--text); + background: transparent; + font-size: 18px; +} + +.user-avatar { + width: 30px; + height: 30px; + border-radius: 50%; +} + +.sales-layout { + min-height: calc(100vh - 62px); + display: grid; + grid-template-columns: 376px minmax(0, 1fr); +} + +.sales-sidebar { + min-width: 0; + padding: 18px 16px 24px; + border-right: 1px solid var(--line); + background: #fff; +} + +.side-section { + margin-bottom: 22px; +} + +.page-notice, +.side-tip, +.side-loading { + color: var(--text); + background: var(--surface-soft); + border: 1px solid var(--line-soft); +} + +.page-notice { + margin-bottom: 14px; + padding: 10px 12px; + border-radius: var(--radius); + font-size: 13px; + line-height: 1.5; +} + +.page-notice.is-error { + color: #9a3412; + background: var(--orange-soft); + border-color: #fed7aa; +} + +.side-tip { + margin: 10px 0 0; + padding: 8px 10px; + border-radius: 6px; + font-size: 12px; + line-height: 1.5; +} + +.side-loading { + min-height: 70px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--muted); + font-size: 13px; +} + +.side-loading span { + width: 14px; + height: 14px; + border: 2px solid var(--line); + border-top-color: var(--blue); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.side-heading { + justify-content: space-between; + gap: 12px; +} + +.side-section h2, +.side-heading h2 { + margin: 0 0 10px; + color: var(--ink); + font-size: 16px; + line-height: 1.35; +} + +.text-action, +.link-action { + border: 0; + color: var(--blue); + background: transparent; + font-size: 13px; + font-weight: 700; + white-space: nowrap; +} + +.filter-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 10px 0 12px; +} + +.filter-row button { + min-height: 28px; + padding: 4px 10px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + background: #fff; + font-size: 12px; + font-weight: 700; +} + +.filter-row button.is-active { + border-color: var(--blue); + color: #fff; + background: var(--blue); +} + +.goal-list, +.company-list, +.target-list { + border: 1px solid var(--line-soft); + border-radius: var(--radius); + overflow: hidden; + background: #fff; +} + +.goal-item { + width: 100%; + min-height: 62px; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: center; + padding: 11px 12px; + border: 0; + border-bottom: 1px solid var(--line-soft); + color: var(--text); + background: #fff; + text-align: left; +} + +.goal-item:last-child, +.related-row:last-child, +.target-row:last-child { + border-bottom: 0; +} + +.goal-item.is-active { + background: linear-gradient(135deg, rgba(47, 83, 255, 0.12), rgba(109, 69, 245, 0.06)); +} + +.goal-item strong, +.related-row strong, +.target-row strong, +.library-row strong { + display: block; + min-width: 0; + overflow: hidden; + color: var(--ink); + font-size: 14px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.goal-item em, +.related-row em, +.target-row em, +.header-actions em, +.source-cell em { + display: block; + margin-top: 3px; + color: var(--muted); + font-size: 12px; + font-style: normal; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #8a92a6; +} + +.goal-item.is-active .dot { + background: var(--blue); +} + +.new-goal, +.company-search { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; +} + +.new-goal { + margin-top: 10px; +} + +input, +textarea { + min-width: 0; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--ink); + background: #fff; + outline: 0; +} + +input { + height: 40px; +} + +textarea { + min-height: 128px; + padding-top: 12px; + line-height: 1.6; + resize: vertical; +} + +input:focus, +textarea:focus { + border-color: rgba(47, 83, 255, 0.68); + box-shadow: 0 0 0 3px rgba(47, 83, 255, 0.1); +} + +.company-search button, +.new-goal button, +.primary-button, +.secondary-button, +.material-import button, +.qa-input button { + min-height: 40px; + border: 0; + border-radius: 6px; + color: #fff; + background: linear-gradient(135deg, var(--blue), var(--purple)); + box-shadow: 0 12px 28px rgba(47, 83, 255, 0.2); + font-weight: 800; +} + +.company-search button, +.new-goal button { + padding: 0 16px; +} + +.related-row, +.target-row { + width: 100%; + min-height: 58px; + gap: 10px; + padding: 10px 12px; + border: 0; + border-bottom: 1px solid var(--line-soft); + background: #fff; + text-align: left; +} + +.related-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; +} + +.target-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; +} + +.target-row.is-selected { + background: var(--surface-soft); +} + +.company-token { + width: 28px; + height: 28px; + border-radius: 6px; + font-size: 13px; +} + +.status-pill, +.progress-status { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 4px 10px; + border-radius: 6px; + color: var(--orange); + background: var(--orange-soft); + font-size: 13px; + font-weight: 800; + white-space: nowrap; +} + +.mini-progress { + width: 100%; + height: 5px; + display: block; + margin-top: 8px; + overflow: hidden; + border-radius: 999px; + background: #eceff7; +} + +.mini-progress i { + height: 100%; + display: block; + border-radius: inherit; + background: linear-gradient(90deg, var(--blue), var(--green)); +} + +.empty { + min-height: 70px; + display: grid; + place-items: center; + color: var(--muted); + font-size: 13px; +} + +.empty.large { + min-height: 220px; + border: 1px dashed var(--line); + border-radius: var(--radius); + background: var(--surface-soft); +} + +.workspace, +.workspace-empty { + min-width: 0; + padding: 30px 42px 56px; +} + +.workspace-empty { + display: grid; + place-items: center; + color: var(--muted); + text-align: center; +} + +.empty-workspace { + display: grid; + grid-template-rows: auto minmax(280px, 1fr); + gap: 20px; +} + +.workspace-empty-message { + display: grid; + place-content: center; + color: var(--muted); + text-align: center; +} + +.provider-diagnostics, +.provider-diagnostics-empty { + margin-bottom: 20px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; +} + +.provider-diagnostics.has-issues { + border-color: #efd7a4; +} + +.provider-diagnostics-empty { + padding: 13px 15px; + color: var(--muted); + font-size: 13px; +} + +.provider-diagnostics summary { + min-height: 52px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 15px; + cursor: pointer; + list-style: none; +} + +.provider-diagnostics summary::-webkit-details-marker { + display: none; +} + +.provider-diagnostics summary > span:first-child { + min-width: 0; + display: grid; + gap: 2px; +} + +.provider-diagnostics summary strong { + color: var(--ink); + font-size: 14px; +} + +.provider-diagnostics summary em, +.provider-diagnostics summary > span:last-child { + color: var(--muted); + font-size: 11px; + font-style: normal; +} + +.provider-diagnostics.has-issues summary > span:last-child { + color: #8a5800; + font-weight: 800; +} + +.provider-diagnostic-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid var(--line-soft); +} + +.provider-diagnostic-row { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 7px 12px; + padding: 13px 15px; + border-right: 1px solid var(--line-soft); + border-bottom: 1px solid var(--line-soft); +} + +.provider-diagnostic-row:nth-child(2n) { + border-right: 0; +} + +.provider-diagnostic-row > div { + min-width: 0; + display: grid; + gap: 3px; +} + +.provider-diagnostic-row strong, +.provider-diagnostic-row span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.provider-diagnostic-row strong { + color: var(--ink); + font-size: 13px; +} + +.provider-diagnostic-row span, +.provider-diagnostic-row p, +.provider-diagnostic-row > em { + font-size: 11px; +} + +.provider-diagnostic-row span { + color: var(--muted); +} + +.provider-diagnostic-row > em { + align-self: start; + color: var(--text); + font-style: normal; + font-weight: 800; +} + +.provider-diagnostic-row.is-ready > em { + color: var(--green); +} + +.provider-diagnostic-row.is-warning > em { + color: #8a5800; +} + +.provider-diagnostic-row.is-error > em { + color: #b42318; +} + +.provider-diagnostic-row p { + grid-column: 1 / -1; + margin: 0; + color: var(--text); + line-height: 1.5; +} + +.company-header { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: flex-start; + margin-bottom: 24px; +} + +.company-title { + min-width: 0; + gap: 18px; +} + +.company-logo { + width: 64px; + height: 64px; + border-radius: var(--radius); + box-shadow: var(--shadow); + font-size: 34px; +} + +.company-title h1 { + margin: 0 0 7px; + color: var(--ink); + font-size: 30px; + line-height: 1.15; +} + +.company-title p { + margin-bottom: 8px; + color: var(--muted); + font-size: 14px; +} + +.chip-row { + flex-wrap: wrap; + gap: 8px; +} + +.chip-row span { + min-height: 26px; + display: inline-flex; + align-items: center; + padding: 4px 11px; + border-radius: 6px; + color: #5d6678; + background: #f0f2f7; + font-size: 13px; + font-weight: 700; +} + +.header-actions { + flex-direction: column; + align-items: flex-end; + gap: 8px; + white-space: nowrap; +} + +.header-actions em.is-notice { + color: var(--blue); + font-weight: 700; +} + +.dossier-job-control { + width: min(320px, 100%); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.dossier-job-running { + position: relative; + min-width: 180px; + overflow: hidden; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; +} + +.dossier-job-running:disabled { + opacity: 1; + cursor: default; +} + +.dossier-job-spinner { + width: 14px; + height: 14px; + flex: 0 0 auto; + border: 2px solid rgba(255, 255, 255, 0.45); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.dossier-job-flow { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 3px; + overflow: hidden; +} + +.dossier-job-flow::after { + position: absolute; + width: 42%; + height: 100%; + content: ""; + background: rgba(255, 255, 255, 0.82); + transform: translateX(-120%); + animation: dossier-job-flow 1.35s ease-in-out infinite; +} + +.dossier-job-retry { + white-space: nowrap; +} + +.job-inline-action { + min-height: 28px; + padding: 0 8px; + color: var(--blue); + background: transparent; + border: 1px solid var(--line); + border-radius: 5px; + font-size: 12px; + font-weight: 700; +} + +.job-inline-action:hover { + background: #f4f6fb; +} + +.primary-button { + padding: 0 20px; +} + +.secondary-button { + padding: 0 16px; + color: var(--blue); + background: #fff; + border: 1px solid var(--line); + box-shadow: none; +} + +.progress-card { + min-height: 102px; + display: grid; + grid-template-columns: 160px minmax(0, 1fr); + gap: 18px; + align-items: center; + margin-bottom: 26px; + padding: 24px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; +} + +.operation-strip { + margin-bottom: 16px; + border: 1px solid var(--line); + border-left: 3px solid var(--blue); + border-radius: var(--radius); + background: #fff; +} + +.operation-strip.is-success { + border-left-color: var(--green); +} + +.operation-strip.is-error { + border-left-color: #c2410c; +} + +.operation-strip.is-empty { + padding: 12px 14px; + border-left-color: var(--line); +} + +.operation-strip.is-empty div { + display: flex; + align-items: center; + gap: 12px; +} + +.operation-strip.is-empty strong, +.operation-strip.is-empty span { + font-size: 13px; +} + +.operation-strip.is-empty span { + color: var(--muted); +} + +.operation-strip summary { + min-height: 52px; + display: grid; + grid-template-columns: 8px minmax(0, 1fr) auto; + gap: 11px; + align-items: center; + padding: 9px 14px; + cursor: pointer; + list-style: none; +} + +.operation-strip summary::-webkit-details-marker, +.citation-item summary::-webkit-details-marker { + display: none; +} + +.operation-marker { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--blue); +} + +.operation-strip.is-success .operation-marker { + background: var(--green); +} + +.operation-strip.is-error .operation-marker { + background: #c2410c; +} + +.operation-main { + min-width: 0; + display: flex; + align-items: baseline; + gap: 9px; +} + +.operation-main strong { + color: var(--ink); + font-size: 14px; +} + +.operation-main em, +.operation-token { + color: var(--muted); + font-size: 12px; + font-style: normal; +} + +.operation-token { + color: var(--ink); + font-weight: 800; +} + +.operation-detail { + padding: 0 14px 14px 33px; + border-top: 1px solid var(--line-soft); +} + +.operation-detail > p { + margin: 10px 0 0; + font-size: 12px; +} + +.operation-detail > p span { + display: inline-block; + width: 42px; + color: var(--muted); +} + +.operation-detail code { + overflow-wrap: anywhere; + color: var(--text); +} + +.operation-detail .operation-error { + color: #9a3412; +} + +.operation-steps { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 8px; + margin-top: 12px; +} + +.operation-steps div { + min-width: 0; + display: grid; + gap: 3px; + padding: 9px 10px; + border: 1px solid var(--line-soft); + border-radius: 6px; + background: var(--surface-soft); +} + +.operation-steps strong, +.operation-steps span, +.operation-steps em { + overflow-wrap: anywhere; + font-size: 12px; + font-style: normal; +} + +.operation-steps strong { + color: var(--ink); +} + +.operation-steps span, +.operation-steps em { + color: var(--muted); +} + +.progress-card h2 { + margin: 0 0 10px; + color: var(--ink); + font-size: 16px; + line-height: 1.35; +} + +.progress-card p { + margin: 0; + color: var(--ink); + font-size: 15px; + line-height: 1.65; +} + +.recent-section { + margin-bottom: 16px; + padding: 22px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; +} + +.section-title, +.support-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.section-title h2, +.support-heading h2 { + margin: 0; + color: var(--ink); + font-size: 18px; + line-height: 1.35; +} + +.version-tabs { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.version-tabs button { + min-width: 38px; + height: 30px; + padding: 0 9px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + background: #fff; + font-size: 12px; + font-weight: 800; +} + +.version-tabs button.is-active { + border-color: var(--blue); + color: var(--blue); + background: var(--surface-tint); +} + +.support-heading h2 span { + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.support-heading .filter-row { + margin: 0; + justify-content: flex-end; +} + +.library-heading { + display: grid; + gap: 12px; + min-width: 0; +} + +.library-heading > * { + min-width: 0; +} + +.library-heading-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.library-control-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + max-width: 100%; + min-width: 0; +} + +.library-tools { + flex: 0 0 auto; +} + +.material-filter { + display: grid; + grid-template-columns: repeat(4, minmax(92px, 108px)); + width: auto; + min-width: 0; + overflow-x: auto; + overscroll-behavior-inline: contain; + scrollbar-width: thin; +} + +.material-filter button { + min-width: 92px; + min-height: 34px; + white-space: nowrap; +} + +.library-import-button { + flex: 0 0 auto; + min-height: 36px; +} + +.recent-section .dossier-detail { + position: static; + padding: 0; + border: 0; + box-shadow: none; +} + +.recent-section .dossier-detail h2 { + font-size: 20px; +} + +.support-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + align-items: start; +} + +.support-tabs { + display: flex; + align-items: flex-end; + gap: 28px; + min-width: 0; + border-bottom: 1px solid var(--line); +} + +.support-tabs button { + position: relative; + min-height: 46px; + padding: 0 4px 12px; + border: 0; + color: var(--text); + background: transparent; + font-size: 17px; + font-weight: 800; + white-space: nowrap; +} + +.support-tabs button::after { + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 3px; + border-radius: 3px 3px 0 0; + background: transparent; + content: ""; +} + +.support-tabs button.is-active { + color: var(--blue); +} + +.support-tabs button.is-active::after { + background: var(--blue); +} + +.support-tab-panel { + min-width: 0; +} + +.support-tab-panel[hidden] { + display: none; +} + +.content-tabs { + height: 48px; + display: flex; + align-items: flex-end; + gap: 26px; + border-bottom: 1px solid var(--line); +} + +.content-tabs button { + height: 48px; + padding: 0 10px; + border: 0; + border-bottom: 3px solid transparent; + color: var(--text); + background: transparent; + font-size: 16px; + font-weight: 700; +} + +.content-tabs button.is-active { + border-color: var(--blue); + color: var(--blue); +} + +.updates-panel { + padding-top: 22px; +} + +.library-panel, +.qa-panel { + min-width: 0; + padding: 20px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; +} + +.dossier-layout { + display: grid; + grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); + gap: 24px; + align-items: start; +} + +.dossier-history { + min-width: 0; + border: 1px solid var(--line); + border-radius: var(--radius); + border-top: 1px solid var(--line-soft); + overflow: hidden; + background: #fff; +} + +.update-row { + min-height: 72px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 12px 14px; + border-bottom: 1px solid var(--line-soft); +} + +.update-row.is-selected { + background: var(--surface-soft); + box-shadow: inset 3px 0 0 var(--blue); +} + +.doc-icon { + width: 46px; + height: 46px; + border-radius: 7px; + background: linear-gradient(145deg, #eef2ff, #fafbff); + color: var(--blue); + border: 1px solid var(--line); + box-shadow: none; +} + +.update-copy h3 { + margin: 0 0 5px; + color: var(--ink); + font-size: 14px; + line-height: 1.35; +} + +.update-copy p { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.source-cell { + display: grid; + gap: 5px; + justify-items: start; + color: var(--text); + font-size: 14px; +} + +.detail-button { + padding: 0 0 0 6px; + border: 0; + color: var(--blue); + background: transparent; + font-size: 13px; + font-weight: 800; + text-decoration: none; + white-space: nowrap; +} + +.dossier-detail { + min-width: 0; + padding: 24px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; + box-shadow: var(--shadow); + position: sticky; + top: 84px; +} + +.detail-head { + display: flex; + justify-content: space-between; + gap: 14px; + margin-bottom: 12px; + color: var(--muted); + font-size: 13px; + font-weight: 800; +} + +.detail-head em { + font-style: normal; +} + +.detail-meta { + display: flex; + align-items: center; + gap: 12px; +} + +.dossier-timing { + margin: -10px 0 18px; + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + +.dossier-detail h2 { + margin: 0 0 18px; + color: var(--ink); + font-size: 24px; + line-height: 1.35; +} + +.dossier-body { + display: grid; + gap: 12px; +} + +.dossier-report-section { + display: grid; + gap: 10px; + padding: 16px 0; + border-top: 1px solid var(--line); +} + +.dossier-report-section:first-child { + padding-top: 0; + border-top: 0; +} + +.dossier-report-section h3 { + margin: 0; + color: var(--ink); + font-size: 15px; + font-weight: 800; + line-height: 1.5; +} + +.dossier-report-content { + display: grid; + gap: 10px; +} + +.dossier-body p { + margin: 0; + color: var(--ink); + font-size: 15px; + line-height: 2; + text-wrap: pretty; +} + +.dossier-body sup { + margin-left: 2px; + color: var(--blue); + font-weight: 900; +} + +.inline-empty { + padding: 12px; + border: 1px dashed var(--line); + border-radius: 6px; + color: var(--muted); + background: var(--surface-soft); + font-size: 13px; + line-height: 1.6; +} + +.citation-block { + display: grid; + gap: 12px; + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--line-soft); +} + +.citation-block-head, +.citation-group-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.citation-block-head strong { + color: var(--ink); + font-size: 14px; +} + +.citation-block-head span, +.citation-group-head span { + color: var(--muted); + font-size: 12px; +} + +.citation-group { + overflow: hidden; + border: 1px solid var(--line-soft); + border-radius: 8px; + background: var(--surface-soft); +} + +.citation-group-head { + padding: 9px 11px; + border-bottom: 1px solid var(--line-soft); + background: var(--surface); +} + +.citation-group-head strong { + color: var(--text); + font-size: 13px; +} + +.citation-list { + display: grid; +} + +.citation-source-row { + display: grid; + grid-template-columns: 30px minmax(0, 1fr); + gap: 8px; + padding: 10px 11px; + border-bottom: 1px solid var(--line-soft); +} + +.citation-source-row:last-child { + border-bottom: 0; +} + +.citation-source-row > b { + color: var(--blue); + font-size: 12px; + line-height: 1.6; +} + +.citation-source-row > div { + display: grid; + gap: 4px; + min-width: 0; +} + +.citation-source-row strong, +.citation-source-row a { + overflow-wrap: anywhere; + color: var(--text); + font-size: 13px; + font-weight: 700; + line-height: 1.5; + text-decoration: none; +} + +.citation-source-row a { + color: var(--blue); +} + +.citation-source-row span { + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + +.professional-source-details { + margin-top: 2px; +} + +.professional-source-details summary { + width: max-content; + color: var(--blue); + font-size: 12px; + font-weight: 700; + line-height: 1.6; + cursor: pointer; +} + +.professional-source-details dl { + display: grid; + gap: 0; + margin: 8px 0 0; + overflow: hidden; + border: 1px solid var(--line-soft); + border-radius: 6px; + background: var(--surface); +} + +.professional-source-details dl > div { + display: grid; + grid-template-columns: minmax(96px, 0.28fr) minmax(0, 1fr); + gap: 10px; + padding: 7px 9px; + border-bottom: 1px solid var(--line-soft); +} + +.professional-source-details dl > div:last-child { + border-bottom: 0; +} + +.professional-source-details dt, +.professional-source-details dd { + margin: 0; + overflow-wrap: anywhere; + font-size: 12px; + line-height: 1.55; +} + +.professional-source-details dt { + color: var(--muted); +} + +.professional-source-details dd { + color: var(--text); +} + +.citation-plain { + padding: 8px 10px; + border: 1px solid var(--line-soft); + border-radius: 6px; + background: var(--surface-soft); + color: var(--text); + font-size: 13px; + line-height: 1.5; +} + +.more-button { + display: block; + margin: 26px auto 0; + border: 0; + color: var(--blue); + background: transparent; + font-weight: 800; +} + +.library-toolbar { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + margin-bottom: 14px; +} + +.library-toolbar p { + margin: 0; + color: var(--muted); + font-size: 14px; +} + +.material-import { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(220px, 320px) auto; + gap: 10px; + margin-bottom: 16px; + padding: 14px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; +} + +.material-import textarea { + grid-column: 1 / -1; +} + +.material-import button { + padding: 0 18px; +} + +.library-table { + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; + background: #fff; +} + +.library-head, +.library-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 96px 136px; + gap: 14px; + align-items: center; + min-height: 52px; + padding: 0 16px; + border-bottom: 1px solid var(--line-soft); +} + +.library-head { + color: var(--text); + background: var(--surface-soft); + font-size: 13px; + font-weight: 800; +} + +.library-row:last-child { + border-bottom: 0; +} + +.library-row span { + color: var(--text); + font-size: 14px; +} + +.library-dossier-link { + min-width: 0; + padding: 0; + overflow: hidden; + border: 0; + background: transparent; + color: var(--ink); + font: inherit; + font-weight: 800; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.library-dossier-link:hover { + color: var(--primary); + text-decoration: underline; + text-underline-offset: 3px; +} + +.qa-note { + margin: -4px 0 16px; + color: var(--text); + font-size: 13px; + line-height: 1.5; +} + +.chat-area { + min-height: 250px; + max-height: 360px; + display: grid; + align-content: start; + gap: 14px; + overflow: auto; +} + +.chat-message { + max-width: min(820px, 88%); + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; +} + +.chat-message.user { + justify-self: end; + max-width: 360px; + border-color: transparent; + background: var(--surface-tint); +} + +.chat-message p { + margin: 0; + color: var(--ink); + font-size: 14px; + line-height: 1.85; + text-wrap: pretty; +} + +.chat-message.user > p { + white-space: pre-wrap; +} + +.qa-answer-body { + display: grid; + gap: 14px; +} + +.qa-answer-paragraph { + display: grid; + gap: 7px; +} + +.qa-answer-paragraph + .qa-answer-paragraph { + padding-top: 11px; + border-top: 1px solid var(--line); +} + +.qa-answer-paragraph h3 { + margin: 0; + color: var(--ink); + font-size: 14px; + line-height: 1.5; +} + +.chat-message.is-pending { + display: inline-flex; + align-items: center; + width: fit-content; + color: var(--text); + background: var(--surface-soft); +} + +.chat-message.is-pending span { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 13px; + line-height: 1.5; +} + +.chat-message.is-pending span::before { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--primary); + box-shadow: 0 0 0 0 rgba(79, 70, 229, 0.28); + content: ""; + animation: qa-pending-pulse 1.4s ease-out infinite; +} + +@keyframes qa-pending-pulse { + 70% { + box-shadow: 0 0 0 7px rgba(79, 70, 229, 0); + } + + 100% { + box-shadow: 0 0 0 0 rgba(79, 70, 229, 0); + } +} + +.qa-citation-anchor { + white-space: nowrap; +} + +.qa-answer-refs { + display: inline-flex; + gap: 3px; + margin-left: 4px; + vertical-align: super; + line-height: 1; +} + +.qa-answer-refs span { + color: var(--blue); + font-size: 11px; + font-weight: 800; +} + +.citation-row { + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.citation-row span { + min-height: 26px; + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 6px; + color: var(--blue); + background: var(--surface-tint); + font-size: 13px; + font-weight: 800; +} + +.citation-row span b { + margin-right: 5px; +} + +.qa-input { + display: grid; + grid-template-columns: minmax(0, 1fr) 86px; + gap: 10px; + margin-top: 16px; +} + +.qa-input input { + height: 48px; +} + +.qa-input button { + height: 48px; +} + +.management-section { + display: grid; + gap: 16px; + padding-top: 24px; + border-top: 1px solid var(--line); +} + +.operator-status { + display: grid; + gap: 10px; + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); +} + +.operator-status-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.operator-status-heading h2 { + margin: 0; + color: var(--ink); + font-size: 15px; +} + +.operator-status-heading > span { + color: var(--muted); + font-size: 11px; + font-weight: 800; +} + +.operator-status-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.operator-status-grid article { + min-width: 0; + display: grid; + gap: 4px; + padding: 10px 12px; + border-left: 3px solid var(--line); + background: var(--surface-soft); +} + +.operator-status-grid article.is-ready { + border-left-color: var(--green); +} + +.operator-status-grid article.is-warning { + border-left-color: #c48300; +} + +.operator-status-grid span, +.operator-status-grid em { + overflow: hidden; + color: var(--muted); + font-size: 11px; + font-style: normal; + text-overflow: ellipsis; + white-space: nowrap; +} + +.operator-status-grid strong { + overflow: hidden; + color: var(--ink); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.operator-status-error { + padding: 10px 12px; + border-left: 3px solid #d14343; + color: #9f2d2d; + background: #fff5f5; + font-size: 12px; +} + +.management-heading, +.management-panel-title, +.sync-source-actions, +.job-row summary, +.job-row summary > span { + display: flex; + align-items: center; +} + +.management-heading { + justify-content: space-between; + gap: 18px; +} + +.management-heading h2, +.management-panel-title h3 { + margin: 0; + color: var(--ink); +} + +.management-heading h2 { + font-size: 18px; +} + +.management-heading p { + margin: 5px 0 0; + color: var(--muted); + font-size: 13px; +} + +.management-error { + padding: 10px 12px; + border-left: 3px solid #d14343; + color: #9f2d2d; + background: #fff5f5; + font-size: 13px; + line-height: 1.5; +} + +.management-grid { + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr); + gap: 16px; + align-items: start; +} + +.sync-source-panel, +.job-panel { + min-width: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); +} + +.management-panel-title { + min-height: 52px; + justify-content: space-between; + padding: 0 16px; + border-bottom: 1px solid var(--line-soft); +} + +.management-panel-title h3 { + font-size: 15px; +} + +.management-panel-title span { + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.management-empty { + min-height: 112px; + display: grid; + place-content: center; + padding: 20px; + color: var(--muted); + font-size: 13px; + text-align: center; +} + +.sync-source-row { + min-height: 96px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: center; + padding: 14px 16px; + border-bottom: 1px solid var(--line-soft); +} + +.sync-source-row:last-child, +.job-row:last-child { + border-bottom: 0; +} + +.sync-source-row.is-error { + box-shadow: inset 3px 0 0 #d14343; +} + +.sync-source-main { + min-width: 0; + display: grid; + gap: 4px; +} + +.sync-source-main strong, +.sync-source-main span, +.sync-source-main em { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sync-source-main strong { + color: var(--ink); + font-size: 14px; +} + +.sync-source-main span, +.sync-source-main em, +.sync-source-main p { + font-size: 12px; +} + +.sync-source-main em { + color: var(--muted); + font-style: normal; +} + +.sync-source-main p { + margin: 2px 0 0; + color: #9f2d2d; +} + +.sync-source-actions { + justify-content: flex-end; + flex-wrap: wrap; + gap: 6px; +} + +.sync-source-actions button { + min-width: 52px; + height: 30px; + padding: 0 9px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + background: #fff; + font-size: 12px; + font-weight: 700; +} + +.sync-source-actions button:hover { + border-color: var(--blue); + color: var(--blue); +} + +.sync-source-actions .danger-action { + color: #b42318; +} + +.sync-source-actions .danger-action:hover { + border-color: #d14343; + color: #9f2d2d; +} + +.source-status { + min-height: 26px; + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 6px; + color: var(--text); + background: var(--surface-soft); + font-size: 11px; + font-weight: 800; +} + +.source-status.is-active { + color: #117a4f; + background: #eaf8f1; +} + +.source-status.is-paused { + color: #8a5800; + background: #fff5db; +} + +.source-status.is-error { + color: #9f2d2d; + background: #fff0f0; +} + +.job-row { + border-bottom: 1px solid var(--line-soft); +} + +.job-row summary { + min-height: 64px; + justify-content: space-between; + gap: 12px; + padding: 10px 16px; + cursor: pointer; + list-style: none; +} + +.job-row summary::-webkit-details-marker { + display: none; +} + +.job-row summary > span:first-child { + min-width: 0; + display: grid; + gap: 3px; +} + +.job-row summary strong { + overflow: hidden; + color: var(--ink); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.job-row summary em { + color: var(--muted); + font-size: 11px; + font-style: normal; +} + +.job-status { + flex: 0 0 auto; + font-size: 12px; + font-weight: 800; +} + +.job-row.is-success .job-status { + color: var(--green); +} + +.job-row.is-error .job-status, +.job-error { + color: #b42318; +} + +.job-row > div { + display: grid; + gap: 7px; + padding: 0 16px 14px; +} + +.job-row > div p { + min-width: 0; + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 8px; + margin: 0; + color: var(--muted); + font-size: 12px; +} + +.job-row code { + overflow-wrap: anywhere; + color: var(--text); +} + +.job-row > div .job-actions { + display: flex; + justify-content: flex-end; + gap: 6px; + padding-top: 3px; +} + +.job-actions button { + min-width: 52px; + height: 30px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + background: #fff; + font-size: 12px; + font-weight: 700; +} + +.job-actions button:hover { + border-color: var(--blue); + color: var(--blue); +} + +.job-actions .danger-action { + color: #b42318; +} + +.job-actions .danger-action:hover { + border-color: #d14343; + color: #9f2d2d; +} + +@media (max-width: 1180px) { + .sales-layout { + grid-template-columns: 330px minmax(0, 1fr); + } + + .workspace, + .workspace-empty { + padding: 26px 26px 48px; + } + + .progress-card { + grid-template-columns: 1fr; + gap: 10px; + } + + .support-grid { + grid-template-columns: 1fr; + } + + .management-grid { + grid-template-columns: 1fr; + } + + .operator-status-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .provider-diagnostic-grid { + grid-template-columns: 1fr; + } + + .provider-diagnostic-row, + .provider-diagnostic-row:nth-child(2n) { + border-right: 0; + } + + .update-row { + grid-template-columns: 52px minmax(0, 1fr); + } + + .dossier-layout { + grid-template-columns: 1fr; + } + + .dossier-detail { + position: static; + } + + .source-cell { + grid-column: 2; + grid-template-columns: repeat(3, auto); + gap: 14px; + } +} + +@media (max-width: 780px) { + .sales-topbar { + padding: 0 14px; + } + + .brand { + gap: 8px; + } + + .brand strong { + font-size: 18px; + } + + .topbar-right { + gap: 6px; + } + + .user-name { + display: none; + } + + .auth-panel { + padding: 24px 20px; + } + + .dialog-backdrop { + align-items: end; + padding: 0; + } + + .dialog-modal { + width: 100%; + max-height: 88vh; + border-right: 0; + border-bottom: 0; + border-left: 0; + border-radius: 8px 8px 0 0; + } + + .feishu-import-dates { + grid-template-columns: 1fr; + } + + .feishu-import-actions button { + flex: 1 1 0; + } + + .runtime-status { + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + } + + .operator-status-grid { + grid-template-columns: 1fr; + } + + .management-heading { + align-items: flex-start; + } + + .sync-source-row { + grid-template-columns: 1fr; + } + + .sync-source-actions { + justify-content: flex-start; + } + + .sales-layout { + grid-template-columns: 1fr; + } + + .sales-sidebar { + display: none; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .sales-layout.is-mobile-navigation-open .sales-sidebar { + display: block; + } + + .sales-layout.is-mobile-navigation-open .workspace { + display: none; + } + + .mobile-navigation-toggle { + display: grid; + width: 34px; + height: 34px; + flex: 0 0 auto; + place-items: center; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--ink); + background: var(--surface); + font-size: 20px; + line-height: 1; + } + + .mobile-navigation-empty-action { + display: block; + width: max-content; + margin: 14px auto 0; + } + + .logout-button { + width: 34px; + min-width: 34px; + padding: 0; + } + + .logout-label { + display: none; + } + + .logout-icon { + display: inline; + font-size: 18px; + } + + .workspace, + .workspace-empty { + padding: 22px 16px 40px; + } + + .company-header { + flex-direction: column; + } + + .operation-strip summary { + grid-template-columns: 8px minmax(0, 1fr); + } + + .operation-main { + align-items: flex-start; + flex-direction: column; + gap: 2px; + } + + .operation-token { + grid-column: 2; + } + + .operation-detail { + padding-left: 33px; + } + + .section-title { + align-items: flex-start; + flex-direction: column; + } + + .version-tabs { + justify-content: flex-start; + } + + .header-actions { + align-items: stretch; + width: 100%; + } + + .dossier-job-control { + width: 100%; + } + + .dossier-job-running, + .dossier-job-retry { + flex: 1; + } + + .company-title h1 { + font-size: 25px; + } + + .company-logo { + width: 54px; + height: 54px; + font-size: 28px; + } + + .citation-block-head { + align-items: flex-start; + flex-direction: column; + gap: 3px; + } + + .professional-source-details dl > div { + grid-template-columns: 1fr; + gap: 2px; + } + + .update-row, + .dossier-layout, + .support-heading, + .library-head, + .library-row, + .qa-input { + grid-template-columns: 1fr; + } + + .support-heading { + align-items: flex-start; + flex-direction: column; + } + + .support-heading .filter-row { + justify-content: flex-start; + } + + .library-heading { + width: 100%; + } + + .library-heading-row { + width: 100%; + align-items: center; + flex-direction: row; + } + + .library-control-row { + width: 100%; + align-items: stretch; + flex-direction: column; + gap: 12px; + } + + .material-filter { + grid-template-columns: repeat(4, 76px); + width: 100%; + max-width: 100%; + } + + .material-filter button { + min-width: 76px; + } + + .library-tools { + display: flex; + justify-content: flex-end; + order: -1; + } + + .library-heading .library-import-button { + width: auto; + } + + .doc-icon { + display: none; + } + + .source-cell { + grid-column: auto; + grid-template-columns: 1fr; + gap: 5px; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes dossier-job-flow { + to { + transform: translateX(340%); + } +} + +@media (prefers-reduced-motion: reduce) { + .dossier-job-spinner, + .dossier-job-flow::after { + animation-duration: 2.4s; + } +} diff --git a/demohouse/sales-intelligence-workbench/frontend/text-format.js b/demohouse/sales-intelligence-workbench/frontend/text-format.js new file mode 100644 index 00000000..c81575ee --- /dev/null +++ b/demohouse/sales-intelligence-workbench/frontend/text-format.js @@ -0,0 +1,189 @@ +(function (root) { + const HAN_CHARACTER = /[\u3400-\u9fff]/; + const HAN_NUMERAL_LIST = /^[一二三四五六七八九十]+、\s*\S/; + const HAN_ORDINAL_LIST = /^(?:第[一二三四五六七八九十]+[,、]|[一二三四五六七八九十]+是)\s*\S/; + + function isHanCharacter(value) { + return HAN_CHARACTER.test(String(value || "")); + } + + function normalizeChineseTypography(value) { + const source = String(value ?? "").replace(/\r/g, ""); + const characters = Array.from(source); + const normalized = characters.map((character, index) => { + const previous = characters[index - 1] || ""; + const next = characters[index + 1] || ""; + const touchesChinese = isHanCharacter(previous) || isHanCharacter(next); + if (!touchesChinese) return character; + if (character === ",") return ","; + if (character === "." && /\d/.test(previous) && !/\d/.test(next)) return "."; + if (character === ".") return "。"; + if (character === ";") return ";"; + if (character === "!") return "!"; + if (character === "?") return "?"; + if (character === ":") return ":"; + return character; + }).join(""); + + return normalized + .split("\n") + .map((line) => line.replace(/[ \t]+/g, " ").trim()) + .join("\n") + .replace(/(^|\n)((?:\d[ \t]*\n+)+)(?=\d)/gm, (_match, prefix, fragments) => ( + `${prefix}${fragments.replace(/\s+/g, "")}` + )) + .replace(/([^。!?;:\n])\n{2,}(?=\d)/g, "$1") + .replace(/[ \t]*([,。;!?:、])[ \t]*/g, "$1") + .replace(/([\u3400-\u9fff])([A-Za-z])/g, "$1 $2") + .replace(/([A-Za-z])([\u3400-\u9fff])/g, "$1 $2") + .replace(/[ \t]{2,}/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim(); + } + + function isNumberedListLine(value) { + const line = String(value || "").trim(); + if (!line) return false; + if (HAN_NUMERAL_LIST.test(line)) return true; + if (HAN_ORDINAL_LIST.test(line)) return true; + if (/^\d{1,2}[))、]\s*\S/.test(line)) return true; + if (/^\d{1,2}\.\s+\S/.test(line)) return true; + return /^\d{1,2}\.(?!\d)\S/.test(line); + } + + function structureInlineLists(value) { + return String(value || "") + .replace(/([。!?;:])\s*(?=第[一二三四五六七八九十]+[,、]\s*\S)/g, "$1\n\n") + .replace(/[ \t]+(?=第[一二三四五六七八九十]+[,、]\s*\S)/g, "\n\n") + .replace(/([。!?;:])\s*(?=[一二三四五六七八九十]+是\s*\S)/g, "$1\n\n") + .replace(/[ \t]+(?=[一二三四五六七八九十]+是\s*\S)/g, "\n\n") + .replace(/([。!?;:])\s*(?=\d{1,2}[))、]\s*\S)/g, "$1\n\n") + .replace(/[ \t]+(?=\d{1,2}[))、]\s*\S)/g, "\n\n") + .replace(/([。!?;:])\s*(?=\d{1,2}\.(?!\d)\s*\S)/g, "$1\n\n") + .replace(/[ \t]+(?=\d{1,2}\.(?!\d)\s*\S)/g, "\n\n"); + } + + function joinSoftWrappedLine(current, next) { + if (!current) return next; + if (!next) return current; + const needsSpace = /[A-Za-z]$/.test(current) && /^[A-Za-z]/.test(next); + return `${current}${needsSpace ? " " : ""}${next}`; + } + + function unwrapBlock(value) { + const lines = String(value || "") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + if (lines.length < 2) return lines; + + const segments = []; + let buffer = ""; + const flush = () => { + if (!buffer) return; + segments.push(buffer); + buffer = ""; + }; + + lines.forEach((line) => { + if (isNumberedListLine(line)) { + flush(); + segments.push(line); + return; + } + buffer = joinSoftWrappedLine(buffer, line); + }); + flush(); + return segments; + } + + function groupSentences(value, maxLength) { + const text = String(value || "").trim(); + if (!text || isNumberedListLine(text)) return text ? [text] : []; + const sentences = text.match(/[^。!?;]+[。!?;]?/g) + ?.map((item) => item.trim()) + .filter(Boolean) || []; + if (sentences.length < 2) return [text]; + + const paragraphs = []; + let buffer = ""; + sentences.forEach((sentence) => { + if (buffer && buffer.length + sentence.length > maxLength) { + paragraphs.push(buffer); + buffer = sentence; + return; + } + buffer += sentence; + }); + if (buffer) paragraphs.push(buffer); + return paragraphs; + } + + function splitReadableBlocks(value, maxLength = 180) { + const normalized = structureInlineLists(normalizeChineseTypography(value)); + if (!normalized) return []; + return normalized + .split(/\n{2,}/) + .flatMap(unwrapBlock) + .flatMap((item) => groupSentences(item, maxLength)) + .map((item) => item.trim()) + .filter(Boolean); + } + + function normalizedCitationIds(value) { + return [...new Set((value || []).map((item) => String(item || "").trim()).filter(Boolean))]; + } + + function citationSetKey(value) { + return normalizedCitationIds(value).sort().join("\u001f"); + } + + function collapseRepeatedCitationRuns(paragraphs) { + const items = (paragraphs || []).map((paragraph) => ({ + ...paragraph, + citationIds: normalizedCitationIds(paragraph.citationIds), + })); + return items.map((paragraph, index) => { + const currentKey = citationSetKey(paragraph.citationIds); + const nextKey = citationSetKey(items[index + 1]?.citationIds); + const currentGroup = String(paragraph.citationGroup ?? "default"); + const nextGroup = String(items[index + 1]?.citationGroup ?? "default"); + return { + ...paragraph, + displayCitationIds: currentKey && currentKey === nextKey && currentGroup === nextGroup + ? [] + : paragraph.citationIds, + }; + }); + } + + function dedupeCitationEntries(entries) { + const uniqueEntries = []; + const numberByKey = new Map(); + const citationNumbers = {}; + + (entries || []).forEach((entry) => { + const id = String(entry?.id || "").trim(); + const label = String(entry?.label || "").trim(); + if (!label) return; + const key = label.toLocaleLowerCase(); + let number = numberByKey.get(key); + if (!number) { + number = uniqueEntries.length + 1; + numberByKey.set(key, number); + uniqueEntries.push({ ...entry, id, label }); + } + if (id) citationNumbers[id] = number; + }); + + return { entries: uniqueEntries, citationNumbers }; + } + + root.SalesTextFormat = Object.freeze({ + collapseRepeatedCitationRuns, + dedupeCitationEntries, + normalizeChineseTypography, + splitReadableBlocks, + structureInlineLists, + }); +})(typeof window === "undefined" ? globalThis : window); diff --git a/demohouse/sales-intelligence-workbench/package-lock.json b/demohouse/sales-intelligence-workbench/package-lock.json new file mode 100644 index 00000000..bce3f64d --- /dev/null +++ b/demohouse/sales-intelligence-workbench/package-lock.json @@ -0,0 +1,15 @@ +{ + "name": "sales-intelligence-workbench-oss", + "version": "0.10.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sales-intelligence-workbench-oss", + "version": "0.10.0", + "engines": { + "node": ">=20" + } + } + } +} diff --git a/demohouse/sales-intelligence-workbench/package.json b/demohouse/sales-intelligence-workbench/package.json new file mode 100644 index 00000000..23dbb0e0 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/package.json @@ -0,0 +1,20 @@ +{ + "name": "sales-intelligence-workbench-oss", + "version": "0.10.0", + "private": true, + "type": "module", + "scripts": { + "release:validate": "node scripts/validate-public-release.mjs", + "skill:install": "node scripts/install-codex-skill.mjs", + "skill:install:codex": "node scripts/install-codex-skill.mjs", + "skill:install:claude": "node scripts/install-claude-code-skill.mjs", + "skill:install:all": "node scripts/install-agent-skill.mjs --target all", + "skill:command": "node scripts/print-public-skill-command.mjs", + "skill:validate": "node scripts/validate-skill-package.mjs", + "skill:test": "node scripts/test-skill-installer.mjs", + "verify": "npm run release:validate && npm run skill:validate && npm run skill:test && npm --prefix backend run release:verify" + }, + "engines": { + "node": ">=20" + } +} diff --git a/demohouse/sales-intelligence-workbench/scripts/install-agent-skill.mjs b/demohouse/sales-intelligence-workbench/scripts/install-agent-skill.mjs new file mode 100644 index 00000000..8b9caf24 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/scripts/install-agent-skill.mjs @@ -0,0 +1,153 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const skillName = "sales-intelligence-workbench"; +const source = path.join(root, "skills", skillName); + +const targetDefinitions = { + codex: { + label: "Codex", + configRoot: (environment) => path.resolve( + environment.CODEX_HOME || path.join(os.homedir(), ".codex"), + ), + restartHint: "重新启动 Codex 后,可使用 $sales-intelligence-workbench。", + }, + "claude-code": { + label: "Claude Code", + configRoot: (environment) => path.resolve( + environment.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"), + ), + restartHint: "在 Claude Code 中可使用 /sales-intelligence-workbench;当前会话未识别时请重启。", + }, +}; + +function copyFilter(entry) { + const relative = path.relative(source, entry); + if (!relative) return true; + const segments = relative.split(path.sep); + const name = path.basename(entry); + return !segments.some((segment) => [ + "node_modules", + "dist", + ".git", + ".temp", + "coverage", + ].includes(segment)) + && name !== ".DS_Store" + && !(name.startsWith(".env.") && name !== ".env.example") + && name !== ".env" + && !/\.(?:log|pid)$/i.test(name); +} + +function normalizeTarget(value) { + const target = String(value || "").trim().toLowerCase(); + if (target === "claude" || target === "claude_code") return "claude-code"; + if (target === "codex" || target === "claude-code" || target === "all") return target; + throw new Error("安装目标必须是 codex、claude-code 或 all。"); +} + +function requestedTarget(argv, defaultTarget) { + const targetIndex = argv.indexOf("--target"); + if (targetIndex < 0) return normalizeTarget(defaultTarget); + const value = argv[targetIndex + 1]; + if (!value || value.startsWith("--")) throw new Error("--target 缺少参数值。"); + return normalizeTarget(value); +} + +function resolveTargets(target, environment) { + const names = target === "all" ? ["codex", "claude-code"] : [target]; + const targets = names.map((name) => { + const definition = targetDefinitions[name]; + const skillsRoot = path.join(definition.configRoot(environment), "skills"); + return { + ...definition, + name, + skillsRoot, + target: path.join(skillsRoot, skillName), + }; + }); + if (new Set(targets.map((item) => item.target)).size !== targets.length) { + throw new Error("Codex 与 Claude Code 的 Skill 安装目录不能指向同一路径。"); + } + return targets; +} + +function installOne(definition, force) { + fs.mkdirSync(definition.skillsRoot, { recursive: true, mode: 0o700 }); + const staging = path.join(definition.skillsRoot, `.${skillName}-${randomUUID()}.install`); + const backup = path.join(definition.skillsRoot, `.${skillName}.previous`); + + try { + fs.cpSync(source, staging, { + recursive: true, + force: true, + filter: copyFilter, + }); + fs.rmSync(backup, { recursive: true, force: true }); + if (fs.existsSync(definition.target)) { + if (!force) { + throw new Error( + `${definition.label} Skill 已存在:${definition.target}。如需更新,请追加 --force。`, + ); + } + fs.renameSync(definition.target, backup); + } + fs.renameSync(staging, definition.target); + fs.rmSync(backup, { recursive: true, force: true }); + } catch (error) { + fs.rmSync(staging, { recursive: true, force: true }); + if (!fs.existsSync(definition.target) && fs.existsSync(backup)) { + fs.renameSync(backup, definition.target); + } + throw error; + } +} + +export function installAgentSkill({ + target = "codex", + force = false, + environment = process.env, +} = {}) { + if (!fs.existsSync(path.join(source, "SKILL.md"))) { + throw new Error(`Skill 源目录无效:${source}`); + } + const definitions = resolveTargets(normalizeTarget(target), environment); + if (!force) { + const existing = definitions.find((definition) => fs.existsSync(definition.target)); + if (existing) { + throw new Error( + `${existing.label} Skill 已存在:${existing.target}。如需更新,请追加 --force。`, + ); + } + } + for (const definition of definitions) installOne(definition, force); + return definitions; +} + +export function runInstallerCli({ + argv = process.argv.slice(2), + defaultTarget = "codex", + environment = process.env, +} = {}) { + const target = requestedTarget(argv, defaultTarget); + const force = argv.includes("--force"); + const installed = installAgentSkill({ target, force, environment }); + for (const definition of installed) { + process.stdout.write(`${definition.label} Skill 已安装:${definition.target}\n`); + process.stdout.write(`${definition.restartHint}\n`); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + runInstallerCli(); + } catch (error) { + process.stderr.write(`错误:${error instanceof Error ? error.message : String(error)}\n`); + if (process.env.DEBUG && error?.stack) process.stderr.write(`${error.stack}\n`); + process.exitCode = 1; + } +} diff --git a/demohouse/sales-intelligence-workbench/scripts/install-claude-code-skill.mjs b/demohouse/sales-intelligence-workbench/scripts/install-claude-code-skill.mjs new file mode 100644 index 00000000..b1aac959 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/scripts/install-claude-code-skill.mjs @@ -0,0 +1,9 @@ +import { runInstallerCli } from "./install-agent-skill.mjs"; + +try { + runInstallerCli({ defaultTarget: "claude-code" }); +} catch (error) { + process.stderr.write(`错误:${error instanceof Error ? error.message : String(error)}\n`); + if (process.env.DEBUG && error?.stack) process.stderr.write(`${error.stack}\n`); + process.exitCode = 1; +} diff --git a/demohouse/sales-intelligence-workbench/scripts/install-codex-skill.mjs b/demohouse/sales-intelligence-workbench/scripts/install-codex-skill.mjs new file mode 100644 index 00000000..e803cf69 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/scripts/install-codex-skill.mjs @@ -0,0 +1,9 @@ +import { runInstallerCli } from "./install-agent-skill.mjs"; + +try { + runInstallerCli({ defaultTarget: "codex" }); +} catch (error) { + process.stderr.write(`错误:${error instanceof Error ? error.message : String(error)}\n`); + if (process.env.DEBUG && error?.stack) process.stderr.write(`${error.stack}\n`); + process.exitCode = 1; +} diff --git a/demohouse/sales-intelligence-workbench/scripts/print-public-skill-command.mjs b/demohouse/sales-intelligence-workbench/scripts/print-public-skill-command.mjs new file mode 100644 index 00000000..49cfe0c3 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/scripts/print-public-skill-command.mjs @@ -0,0 +1,74 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); + +function option(name) { + const index = process.argv.indexOf(name); + if (index < 0) return ""; + const value = process.argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${name} 缺少参数值。`); + return value.trim(); +} + +function usage() { + return ` +生成销售助手公开初始化口令 + +用法: + node scripts/print-public-skill-command.mjs \\ + --repository https://github.com// \\ + --ref v${packageJson.version} \\ + [--skill-path skills/sales-intelligence-workbench/SKILL.md] + +说明: + - --repository 必须是公开 GitHub 仓库根地址。 + - --ref 必须是已发布的不可变 tag 或 commit;正式发布不要使用 main。 + - --skill-path 是 Skill 在仓库内的相对路径;默认适用于独立仓库。 + - 本命令只生成文字,不访问网络、不修改文件。 +`.trimStart(); +} + +if (process.argv.includes("--help") || process.argv.includes("-h")) { + process.stdout.write(usage()); + process.exit(0); +} + +const repository = option("--repository"); +const ref = option("--ref"); +const skillPath = option("--skill-path") || "skills/sales-intelligence-workbench/SKILL.md"; +if (!repository || !ref) throw new Error("必须同时提供 --repository 和 --ref。"); +if (ref === "main" || ref === "master") { + throw new Error("正式初始化口令必须固定到 release tag 或 commit,不能使用 main/master。"); +} + +let url; +try { + url = new URL(repository); +} catch { + throw new Error("--repository 不是有效 URL。"); +} +if (url.protocol !== "https:" || url.hostname !== "github.com") { + throw new Error("--repository 必须是 https://github.com//。"); +} + +const segments = url.pathname.replace(/\.git$/, "").split("/").filter(Boolean); +if (segments.length !== 2) { + throw new Error("--repository 必须指向 GitHub 仓库根目录,不能包含额外路径。"); +} + +const [owner, repo] = segments; +const skillPathSegments = skillPath.split("/").filter(Boolean); +if ( + skillPath.startsWith("/") + || skillPathSegments.includes(".") + || skillPathSegments.includes("..") + || skillPathSegments.at(-1) !== "SKILL.md" +) { + throw new Error("--skill-path 必须是仓库内以 SKILL.md 结尾的安全相对路径。"); +} +const encodedSkillPath = skillPathSegments.map((segment) => encodeURIComponent(segment)).join("/"); +const entryUrl = `https://github.com/${owner}/${repo}/blob/${encodeURIComponent(ref)}/${encodedSkillPath}`; +process.stdout.write(`帮我初始化销售助手:${entryUrl}\n`); diff --git a/demohouse/sales-intelligence-workbench/scripts/test-skill-installer.mjs b/demohouse/sales-intelligence-workbench/scripts/test-skill-installer.mjs new file mode 100644 index 00000000..8422dbf3 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/scripts/test-skill-installer.mjs @@ -0,0 +1,200 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const commandPrinter = path.join(root, "scripts", "print-public-skill-command.mjs"); +const temporaryHome = fs.mkdtempSync(path.join(os.tmpdir(), "sales-workbench-skill-")); +const codexHome = path.join(temporaryHome, "codex"); +const claudeConfigDir = path.join(temporaryHome, "claude"); +const baseEnvironment = { + ...process.env, + HOME: temporaryHome, +}; +const clients = [ + { + label: "Codex", + script: "install-codex-skill.mjs", + environment: { CODEX_HOME: codexHome }, + target: path.join(codexHome, "skills", "sales-intelligence-workbench"), + trigger: /\$sales-intelligence-workbench/, + }, + { + label: "Claude Code", + script: "install-claude-code-skill.mjs", + environment: { CLAUDE_CONFIG_DIR: claudeConfigDir }, + target: path.join(claudeConfigDir, "skills", "sales-intelligence-workbench"), + trigger: /\/sales-intelligence-workbench/, + }, +]; + +function run(client, args, expectedStatus) { + const result = spawnSync(process.execPath, [ + path.join(root, "scripts", client.script), + ...args, + ], { + cwd: root, + env: { ...baseEnvironment, ...client.environment }, + encoding: "utf8", + }); + assert.equal( + result.status, + expectedStatus, + `${client.label} 安装器退出码异常。\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + return result; +} + +function assertInstalled(client) { + const installedSkillPath = path.join(client.target, "SKILL.md"); + assert.ok(fs.existsSync(installedSkillPath)); + assert.ok(fs.existsSync(path.join(client.target, "scripts", "onboard.mjs"))); + assert.ok(fs.existsSync(path.join(client.target, "assets", "app", "backend", ".env.example"))); + assert.equal(fs.existsSync(path.join(client.target, ".DS_Store")), false); + const installedSkill = fs.readFileSync(installedSkillPath, "utf8"); + assert.match(installedSkill, /## 远程 Skill 入口/); + assert.match(installedSkill, /skills\/sales-intelligence-workbench\/SKILL\.md/); + assert.match(installedSkill, /node scripts\/validate-skill-package\.mjs/); + assert.match(installedSkill, /node scripts\/test-skill-installer\.mjs/); +} + +try { + for (const client of clients) { + const installed = run(client, [], 0); + assert.match(installed.stdout, new RegExp(`${client.label} Skill 已安装`)); + assert.match(installed.stdout, client.trigger); + assertInstalled(client); + + const duplicate = run(client, [], 1); + assert.match(duplicate.stderr, /Skill 已存在/); + run(client, ["--force"], 0); + assertInstalled(client); + } + + const allSandbox = path.join(temporaryHome, "all"); + const allResult = spawnSync(process.execPath, [ + path.join(root, "scripts", "install-agent-skill.mjs"), + "--target", + "all", + ], { + cwd: root, + env: { + ...baseEnvironment, + CODEX_HOME: path.join(allSandbox, "codex"), + CLAUDE_CONFIG_DIR: path.join(allSandbox, "claude"), + }, + encoding: "utf8", + }); + assert.equal(allResult.status, 0, allResult.stderr || allResult.stdout); + assert.match(allResult.stdout, /Codex Skill 已安装/); + assert.match(allResult.stdout, /Claude Code Skill 已安装/); + assert.ok(fs.existsSync(path.join( + allSandbox, + "codex", + "skills", + "sales-intelligence-workbench", + "SKILL.md", + ))); + assert.ok(fs.existsSync(path.join( + allSandbox, + "claude", + "skills", + "sales-intelligence-workbench", + "SKILL.md", + ))); + + const onboardingEnvironment = { + ...baseEnvironment, + CODEX_HOME: codexHome, + PORT: process.env.SKILL_TEST_PORT || "18787", + SALES_WORKBENCH_HOME: path.join(temporaryHome, "runtime"), + SALES_WORKBENCH_CONFIG_HOME: path.join(temporaryHome, "config"), + SALES_WORKBENCH_STATE_HOME: path.join(temporaryHome, "state"), + }; + const codexTarget = clients[0].target; + const help = spawnSync(process.execPath, [ + path.join(codexTarget, "scripts", "onboard.mjs"), + "--help", + ], { + env: onboardingEnvironment, + encoding: "utf8", + }); + assert.equal(help.status, 0, help.stderr); + assert.match(help.stdout, /安全编排/); + + const onboarding = spawnSync(process.execPath, [ + path.join(codexTarget, "scripts", "onboard.mjs"), + "--workspace-name", "隔离验收工作台", + "--sales-goal", "验证 Skill 部署入口", + "--target-scope", "测试企业", + "--sources", "none", + "--deployment", "local", + ], { + env: onboardingEnvironment, + encoding: "utf8", + }); + assert.equal( + onboarding.status, + 0, + `onboarding 退出码异常。\nstdout:\n${onboarding.stdout}\nstderr:\n${onboarding.stderr}`, + ); + assert.match(onboarding.stdout, /当前阶段:app/); + assert.match(onboarding.stdout, /已安全暂停在“agent_plan”阶段/); + assert.ok(fs.existsSync(path.join(onboardingEnvironment.SALES_WORKBENCH_HOME, "app", "backend", "package.json"))); + assert.ok(fs.existsSync(path.join(onboardingEnvironment.SALES_WORKBENCH_STATE_HOME, "builder-brief.json"))); + assert.equal(fs.existsSync(path.join(onboardingEnvironment.SALES_WORKBENCH_CONFIG_HOME, "credentials.env")), false); + assert.equal(fs.existsSync(path.join(onboardingEnvironment.SALES_WORKBENCH_STATE_HOME, "doctor-live.json")), false); + + const publicCommand = spawnSync(process.execPath, [ + commandPrinter, + "--repository", "https://github.com/example/sales-workbench", + "--ref", "v0.9.1", + ], { + cwd: root, + env: baseEnvironment, + encoding: "utf8", + }); + assert.equal(publicCommand.status, 0, publicCommand.stderr); + assert.equal( + publicCommand.stdout.trim(), + "帮我初始化销售助手:https://github.com/example/sales-workbench/blob/v0.9.1/skills/sales-intelligence-workbench/SKILL.md", + ); + assert.doesNotMatch(publicCommand.stdout, /sales-assistant-builder\.md/); + + const nestedPublicCommand = spawnSync(process.execPath, [ + commandPrinter, + "--repository", "https://github.com/volcengine/ai-app-lab", + "--ref", "0123456789abcdef", + "--skill-path", "demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md", + ], { + cwd: root, + env: baseEnvironment, + encoding: "utf8", + }); + assert.equal(nestedPublicCommand.status, 0, nestedPublicCommand.stderr); + assert.equal( + nestedPublicCommand.stdout.trim(), + "帮我初始化销售助手:https://github.com/volcengine/ai-app-lab/blob/0123456789abcdef/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md", + ); + + const mutableCommand = spawnSync(process.execPath, [ + commandPrinter, + "--repository", "https://github.com/example/sales-workbench", + "--ref", "main", + ], { + cwd: root, + env: baseEnvironment, + encoding: "utf8", + }); + assert.equal(mutableCommand.status, 1); + assert.match(mutableCommand.stderr, /不能使用 main\/master/); + + process.stdout.write( + "Codex 与 Claude Code Skill 隔离安装、双端安装、重复安装保护、强制更新和安全 onboarding 检查通过。\n", + ); +} finally { + fs.rmSync(temporaryHome, { recursive: true, force: true }); +} diff --git a/demohouse/sales-intelligence-workbench/scripts/validate-public-release.mjs b/demohouse/sales-intelligence-workbench/scripts/validate-public-release.mjs new file mode 100644 index 00000000..1f914cf3 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/scripts/validate-public-release.mjs @@ -0,0 +1,292 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const ignoredDirectories = new Set([".git", "coverage", "dist", "node_modules"]); +const internalOnlyPaths = new Set([ + "docs/agents/skills/sales-assistant-builder.md", + "docs/production-readiness-roadmap.md", + "docs/release-checklist.md", + "目录说明.md", + "backend/src/config/runtimeMode.js", + "backend/src/fixtures/demoData.js", + "backend/src/fixtures/salesData.js", + "backend/src/providers/mockProviders.js", + "backend/src/repositories/memoryRepository.js", + "backend/src/services/demoService.js", +]); +const requiredPaths = [ + ".github/workflows/ci.yml", + "CHANGELOG.md", + "CONTRIBUTING.md", + "LICENSE", + "README.md", + "SECURITY.md", + "THIRD_PARTY_NOTICES.md", + "backend/.env.example", + "docs/README.md", + "docs/api/api-contract.md", + "docs/database/supabase-schema.md", + "docs/deployment/self-hosting.md", + "package-lock.json", + "package.json", + "skills/sales-intelligence-workbench/SKILL.md", +]; +const forbiddenFilePatterns = [ + /^\.DS_Store$/i, + /^\.env$/i, + /^\.env\.(?!example$|sample$)/i, + /\.(?:db|log|mov|mp4|mkv|p12|pem|pfx|pid|sqlite|sqlite3)$/i, +]; +const publicContentRules = [ + { id: "macos_user_path", pattern: /\/Users\/[^/\s"'`]+/ }, + { id: "macos_temporary_path", pattern: /\/var\/folders\// }, + { id: "clipboard_artifact", pattern: /codex-clipboard/i }, + { + id: "unexpected_sales_repository", + pattern: /github\.com\/(?!3494036618-eng\/sales-intelligence-workbench(?:[\/\s`]|$)|volcengine\/ai-app-lab(?:[\/\s`]|$))[^/\s]+\/sales-intelligence-workbench/i, + }, + { + id: "environment_specific_release_note", + pattern: /当前开发机|当前账号缺少|个人(?: GitHub|公开)?仓库|公司官方仓库|本轮模型合计|代码归属与许可证批准人/, + }, +]; +const contentScanExclusions = new Set([ + "scripts/validate-public-release.mjs", + "scripts/validate-skill-package.mjs", +]); + +function normalize(relativePath) { + return relativePath.split(path.sep).join("/"); +} + +function walkFiles(current, output = []) { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + if (entry.isSymbolicLink()) { + output.push(path.join(current, entry.name)); + continue; + } + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) walkFiles(path.join(current, entry.name), output); + continue; + } + if (entry.isFile()) output.push(path.join(current, entry.name)); + } + return output; +} + +function releaseFiles() { + const tracked = spawnSync("git", ["-C", root, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + if (tracked.status === 0) { + return tracked.stdout + .split("\0") + .filter(Boolean) + .map((relativePath) => path.join(root, relativePath)) + .filter((filePath) => fs.existsSync(filePath)); + } + return walkFiles(root); +} + +function isText(bytes) { + return !bytes.subarray(0, 4096).includes(0); +} + +function markdownLinkIssues(relativePath, text) { + const issues = []; + const linkPattern = /!?\[[^\]]*]\(([^)]+)\)/g; + for (const match of text.matchAll(linkPattern)) { + const rawTarget = match[1].trim().replace(/^<|>$/g, "").split(/\s+["']/)[0]; + if (!rawTarget || /^(?:#|https?:\/\/|mailto:)/i.test(rawTarget)) continue; + const targetWithoutAnchor = rawTarget.split("#")[0]; + if (!targetWithoutAnchor) continue; + let decodedTarget = targetWithoutAnchor; + try { + decodedTarget = decodeURIComponent(targetWithoutAnchor); + } catch { + issues.push(`${relativePath}: invalid URL encoding in Markdown link ${rawTarget}`); + continue; + } + const resolved = path.resolve(root, path.dirname(relativePath), decodedTarget); + if (!resolved.startsWith(`${root}${path.sep}`) && resolved !== root) { + issues.push(`${relativePath}: Markdown link escapes repository ${rawTarget}`); + } else if (!fs.existsSync(resolved)) { + issues.push(`${relativePath}: broken Markdown link ${rawTarget}`); + } + } + return issues; +} + +const issues = []; +const files = releaseFiles(); +const relativeFiles = new Set(files.map((filePath) => normalize(path.relative(root, filePath)))); + +for (const requiredPath of requiredPaths) { + if (!relativeFiles.has(requiredPath)) issues.push(`missing required release file: ${requiredPath}`); +} +for (const internalPath of internalOnlyPaths) { + if (relativeFiles.has(internalPath)) issues.push(`internal-only file is tracked: ${internalPath}`); +} + +for (const filePath of files) { + const relativePath = normalize(path.relative(root, filePath)); + const stat = fs.lstatSync(filePath); + if (stat.isSymbolicLink()) { + issues.push(`symbolic link is not allowed in the release tree: ${relativePath}`); + continue; + } + if (forbiddenFilePatterns.some((pattern) => pattern.test(path.basename(filePath)))) { + issues.push(`private or generated file is tracked: ${relativePath}`); + continue; + } + if (stat.size > 5 * 1024 * 1024) { + issues.push(`unexpected file larger than 5 MiB: ${relativePath}`); + continue; + } + const bytes = fs.readFileSync(filePath); + if (!isText(bytes)) continue; + const text = bytes.toString("utf8"); + if (!contentScanExclusions.has(relativePath)) { + for (const rule of publicContentRules) { + if (rule.pattern.test(text)) issues.push(`${relativePath}: ${rule.id}`); + } + } + if (relativePath.endsWith(".md")) issues.push(...markdownLinkIssues(relativePath, text)); +} + +const readme = fs.readFileSync(path.join(root, "README.md"), "utf8"); +const skill = fs.readFileSync( + path.join(root, "skills", "sales-intelligence-workbench", "SKILL.md"), + "utf8", +); +const workflow = fs.readFileSync( + path.join(root, "skills", "sales-intelligence-workbench", "references", "cookbook-workflow.md"), + "utf8", +); +const ci = fs.readFileSync(path.join(root, ".github", "workflows", "ci.yml"), "utf8"); +const license = fs.readFileSync(path.join(root, "LICENSE"), "utf8"); +const packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); +const backendPackageJson = JSON.parse(fs.readFileSync(path.join(root, "backend", "package.json"), "utf8")); +const packageLock = JSON.parse(fs.readFileSync(path.join(root, "package-lock.json"), "utf8")); +const routes = fs.readFileSync(path.join(root, "backend", "src", "routes", "index.js"), "utf8"); +const deployment = fs.readFileSync(path.join(root, "docs", "deployment", "self-hosting.md"), "utf8"); +const security = fs.readFileSync(path.join(root, "SECURITY.md"), "utf8"); +const apiContract = fs.readFileSync(path.join(root, "docs", "api", "api-contract.md"), "utf8"); +const architecture = fs.readFileSync( + path.join(root, "skills", "sales-intelligence-workbench", "references", "architecture.md"), + "utf8", +); +const providerConfiguration = fs.readFileSync( + path.join(root, "skills", "sales-intelligence-workbench", "references", "provider-configuration.md"), + "utf8", +); +const envExample = fs.readFileSync(path.join(root, "backend", ".env.example"), "utf8"); +const frontendApp = fs.readFileSync(path.join(root, "frontend", "app.js"), "utf8"); +const frontendStyles = fs.readFileSync(path.join(root, "frontend", "styles.css"), "utf8"); +const authService = fs.readFileSync(path.join(root, "backend", "src", "security", "authService.js"), "utf8"); +const salesService = fs.readFileSync(path.join(root, "backend", "src", "services", "salesService.js"), "utf8"); +const canonicalSkillUrl = `https://github.com/3494036618-eng/sales-intelligence-workbench/blob/v${packageJson.version}/skills/sales-intelligence-workbench/SKILL.md`; + +for (const [label, text] of [["README", readme], ["Skill", skill]]) { + if (!text.includes(canonicalSkillUrl)) issues.push(`${label} is missing the canonical release Skill URL`); +} +for (const officialName of [ + "专业数据集(DataPro)", + "豆包搜索(联网搜索)", + "Agent 记忆(OpenViking)", + "AI Native 应用开发底座(Supabase)", +]) { + for (const [label, text] of [["README", readme], ["Skill", skill], ["Cookbook", workflow]]) { + if (!text.includes(officialName)) issues.push(`${label} is missing official product name: ${officialName}`); + } +} +for (const [label, text, requiredPhrases] of [ + ["README", readme, [ + "### 初始化 Agent 记忆(OpenViking)", + "### 初始化 AI Native 应用开发底座(Supabase)", + "少量 Agent Plan 模型、专业数据集(DataPro)和豆包搜索(联网搜索)用量", + ]], + ["Skill", skill, [ + "## 5. 初始化 Agent 记忆(OpenViking)", + "AI Native 应用开发底座(Supabase)写入", + "Agent 记忆(OpenViking)新资源创建", + ]], + ["Cookbook", workflow, [ + "`专业数据集`、`豆包搜索`、`Agent 记忆`", + "`AI Native 应用开发底座`", + "Agent 记忆(OpenViking)live doctor", + ]], +]) { + for (const phrase of requiredPhrases) { + if (!text.includes(phrase)) issues.push(`${label} is missing official user-facing name: ${phrase}`); + } +} +for (const [label, text] of [["README", readme], ["Skill", skill], ["Cookbook", workflow]]) { + if (/^#{2,3} 初始化 (?:OpenViking|Supabase)(?: 记忆库)?$/m.test(text)) { + issues.push(`${label} exposes an internal-only capability heading`); + } + if (/少量模型、DataPro 和搜索用量|真实资料写入 Supabase 与 OpenViking/.test(text)) { + issues.push(`${label} uses internal capability names in user guidance`); + } +} +for (const [label, text] of [ + ["README", readme], + ["Skill", skill], + ["API contract", apiContract], + ["architecture", architecture], + ["provider configuration", providerConfiguration], + ["environment example", envExample], +]) { + if (/\bAPP_MODE\b|--mode\s+(?:production|development|demo)|\bSALES_(?:DEMO_STABLE_MODE|PROFESSIONAL_DEMO_FALLBACK|SKIP_REAL_DATAPRO)\b/.test(text)) { + issues.push(`${label} exposes obsolete runtime modes`); + } +} +if (/DEMO_MODE|SALES_WORKBENCH_MODE|safe-demo|applySafeRecordingData/.test(frontendApp)) { + issues.push("frontend exposes a selectable or fixture-backed runtime mode"); +} +if (/runtime-status\.demo|demo-mode|mode-demo/.test(frontendStyles)) { + issues.push("frontend styles retain a public demo-mode state"); +} +if (!/name="username" autocomplete="username"/.test(frontendApp) || /name="email"|type="email"/.test(frontendApp)) { + issues.push("frontend authentication is not username-only"); +} +if (/password\/recover|password\/update|找回密码|重置邮件|工作区成员|成员管理|成员邀请/.test(frontendApp)) { + issues.push("frontend exposes an email-recovery or member-management flow"); +} +if (!/internalOwnerEmail/.test(authService) || !/email_confirm:\s*true/.test(authService)) { + issues.push("single-administrator bootstrap is not server-confirmed"); +} +if (/reset-password|忘记密码|找回密码|重置密码/.test(readme + skill + apiContract + providerConfiguration)) { + issues.push("public guidance exposes a password recovery flow"); +} +if (/AUTH_REDIRECT_URL|自有 SMTP|密码恢复依赖/.test(readme + skill + deployment + providerConfiguration)) { + issues.push("public guidance still requires email password recovery"); +} +if (/fixtures\/salesData|salesSeedData|demoProfessionalSources|demoPublicSources|allow_fixture_data|allow_provider_fallback/.test(salesService)) { + issues.push("SalesService contains runtime fixture or provider-fallback logic"); +} +if (!/node-version:\s*20\b/.test(ci)) issues.push("GitHub Actions must test with Node.js 20"); +if (!/npm run verify/.test(ci)) issues.push("GitHub Actions must run npm run verify"); +if (!/Apache License\s+Version 2\.0/.test(license)) issues.push("LICENSE is not Apache License 2.0"); +if (packageJson.private !== true) issues.push("package.json must remain private because this repository is not an npm package"); +if (packageJson.engines?.node !== ">=20") issues.push("package.json must require Node.js >=20"); +if (backendPackageJson.version !== packageJson.version) issues.push("backend/package.json version is inconsistent"); +if (packageLock.version !== packageJson.version || packageLock.packages?.[""]?.version !== packageJson.version) { + issues.push("package-lock.json version is inconsistent"); +} +for (const [label, text, pattern] of [ + ["README", readme, new RegExp(`当前为 \\\`${packageJson.version.replaceAll(".", "\\.")}\\\``)], + ["SECURITY", security, new RegExp(`v${packageJson.version.replaceAll(".", "\\.")}`)], + ["deployment guide", deployment, new RegExp(`\\\`${packageJson.version.replaceAll(".", "\\.")}\\\``)], + ["health route", routes, new RegExp(`version: "${packageJson.version.replaceAll(".", "\\.")}"`)], +]) { + if (!pattern.test(text)) issues.push(`${label} version is inconsistent`); +} + +assert.deepEqual(issues, [], `公开发布检查失败:\n- ${issues.join("\n- ")}`); +process.stdout.write(`公开发布检查通过:${files.length} 个文件,未发现内部材料、环境特定信息、私密文件或失效的相对文档链接。\n`); diff --git a/demohouse/sales-intelligence-workbench/scripts/validate-skill-package.mjs b/demohouse/sales-intelligence-workbench/scripts/validate-skill-package.mjs new file mode 100644 index 00000000..b2ae48d7 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/scripts/validate-skill-package.mjs @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const skillRoot = path.join(root, "skills", "sales-intelligence-workbench"); + +function read(relativePath) { + const filePath = path.join(root, relativePath); + assert.ok(fs.existsSync(filePath), `缺少文件:${relativePath}`); + return fs.readFileSync(filePath, "utf8"); +} + +const requiredSkillFiles = [ + "SKILL.md", + "agents/openai.yaml", + "scripts/onboard.mjs", + "scripts/setup.mjs", + "scripts/install.mjs", + "scripts/configure.mjs", + "scripts/setup-openviking.mjs", + "scripts/setup-supabase.mjs", + "scripts/doctor.mjs", + "scripts/start.mjs", + "scripts/login.mjs", + "scripts/import-feishu.mjs", + "scripts/verify-business-chain.mjs", + "references/cookbook-workflow.md", + "assets/app/backend/package.json", + "assets/app/frontend/index.html", +]; + +for (const relativePath of requiredSkillFiles) { + assert.ok(fs.existsSync(path.join(skillRoot, relativePath)), `Skill 缺少文件:${relativePath}`); +} + +for (const relativePath of [ + "scripts/install-agent-skill.mjs", + "scripts/install-codex-skill.mjs", + "scripts/install-claude-code-skill.mjs", +]) { + assert.ok(fs.existsSync(path.join(root, relativePath)), `缺少客户端安装器:${relativePath}`); +} + +const skill = read("skills/sales-intelligence-workbench/SKILL.md"); +const agent = read("skills/sales-intelligence-workbench/agents/openai.yaml"); +const workflow = read("skills/sales-intelligence-workbench/references/cookbook-workflow.md"); +const readme = read("README.md"); +const packageJson = JSON.parse(read("package.json")); +const canonicalRepository = "https://github.com/3494036618-eng/sales-intelligence-workbench"; +const canonicalSkillUrl = `${canonicalRepository}/blob/v${packageJson.version}/skills/sales-intelligence-workbench/SKILL.md`; + +assert.match(skill, /^---\nname: sales-intelligence-workbench\n/m); +assert.match(agent, /\$sales-intelligence-workbench/); +assert.match(agent, /allow_implicit_invocation:\s*true/); +assert.match(skill, /onboard\.mjs/); +assert.match(skill, /setup-openviking\.mjs/); +assert.match(skill, /用户侧只输入\s*一枚 Agent Plan Key/); +assert.match(skill, /## 远程 Skill 入口/); +const publicSkillCommand = skill.match( + /帮我初始化销售助手:(https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/blob\/[A-Za-z0-9._-]+\/(?:[A-Za-z0-9_.-]+\/)*skills\/sales-intelligence-workbench\/SKILL\.md)/, +); +assert.ok(publicSkillCommand, "主 Skill 必须包含不带占位符的 GitHub 初始化 URL"); +assert.doesNotMatch(publicSkillCommand[1], /[<>]/); +assert.equal(publicSkillCommand[1], canonicalSkillUrl); +assert.match(skill, /node scripts\/validate-skill-package\.mjs/); +assert.match(skill, /node scripts\/test-skill-installer\.mjs/); +assert.match(skill, /Codex/); +assert.match(skill, /Claude Code/); +assert.match(skill, /skill:install:codex/); +assert.match(skill, /skill:install:claude/); +assert.doesNotMatch(skill, /页面的“成员”入口/); +assert.doesNotMatch(skill, /AUTH_REDIRECT_URL|自有 SMTP|密码恢复依赖/); +assert.doesNotMatch(skill, /要求用户.*OpenViking.*(?:API )?Key/); +const configure = read("skills/sales-intelligence-workbench/scripts/configure.mjs"); +assert.doesNotMatch(configure, /OpenViking 数据面 API Key/); +assert.doesNotMatch(configure, /hiddenQuestion\(rl, output, "Supabase Service Role Key"/); +assert.doesNotMatch(configure, /hiddenQuestion\(rl, output, "火山 (?:Access|Secret) Key/); +assert.doesNotMatch(configure, /visibleQuestion\(rl, "Supabase Data API URL"/); +assert.doesNotMatch(configure, /AUTH_REDIRECT_URL/); +const login = read("skills/sales-intelligence-workbench/scripts/login.mjs"); +assert.match(login, /--username/); +assert.match(login, /body: JSON\.stringify\(\{ username, password \}\)/); +assert.doesNotMatch(skill + readme, /reset-password|忘记密码|找回密码|重置密码/); +const install = read("skills/sales-intelligence-workbench/scripts/install.mjs"); +assert.match(install, /AUTH_REFRESH_COOKIE_MAX_AGE === "2592000"/); +assert.match(install, /AUTH_REFRESH_COOKIE_MAX_AGE: "31536000"/); +const stop = read("skills/sales-intelligence-workbench/scripts/stop.mjs"); +assert.match(stop, /Date\.now\(\) \+ 35_000/); +assert.doesNotMatch(stop, /SIGKILL/); +assert.match(read("skills/sales-intelligence-workbench/scripts/setup-supabase.mjs"), /自动获取 Data API 端点和后端内部凭据/); +assert.match(workflow, /专业数据集(DataPro)与豆包搜索(联网搜索)有界并发采集、逐查询检查点 → 档案 Agent 六章节事实规划、服务端确定性组装与质量门禁 → AI Native 应用开发底座(Supabase)/); +assert.match(workflow, /Agent 三次以内/); +assert.match(workflow, /可重试故障只继续未完成查询/); +assert.doesNotMatch(workflow, /DataPro → 豆包搜索 → OpenViking → 模型 → Supabase/); +for (const officialName of [ + "专业数据集(DataPro)", + "豆包搜索(联网搜索)", + "Agent 记忆(OpenViking)", + "AI Native 应用开发底座(Supabase)", +]) { + assert.match(skill, new RegExp(officialName), `主 Skill 缺少 Agent Plan 控制台名称:${officialName}`); + assert.match(readme, new RegExp(officialName), `README 缺少 Agent Plan 控制台名称:${officialName}`); + assert.match(workflow, new RegExp(officialName), `Cookbook 缺少 Agent Plan 控制台名称:${officialName}`); +} +assert.match(readme, /npm run skill:install/); +assert.match(readme, /npm run skill:install:codex/); +assert.match(readme, /npm run skill:install:claude/); +assert.match(readme, /\$sales-intelligence-workbench/); +assert.match(readme, /\/sales-intelligence-workbench/); +assert.match(readme, /npm run skill:command/); +assert.match(readme, /skills\/sales-intelligence-workbench\/SKILL\.md/); +assert.match( + readme, + /帮我初始化销售助手:`https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/blob\/[A-Za-z0-9._-]+\/(?:[A-Za-z0-9_.-]+\/)*skills\/sales-intelligence-workbench\/SKILL\.md`/, +); +for (const relativePath of [ + "README.md", + "CHANGELOG.md", + "THIRD_PARTY_NOTICES.md", + "docs/database/supabase-schema.md", + "skills/sales-intelligence-workbench/SKILL.md", +]) { + const publicDocument = read(relativePath); + assert.doesNotMatch(publicDocument, /\/Users\/[^/\s]+|当前开发机|当前账号缺少|个人(?: GitHub|公开)?仓库|公司官方仓库/); + const salesRepositoryUrls = publicDocument.match(/https:\/\/github\.com\/[^/\s`]+\/sales-intelligence-workbench[^\s`)"]*/g) || []; + assert.ok( + salesRepositoryUrls.every((url) => url.startsWith(canonicalRepository)), + `${relativePath} 包含非当前发行仓库的销售工作台地址`, + ); +} +for (const internalOnlyPath of [ + "目录说明.md", + "docs/production-readiness-roadmap.md", + "docs/release-checklist.md", + "docs/agents/skills/sales-assistant-builder.md", +]) { + assert.equal(fs.existsSync(path.join(root, internalOnlyPath)), false, `公开包不应包含内部或遗留文件:${internalOnlyPath}`); +} +assert.equal(packageJson.scripts?.["skill:install"], "node scripts/install-codex-skill.mjs"); +assert.equal(packageJson.scripts?.["skill:install:codex"], "node scripts/install-codex-skill.mjs"); +assert.equal(packageJson.scripts?.["skill:install:claude"], "node scripts/install-claude-code-skill.mjs"); +assert.equal(packageJson.scripts?.["skill:install:all"], "node scripts/install-agent-skill.mjs --target all"); +assert.equal(packageJson.scripts?.["skill:command"], "node scripts/print-public-skill-command.mjs"); +assert.equal(packageJson.scripts?.["release:validate"], "node scripts/validate-public-release.mjs"); +assert.match(packageJson.scripts?.verify || "", /release:validate/); +assert.match(packageJson.scripts?.verify || "", /skill:validate/); +assert.match(packageJson.scripts?.verify || "", /skill:test/); +assert.match(packageJson.scripts?.verify || "", /backend run release:verify/); + +process.stdout.write("Skill 包结构、触发配置、安装入口和 Cookbook 链路检查通过。\n"); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md new file mode 100644 index 00000000..fdbe5b6f --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md @@ -0,0 +1,362 @@ +--- +name: sales-intelligence-workbench +description: 从 0 到 1 搭建、配置、验收和维护真实数据驱动的销售智能工作台,覆盖需求澄清、Agent Plan 模型、专业数据集(DataPro)、豆包搜索(联网搜索)、AI Native 应用开发底座(Supabase)、Agent 记忆(OpenViking)、Codex CLI 调度飞书 CLI 导入资料,以及企业搜索、档案和资料问答闭环。用户要求搭建销售工作台、部署或继续开发该项目、导入销售资料、排查 Provider、迁移数据库、备份恢复或验收真实业务链路时使用。 +--- + +# 销售智能工作台 Builder + +这是一个 Builder Skill:先理解用户的销售目标,再安装经过测试的完整前后端模板,连接用户自己的 Agent Plan、AI Native 应用开发底座(Supabase)、Agent 记忆(OpenViking)和资料来源,最后用真实业务闭环验收。不得用演示企业、固定报告、Mock Provider 或静态来源冒充真实链路。 + +## 远程 Skill 入口 + +用户可能在 Codex 或 Claude Code 中直接通过公开的主 Skill URL 触发本流程,而不是预先克隆 +仓库、安装 Skill 或准备本机配置。两端使用同一份 Skill 和同一套业务逻辑。独立发行仓库 +使用以下版本化入口;其他发行位置也必须固定到已发布的 tag 或经过审核的 commit SHA: + +```text +帮我初始化销售助手:https://github.com/3494036618-eng/sales-intelligence-workbench/blob/v0.10.0/skills/sales-intelligence-workbench/SKILL.md +``` + +如果当前环境中不存在 `{baseDir}/scripts/status.mjs`,说明本 Skill 是从远程 URL 打开的。 +此时 Agent 必须: + +1. 从用户提供的 Skill URL 解析同一个 GitHub 仓库和 ``,取得该版本的完整仓库,不能 + 只下载 `SKILL.md`,也不能通过搜索结果猜测同名仓库。 +2. 将 `{baseDir}` 设为仓库中的 `skills/sales-intelligence-workbench`,确认 + `{baseDir}/scripts/`、`{baseDir}/references/`、`{baseDir}/assets/app/` 及仓库根目录 + `package.json` 均存在。 +3. 在仓库根目录执行 `node scripts/validate-skill-package.mjs` 和 + `node scripts/test-skill-installer.mjs`。两项都通过后按当前客户端安装: + - Codex:`npm run skill:install:codex` + - Claude Code:`npm run skill:install:claude` + - 用户明确要求两端都安装:`npm run skill:install:all` + 已安装旧版时先说明影响,再为对应命令追加 `-- --force`。 +4. 立即使用刚取得仓库中的本文件继续阶段 0,不要求用户重启当前客户端,也不让用户重复 + 提供源码目录。后续重新打开时,Codex 使用 `$sales-intelligence-workbench`,Claude Code + 使用 `/sales-intelligence-workbench`。 +5. 已有同名目录时先核对 Git remote、版本和工作区状态;不覆盖用户改动,不创建第二套 + 运行时。下载、校验和安装阶段不创建云资源、不调用 Agent Plan 外部能力、不产生 AFP。 + +“什么也没配置”表示用户不需要预先准备本地项目、依赖或配置文件,不代表可以绕过云服务 +账号、Agent Plan 套餐、AI Native 应用开发底座(Supabase)/Agent 记忆(OpenViking)权限、飞书登录或真实调用费用。用户侧只输入 +一枚 Agent Plan Key;Agent 记忆(OpenViking)的内部访问凭证由初始化脚本自动获取和私密保存, +不得要求用户查找、粘贴或管理第二个 Key。 + +## Agent Plan 控制台名称约定 + +面向用户的引导必须优先使用 Agent Plan 控制台能力卡片中的名称,并在首次出现时补充 +内部技术名或作用说明: + +- `专业数据集(DataPro)` +- `豆包搜索(联网搜索)` +- `Agent 记忆(OpenViking)` +- `AI Native 应用开发底座(Supabase)` + +引导用户在对应卡片确认“开启抵扣”,首次使用时按“配置使用”完成授权。不要只写 +`DataPro`、`OpenViking`、`Supabase`、“联网搜索”“记忆库”或“业务数据库”而省略控制台 +名称,否则用户无法判断应开启哪张能力卡片。Agent Plan 模型单独说明,不把它误写成上述 +能力卡片。 + +## 执行原则 + +- 每完成一步,说明刚做了什么、为什么做、当前阶段、下一步和是否产生外部调用或费用。 +- 密钥只通过隐藏终端输入、现有私密环境文件或部署平台 Secret 配置;不要要求用户把密钥发到聊天。 +- 先做配置检查,再经用户知情执行 `--live`;真实 doctor 会产生少量 Agent Plan 模型、专业数据集(DataPro)和豆包搜索(联网搜索)用量。 +- 工作台必须 fail closed。配置缺失时不启动;单个上游临时故障时允许工作台启动,但依赖该 Provider 的业务操作必须失败并报告原因,不生成假结果。 +- 数据库迁移、恢复、删除和真实业务写入前明确影响;恢复只对独立目标执行。 +- 读取 `references/evidence-policy.md` 后再修改事实、引用、档案或问答链路。 + +## 0. 先确认用户要搭建什么 + +先询问并复述以下信息,不要求用户先懂技术配置: + +1. 工作台名称和最重要的销售目标。 +2. 目标行业、区域或客户范围。 +3. 历史资料来源:飞书云文档、飞书群聊/单聊,或本次暂不导入。 +4. 运行方式:本机或受控内网。 +5. 是否已经购买并配置 Agent Plan。 + +确认方案后记录不含密钥的业务范围: + +```bash +node {baseDir}/scripts/setup.mjs --init \ + --workspace-name "<工作台名称>" \ + --sales-goal "<销售目标>" \ + --target-scope "<行业、区域或客户范围>" \ + --sources feishu_docs,feishu_chats \ + --deployment local +``` + +该命令不访问外部服务、不创建云资源、不产生 AFP。完整步骤和验收标准见 `references/cookbook-workflow.md`。 + +## 1. 判断当前阶段 + +确认业务范围后,优先运行安全编排器: + +```bash +node {baseDir}/scripts/onboard.mjs +``` + +它会读取 `setup.mjs` 的阶段状态,自动执行本地安装、交互配置和启动等可恢复步骤;遇到 AI Native 应用开发底座(Supabase)写入、Agent 记忆(OpenViking)新资源创建、真实 Provider 调用、用户登录、飞书导入或付费业务验收时必须暂停并说明影响。只有用户明确确认后,才能追加相应的 `--apply-*`、`--yes` 或 `--confirm-live`。不得替用户自动创建、暂停或删除云资源。 + +需要只读查看阶段和唯一下一步时运行: + +```bash +node {baseDir}/scripts/setup.mjs +``` + +Builder 按“业务范围 → 应用 → Agent Plan 模型与能力卡片 → AI Native 应用开发底座(Supabase)→ Agent 记忆(OpenViking)→ 飞书资料 → 真实诊断 → API/Worker → 首批导入 → 业务验收”推进。档案由受约束的单 Agent 使用强制严格函数提交完整六章节规划,服务端确定性组装正文并独立执行证据与展示质量门禁;必要时最多定点修订两次,失败时不保存本地拼接报告。所有阶段通过前,不要宣称工作台已经可直接使用。 + +需要查看进程、地址和 Provider 配置细节时再运行: + +```bash +node {baseDir}/scripts/status.mjs +``` + +## 2. 安装应用 + +默认安装 Skill 自带的真实应用包: + +```bash +node {baseDir}/scripts/install.mjs +``` + +继续开发当前仓库时,从用户确认的源码目录安装: + +```bash +node {baseDir}/scripts/install.mjs --source /绝对路径/销售智能工作台开源版 +``` + +安装先检查前端语法并执行后端全套测试,再原子替换运行时。路径和安装边界见 `references/setup.md`。 + +## 3. 配置真实资源 + +在交互式终端隐藏输入: + +```bash +node {baseDir}/scripts/configure.mjs +``` + +已有私密 `.env.local` 时可迁移,不修改源文件,也不显示值: + +```bash +node {baseDir}/scripts/configure.mjs --from-env-file /绝对路径/.env.local +``` + +工作台不提供运行方式选择,始终连接真实 Provider 和 AI Native 应用开发底座(Supabase)。Provider 与 Key 的对应关系见 `references/provider-configuration.md`。 + +## 4. 初始化数据库 + +目标必须是北京地域的 AI Native 应用开发底座(Supabase)Agent Plan Workspace,不能使用普通按量 Workspace。优先使用显式 profile: + +```bash +byted-supabase-cli login --profile agent-plan --region cn-beijing --is-agent-plan +``` + +这里完成的是火山账号 OAuth 授权,不是要求用户输入另一枚 Key。需要新建时,先确认费用与休眠策略,再由具备 `aidap:CreateWorkspace` 权限的账号执行 `projects create --profile agent-plan --is-agent-plan`。 + +已有 AI Native 应用开发底座(Supabase)Workspace 时,先查看不会写入的初始化计划: + +```bash +node {baseDir}/scripts/setup-supabase.mjs +``` + +只有一个 Agent Plan Workspace 时脚本会自动选择;存在多个时按计划输出的 ID 明确选择。确认目标后执行: + +```bash +node {baseDir}/scripts/setup-supabase.mjs \ + --apply \ + --workspace-id \ + --profile agent-plan \ + --yes +``` + +该命令先只读核验 Workspace 的 Agent Plan 属性与 Running 状态,再自动读取 Data API 地址和后端内部凭据、保存到本机私密配置、应用迁移、创建应用 Workspace 记录并回读验证。用户无需输入 Supabase Key、Data API 地址或火山 AK/SK;命令不会创建、暂停或删除 AI Native 应用开发底座(Supabase)Workspace。 + +已有完整 Data API 配置、只需检查迁移时运行: + +```bash +node {baseDir}/scripts/migrate.mjs +``` + +用户确认将修改目标 AI Native 应用开发底座(Supabase)后再应用: + +```bash +node {baseDir}/scripts/migrate.mjs --apply +``` + +不要对来源不明的现有生产库直接迁移。 + +## 5. 初始化 Agent 记忆(OpenViking) + +先用 Agent Plan Key 只读列出当前账号的记忆库: + +```bash +node {baseDir}/scripts/setup-openviking.mjs +``` + +复用已有记忆库时,按脚本返回的 ResourceID 执行: + +```bash +node {baseDir}/scripts/setup-openviking.mjs --apply --resource-id +``` + +没有可复用资源时,先让用户确认英文名称、持续计费和单账号最多 20 个的限制,再创建: + +```bash +node {baseDir}/scripts/setup-openviking.mjs \ + --apply \ + --collection-name <英文名称> \ + --yes +``` + +脚本通过 Agent 记忆(OpenViking)官方控制面等待资源进入 `READY`,再自动获取该记忆库的内部访问凭证并 +以 `0600` 写入本机私密配置。内部凭证不得显示到终端、聊天、前端或文档,也不得要求用户 +输入。已有 Agent 记忆(OpenViking)官方 CLI 配置或已完成内部配置时直接复用,不重复创建资源。 + +## 6. 诊断并启动 + +配置检查不访问外部服务: + +```bash +node {baseDir}/scripts/doctor.mjs +``` + +向用户说明会产生少量调用后,执行真实只读诊断: + +```bash +node {baseDir}/scripts/doctor.mjs --live +``` + +排障时可用 `--only-provider model|datapro|web_search|openviking|supabase` 单独复测;单项结果不能代替全量启动验收。 + +首次正式使用前建议完成全量真实诊断;诊断失败会保留 Provider 级故障证据,但不会阻止其他独立能力启动。随后启动: + +```bash +node {baseDir}/scripts/start.mjs +node {baseDir}/scripts/status.mjs +``` + +首次打开页面时设置唯一的本机管理员用户名和密码,无需邮箱、邮件确认或公开注册。设置完成后直接进入工作台;后续使用同一浏览器打开时自动恢复本机会话,只有主动退出或会话失效时才使用原用户名和密码再次登录。 + +当前版本支持单工作区、单管理员,以及本机或受控内网部署,没有成员或角色系统,也不提供公网托管 SaaS 或 SLA。密码不得放入命令行参数、日志、聊天或仓库。 + +后端和前端由同一进程、同一地址提供;不要另开静态前端。停止不会删除配置和数据: + +```bash +node {baseDir}/scripts/stop.mjs +``` + +`start.mjs` 同时管理同源 API 和独立任务 Worker;`status.mjs` 中 `running` 与 `worker_running` 都应为 `true`。档案和 Agent 记忆(OpenViking)批量同步由 Worker 执行,不能只启动 API。运行中取消只登记请求,Worker 到达安全检查点后才释放付费预约并允许重试。 + +## 7. 导入飞书资料 + +本项目规定使用 **Codex CLI 调度飞书 CLI**,不以 Feishu MCP 或群机器人替代用户态读取。先阅读 `references/feishu-import.md`。 + +先为导入命令建立本机管理员会话(密码隐藏输入,令牌仅保存到本机 `0600` 状态文件): + +```bash +node {baseDir}/scripts/login.mjs +``` + +```bash +node {baseDir}/scripts/import-feishu.mjs \ + --company-id <企业ID> \ + --doc "https://example.feishu.cn/wiki/..." +``` + +会话导入可使用 `--p2p-user <联系人姓名>` 或 `--chat-id `;云文档只接受完整链接。启用 `FEISHU_CLI_IMPORT_ENABLED=true` 后,本机管理员也可在“历史资料”模块使用“导入飞书资料”。两种入口都会先由 `lark-cli` 读取:正文只写入当前企业的 Agent 记忆(OpenViking)目录,AI Native 应用开发底座(Supabase)只保存来源、游标、内容指纹和 OpenViking 引用。 +成功导入后,Builder 仅保存时间、企业 ID 和来源类型的脱敏回执,不复制飞书正文或凭证。 + +使用结束后可删除本机 CLI 会话: + +```bash +node {baseDir}/scripts/logout.mjs +``` + +## 8. 验收真实链路 + +`verify-real-chain.mjs` 只做 Agent Plan 模型、专业数据集(DataPro)、豆包搜索(联网搜索)、Agent 记忆(OpenViking)和 AI Native 应用开发底座(Supabase)的最小只读诊断,不写业务数据,不能替代产品验收: + +```bash +node {baseDir}/scripts/verify-real-chain.mjs +``` + +完整业务链路使用已授权测试企业,真实执行企业搜索、入池、异步档案和资料问答,并校验逐段引用、Provider Run 与 Token: + +```bash +node {baseDir}/scripts/login.mjs +node {baseDir}/scripts/verify-business-chain.mjs \ + --goal-id <销售目标ID> \ + --company-query <完整企业名称> \ + --question "根据当前档案,下一步应优先确认什么?" \ + --confirm-live +``` + +该命令会产生 AFP/Token,并保留 AI Native 应用开发底座(Supabase)中的企业/档案/任务记录及 Agent 记忆(OpenViking)中的问答 Session,不会自动删除。完整产品验收还必须补充:飞书增量导入、从 Agent 记忆(OpenViking)重启恢复正文与问答、再次生成后的版本比较、备份恢复和浏览器端操作。任一步使用固定前端数据都不通过。 +验收通过后,Builder 保存不含档案正文、问题答案和密钥的脱敏回执,供 `setup.mjs` 判断搭建是否完成。 + +应用队列迁移后,在不调用 Agent Plan 外部能力的情况下验证数据库原子语义: + +```bash +node {baseDir}/scripts/smoke-paid-workflow.mjs +node {baseDir}/scripts/smoke-async-job-queue.mjs +``` + +两项检查都必须显示 `transaction: rolled_back` 和 `provider_calls: 0`。 + +## 9. 备份、恢复与升级 + +```bash +node {baseDir}/scripts/backup.mjs +node {baseDir}/scripts/export-workspace.mjs +node {baseDir}/scripts/restore.mjs --backup-dir /绝对路径/备份目录 +node {baseDir}/scripts/upgrade.mjs --source /绝对路径/新源码 +``` + +`backup.mjs` 是运维级完整备份;`export-workspace.mjs` 仅允许本机管理员使用,输出可迁移 +的销售业务数据并排除密钥、Provider 原文和 OpenViking 内部 URI。两类文件都包含私密业务 +数据,默认以 `0600` 保存,禁止提交到仓库。 + +关键业务写操作、Provider 探测和工作区导出会写入脱敏审计事件;本机管理员可通过 +`/api/admin/audit-events` 查询。 + +恢复默认只预检;执行写入还需原恢复脚本要求的 `--apply`、独立目标和确认参数。升级前先停止服务并建议备份。 + +## 10. 卸载 + +保留配置、日志、备份和云端数据: + +```bash +node {baseDir}/scripts/uninstall.mjs +``` + +只有用户明确要求清除本机私有配置和备份时才执行: + +```bash +node {baseDir}/scripts/uninstall.mjs --purge --yes +``` + +两种方式都不删除 Supabase 或 OpenViking 云端数据。 + +## 维护 Skill 应用包 + +仓库源码通过测试后,由维护者同步到 Skill: + +```bash +node {baseDir}/scripts/sync-assets.mjs +node {baseDir}/scripts/self-test.mjs +``` + +同步脚本排除密钥、依赖、日志、备份和临时文件;自测使用隔离目录和假凭证,不访问外部服务。 + +## 参考资料 + +- `references/cookbook-workflow.md`:从需求澄清到真实业务验收的 Cookbook 映射。 +- `references/setup.md`:安装、目录、数据库初始化和首次启动。 +- `references/architecture.md`:前后端、Provider、Supabase 和 OpenViking 边界。 +- `references/provider-configuration.md`:配置项、凭证和生产门槛。 +- `references/feishu-import.md`:Codex CLI + 飞书 CLI 导入链路。 +- `references/evidence-policy.md`:事实、来源、档案和问答规则。 +- `references/security.md`:密钥、权限、备份和开源边界。 +- `references/troubleshooting.md`:常见故障和恢复步骤。 diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/agents/openai.yaml b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/agents/openai.yaml new file mode 100644 index 00000000..c4bb33b6 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "销售团队工作台 Builder" + short_description: "从零配置到真实数据闭环,初始化单工作区销售助手" + default_prompt: "使用 $sales-intelligence-workbench 从 0 初始化并验收我的真实销售助手;没有本地项目时先按远程入口取得完整仓库。" +policy: + allow_implicit_invocation: true diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/.env.example b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/.env.example new file mode 100644 index 00000000..8802c0ba --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/.env.example @@ -0,0 +1,116 @@ +# Copy to backend/.env.local only when running the backend without the Skill. +# Never commit real keys. + +# The public application has one fail-closed runtime and always uses Supabase. +REPOSITORY_MODE=supabase +HOST=127.0.0.1 +PORT=8787 + +# HTTP authentication and API boundary. +# Keep AUTH_COOKIE_SECURE=false only for loopback HTTP; use true behind HTTPS. +HTTP_AUTH_ENABLED=true +AUTH_BOOTSTRAP_ENABLED=true +AUTH_COOKIE_SECURE=false +AUTH_PROVIDER_TIMEOUT_MS=12000 +AUTH_SESSION_CACHE_TTL_MS=15000 +AUTH_REFRESH_COOKIE_MAX_AGE=31536000 +ALLOWED_ORIGINS=http://127.0.0.1:8787,http://localhost:8787 +TRUST_PROXY=false +API_MAX_BODY_BYTES=1048576 +API_RATE_LIMIT_PER_MIN=180 +API_WRITE_RATE_LIMIT_PER_MIN=60 +API_PAID_RATE_LIMIT_PER_MIN=12 +AUTH_RATE_LIMIT_PER_15_MIN=20 + +# Workspace-wide paid workflow guard. +# A workflow attempt may call one or more Agent Plan capabilities. +PAID_WORKFLOW_MAX_CONCURRENCY=2 +PAID_WORKFLOW_DAILY_LIMIT=100 +PAID_WORKFLOW_BUDGET_TIMEZONE=Asia/Shanghai +PAID_WORKFLOW_STALE_AFTER_SECONDS=1800 +ASYNC_JOBS_ENABLED=true +JOB_WORKER_POLL_MS=1000 +JOB_WORKER_LEASE_SECONDS=600 +PROVIDER_CIRCUIT_BREAKER_ENABLED=true +PROVIDER_CIRCUIT_BREAKER_FAILURE_THRESHOLD=5 +PROVIDER_CIRCUIT_BREAKER_COOLDOWN_SECONDS=60 + +# Optional company used only by live read-only DataPro/Web Search probes. +LIVE_PROBE_COMPANY=北京火山引擎科技有限公司 + +# Agent Plan API Key shared by model, DataPro and Doubao Search. +# Capability-specific keys below are optional advanced overrides. +AGENT_PLAN_API_KEY= + +# Web search provider +WEB_SEARCH_API_KEY= +WEB_SEARCH_BASE_URL=https://open.feedcoopapi.com/search_api/web_search +WEB_SEARCH_TRAFFIC_TAG=skill_web_search_common +WEB_SEARCH_MAX_COUNT=1 +WEB_SEARCH_RUN_ENABLED=true +WEB_SEARCH_TIMEOUT_MS=20000 +# Provider transport retries only cover transient timeout, rate-limit, network and upstream failures. +WEB_SEARCH_MAX_RETRIES=1 + +# DataPro provider +DATAPRO_API_KEY= +DATAPRO_MCP_URL=https://datapro.hqd.cn-beijing.volces.com/mcp +DATAPRO_RUN_ENABLED=true +DATAPRO_MAX_SOURCES=4 +DATAPRO_TIMEOUT_MS=45000 +DATAPRO_MAX_RETRIES=1 + +# Supabase repository / provider: business entities, dossier versions, jobs and sync metadata. +VOLCENGINE_ACCESS_KEY= +VOLCENGINE_SECRET_KEY= +VOLCENGINE_REGION=cn-beijing +SUPABASE_WORKSPACE_ID= +SUPABASE_BRANCH_ID= +SUPABASE_API_URL= +SUPABASE_SERVICE_ROLE_KEY= +SUPABASE_DATA_API_TIMEOUT_MS=15000 +APP_WORKSPACE_ID= +APP_WORKSPACE_SLUG=default +APP_WORKSPACE_NAME=Sales Workbench +APP_WORKSPACE_PLAN_MODE=agent_plan +SUPABASE_READ_ONLY=false +SUPABASE_RUN_ENABLED=true +SUPABASE_CLI_BIN=byted-supabase-cli +SUPABASE_CLI_PROFILE=current +SUPABASE_TIMEOUT_MS=30000 + +# OpenViking provider: imported Feishu content, QA sessions and long-term Agent memory. +# Do not ask the user to fill the internal fields below. The setup Skill uses the +# single Agent Plan Key to initialize a memory collection and writes them privately. +OPENVIKING_API_KEY= +OPENVIKING_BASE_URL=https://api.vikingdb.cn-beijing.volces.com/openviking +OPENVIKING_RESOURCE_ID= +OPENVIKING_COLLECTION_NAME= +OPENVIKING_CLI= +OPENVIKING_CLI_CONFIG= +OPENVIKING_AGENT_ID=default +OPENVIKING_RUN_ENABLED=true +OPENVIKING_SALES_ROOT_URI=viking://resources/sales-workbench +OPENVIKING_FIND_LIMIT=3 +OPENVIKING_TIMEOUT_MS=120000 +OPENVIKING_QA_AUTO_COMMIT_EVERY=4 +OPENVIKING_QA_KEEP_RECENT_MESSAGES=6 + +# Local Feishu CLI import. It is disabled until the operator explicitly enables it. +# The server invokes the authenticated local lark-cli process and never sends its credentials to the browser. +FEISHU_CLI_IMPORT_ENABLED= +FEISHU_CLI_IMPORT_TASK_LIMIT=100 + +# Model provider +MODEL_API_KEY= +MODEL_BASE_URL=https://ark.cn-beijing.volces.com/api/plan/v3 +MODEL_NAME=ark-code-latest +MODEL_RUN_ENABLED=true +MODEL_MAX_CARDS=2 +MODEL_MAX_TOKENS=700 +MODEL_TIMEOUT_MS=90000 +MODEL_MAX_RETRIES=1 +DOSSIER_AGENT_MAX_CALLS=3 +DOSSIER_CHECKPOINT_TTL_MS=1800000 +DOSSIER_DATAPRO_CONCURRENCY=2 +DOSSIER_WEB_CONCURRENCY=3 diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/package.json b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/package.json new file mode 100644 index 00000000..6991476b --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/package.json @@ -0,0 +1,37 @@ +{ + "name": "sales-intelligence-workbench-api", + "version": "0.10.0", + "private": true, + "type": "module", + "scripts": { + "dev": "node src/server.js", + "test": "node --test tests/*.test.mjs", + "release:secrets": "node scripts/check-release-secrets.mjs", + "release:verify": "node scripts/verify-release-local.mjs", + "doctor": "node scripts/doctor.mjs", + "doctor:live": "node scripts/baseline-real-readonly.mjs --live", + "db:migrate": "node scripts/migrate-supabase.mjs --apply", + "db:migrate:check": "node scripts/migrate-supabase.mjs", + "db:verify-qa-boundary": "node scripts/verify-openviking-qa-boundary.mjs", + "db:verify-security-boundary": "node scripts/verify-supabase-security-boundary.mjs", + "db:bootstrap-workspace": "node scripts/bootstrap-workspace.mjs", + "db:configure-data-api": "node scripts/configure-supabase-data-api.mjs", + "db:backup": "node scripts/backup-supabase.mjs", + "db:restore": "node scripts/restore-supabase.mjs", + "feishu:import": "node scripts/import-feishu-cli.mjs", + "baseline:real": "node scripts/baseline-real-readonly.mjs", + "baseline:real:live": "node scripts/baseline-real-readonly.mjs --live", + "verify:business": "node scripts/verify-business-chain.mjs", + "workspace:export": "node scripts/export-workspace.mjs", + "preflight:real": "node scripts/preflight-real.mjs", + "smoke:stage2-data-api": "node scripts/smoke-stage2-data-api.mjs", + "smoke:stage3-material-sync": "node scripts/smoke-stage3-material-sync.mjs", + "smoke:paid-workflow": "node scripts/smoke-paid-workflow-guard.mjs", + "smoke:async-job-queue": "node scripts/smoke-async-job-queue.mjs", + "smoke:stage2-api": "node scripts/smoke-stage2-api.mjs", + "smoke:stage2-backup-package": "node scripts/smoke-stage2-backup-package.mjs" + }, + "engines": { + "node": ">=20" + } +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/backup-supabase.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/backup-supabase.mjs new file mode 100644 index 00000000..998daabc --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/backup-supabase.mjs @@ -0,0 +1,174 @@ +import { randomUUID } from "node:crypto"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + BACKUP_FORMAT_VERSION, + WORKSPACE_TABLE_SPECS, + sha256File, + validateBackupPackage, +} from "../src/backup/supabaseBackup.js"; +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseDataProvider } from "../src/providers/supabaseDataProvider.js"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(scriptDir, "../.."); +const migrationsDir = resolve(repositoryRoot, "supabase/migrations"); +const env = createEnvReader(); +const provider = createSupabaseDataProvider({ env }); +const workspaceId = env.value("APP_WORKSPACE_ID"); +const cloudWorkspaceId = env.value("SUPABASE_WORKSPACE_ID"); +const branchId = env.value("SUPABASE_BRANCH_ID"); + +function option(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : ""; +} + +function timestamp() { + return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); +} + +function writePrivateJson(filePath, value) { + writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: "wx" }); + chmodSync(filePath, 0o600); +} + +async function readAll(table, options = {}) { + const rows = []; + const pageSize = 500; + let offset = 0; + while (true) { + const page = await provider.select(table, { + select: options.select || "*", + filters: options.filters || {}, + order: options.order, + limit: pageSize, + offset, + }); + if (!Array.isArray(page)) throw new Error(`Data API returned a non-array response for ${table}.`); + rows.push(...page); + if (page.length < pageSize) break; + offset += pageSize; + } + return rows; +} + +if (!provider.isConfigured() || !provider.isRunEnabled()) { + throw new Error("Configured and enabled Supabase Data API access is required for backup."); +} +if (!workspaceId || !cloudWorkspaceId || !branchId) { + throw new Error("APP_WORKSPACE_ID, SUPABASE_WORKSPACE_ID, and SUPABASE_BRANCH_ID are required for backup."); +} + +const backupId = `supabase-${timestamp()}-${randomUUID().slice(0, 8)}`; +const outputDir = resolve(option("--output-dir") || resolve(repositoryRoot, "backups/private/supabase", backupId)); +if (existsSync(outputDir)) throw new Error(`Backup directory already exists: ${outputDir}`); +mkdirSync(outputDir, { recursive: true, mode: 0o700 }); +chmodSync(outputDir, 0o700); + +const workspaceRows = await readAll("app_workspaces", { + filters: { id: `eq.${workspaceId}` }, + order: "id.asc", +}); +if (workspaceRows.length !== 1) throw new Error(`Expected exactly one application workspace, found ${workspaceRows.length}.`); + +const tables = { app_workspaces: workspaceRows }; +for (const spec of WORKSPACE_TABLE_SPECS) { + tables[spec.table] = await readAll(spec.table, { + filters: { workspace_id: `eq.${workspaceId}` }, + order: spec.order, + }); +} + +const memberUserIds = [...new Set((tables.app_workspace_members || []).map((row) => row.user_id).filter(Boolean))]; +tables.app_users = []; +for (const userId of memberUserIds) { + const users = await readAll("app_users", { filters: { id: `eq.${userId}` }, order: "id.asc" }); + tables.app_users.push(...users); +} + +const migrations = await readAll("schema_migrations", { order: "version.asc" }); +const appliedVersions = new Set(migrations.map((entry) => entry.version)); +const localMigrations = readdirSync(migrationsDir) + .filter((name) => /^\d+.*\.sql$/.test(name)) + .sort(); +for (const version of appliedVersions) { + if (!localMigrations.some((name) => name.startsWith(version))) { + throw new Error(`Applied migration ${version} is missing from the local repository.`); + } +} + +const backupMigrationsDir = resolve(outputDir, "migrations"); +mkdirSync(backupMigrationsDir, { mode: 0o700 }); +for (const migration of localMigrations.filter((name) => appliedVersions.has(name.slice(0, 12)))) { + const destination = resolve(backupMigrationsDir, migration); + copyFileSync(resolve(migrationsDir, migration), destination); + chmodSync(destination, 0o600); +} + +const exportedAt = new Date().toISOString(); +const data = { + format_version: BACKUP_FORMAT_VERSION, + backup_id: backupId, + exported_at: exportedAt, + source: { + cloud_workspace_id: cloudWorkspaceId, + branch_id: branchId, + app_workspace_id: workspaceId, + app_workspace_slug: env.value("APP_WORKSPACE_SLUG"), + }, + schema_migrations: migrations, + tables, +}; +const dataPath = resolve(outputDir, "data.json"); +writePrivateJson(dataPath, data); + +const files = [dataPath, ...readdirSync(backupMigrationsDir).sort().map((name) => resolve(backupMigrationsDir, name))] + .map((filePath) => ({ + path: relative(outputDir, filePath), + bytes: statSync(filePath).size, + sha256: sha256File(filePath), + })); +const rowCounts = Object.fromEntries(Object.entries(tables).map(([table, rows]) => [table, rows.length])); +const manifest = { + format_version: BACKUP_FORMAT_VERSION, + backup_id: backupId, + created_at: exportedAt, + source: data.source, + required_migrations: migrations.map((entry) => entry.version), + row_counts: rowCounts, + files, + notes: [ + "The package contains private application data and must not be committed.", + "Authentication users and provider secret values are not backed up by this package.", + ], +}; +const manifestPath = resolve(outputDir, "manifest.json"); +writePrivateJson(manifestPath, manifest); + +validateBackupPackage( + outputDir, + JSON.parse(readFileSync(manifestPath, "utf8")), + JSON.parse(readFileSync(dataPath, "utf8")), +); + +console.log(JSON.stringify({ + ok: true, + backup_id: backupId, + output_dir: outputDir, + cloud_workspace_id: cloudWorkspaceId, + app_workspace_id: workspaceId, + migration_count: migrations.length, + row_counts: rowCounts, + checksums_verified: true, +}, null, 2)); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/baseline-real-readonly.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/baseline-real-readonly.mjs new file mode 100644 index 00000000..20c125db --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/baseline-real-readonly.mjs @@ -0,0 +1,244 @@ +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createRuntimePolicy, publicRuntimePolicy } from "../src/config/runtimePolicy.js"; +import { createDataProProvider } from "../src/providers/dataProProvider.js"; +import { createModelProvider } from "../src/providers/modelProvider.js"; +import { createOpenVikingProvider } from "../src/providers/openVikingProvider.js"; +import { createSupabaseDataProvider } from "../src/providers/supabaseDataProvider.js"; +import { createWebSearchProvider } from "../src/providers/webSearchProvider.js"; + +const live = process.argv.includes("--live"); +const onlyProviderIndex = process.argv.indexOf("--only-provider"); +const onlyProvider = onlyProviderIndex >= 0 ? String(process.argv[onlyProviderIndex + 1] || "").trim() : ""; +const supportedProviders = new Set(["model", "datapro", "web_search", "openviking", "supabase"]); +if (onlyProvider && !supportedProviders.has(onlyProvider)) { + throw new Error(`Unsupported --only-provider value: ${onlyProvider}`); +} +const env = createEnvReader(); +const runtimePolicy = createRuntimePolicy({ env }); + +function truthy(value) { + return ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase()); +} + +function safeError(result) { + if (!result?.error) return null; + return { + code: String(result.error.code || "error").slice(0, 100), + message: String(result.error.message || "").slice(0, 300), + http_status: result.http_status || null, + }; +} + +function compactUsage(usage) { + if (!usage || typeof usage !== "object") return null; + return { + prompt_tokens: usage.prompt_tokens ?? null, + completion_tokens: usage.completion_tokens ?? null, + total_tokens: usage.total_tokens ?? null, + }; +} + +function providerState(provider) { + return { + configured: Boolean(provider.isConfigured()), + enabled: Boolean(provider.isRunEnabled()), + }; +} + +function resultCount(value) { + if (Array.isArray(value)) return value.length; + if (Array.isArray(value?.items)) return value.items.length; + if (Array.isArray(value?.result)) return value.result.length; + return value ? 1 : 0; +} + +async function checked(name, fn) { + const startedAt = Date.now(); + try { + const result = await fn(); + const elapsedMs = Date.now() - startedAt; + return { + name, + called: true, + ok: Boolean(result?.ok), + provider_mode: result?.provider_mode || (result?.ok ? "real" : null), + request_id: result?.request_id || null, + raw_ref: result?.raw_ref || null, + latency_ms: result?.latency_ms ?? elapsedMs, + elapsed_ms: elapsedMs, + attempts: Math.max(1, Number(result?.attempts || 1)), + usage: compactUsage(result?.usage), + error: safeError(result), + result, + }; + } catch (error) { + return { + name, + called: true, + ok: false, + provider_mode: null, + request_id: null, + raw_ref: null, + latency_ms: Date.now() - startedAt, + elapsed_ms: Date.now() - startedAt, + attempts: 1, + usage: null, + error: { + code: "exception", + message: String(error?.message || error).slice(0, 300), + http_status: null, + }, + result: null, + }; + } +} + +function publicResult(check) { + if (!check) return null; + return { + called: check.called, + ok: check.ok, + provider_mode: check.provider_mode, + request_id: check.request_id, + raw_ref: check.raw_ref, + latency_ms: check.latency_ms, + elapsed_ms: check.elapsed_ms, + attempts: check.attempts, + usage: check.usage, + error: check.error, + }; +} + +const providers = { + model: createModelProvider(), + datapro: createDataProProvider(), + web_search: createWebSearchProvider(), + openviking: createOpenVikingProvider(), + supabase: createSupabaseDataProvider(), +}; + +const providerStates = Object.fromEntries( + Object.entries(providers).map(([name, provider]) => [name, providerState(provider)]), +); + +const runtime = { + app: publicRuntimePolicy(runtimePolicy), + repository_mode: env.value("REPOSITORY_MODE", "supabase"), + supabase_read_only: truthy(env.value("SUPABASE_READ_ONLY", "false")), +}; + +const startedAt = new Date().toISOString(); +const checks = {}; +const selected = (name) => !onlyProvider || onlyProvider === name; +const liveProbeCompany = process.env.LIVE_PROBE_COMPANY || "北京火山引擎科技有限公司"; + +if (live) { + if (selected("model") && providerStates.model.enabled) { + checks.model = await checked("model", () => providers.model.callJson({ + operation: "sales_workbench_readonly_baseline", + maxTokens: 80, + system: "你是只读连通性探针。只输出 JSON,不调用工具,不补充事实。", + payload: { + task: "返回指定结构", + output_schema: { ok: true, message: "ready" }, + }, + })); + } + + if (selected("datapro") && providerStates.datapro.enabled) { + checks.datapro = await checked( + "datapro", + () => providers.datapro.callTool(`${liveProbeCompany} 企业工商信息`), + ); + } + + if (selected("web_search") && providerStates.web_search.enabled) { + checks.web_search = await checked( + "web_search", + () => providers.web_search.search({ + query: "火山引擎 Agent Plan 官方文档", + count: 1, + need_summary: false, + }), + ); + } + + if (selected("openviking") && providerStates.openviking.enabled) { + const health = await checked("openviking_health", () => providers.openviking.health()); + let find = null; + if (health.ok) { + find = await checked( + "openviking_find", + () => providers.openviking.findMemories("销售工作台", { limit: 1 }), + ); + } + checks.openviking = { + health: publicResult(health), + find: publicResult(find), + find_result_count: find?.ok ? resultCount(find.result?.result) : 0, + ok: Boolean(health.ok && find?.ok), + }; + } + + if (selected("supabase") && providerStates.supabase.enabled) { + checks.supabase = await checked("supabase", () => providers.supabase.probe()); + } +} + +const blockers = []; + +blockers.push(...runtimePolicy.blockers); + +if (runtime.repository_mode !== "supabase") { + blockers.push("REPOSITORY_MODE is not supabase."); +} + +for (const [name, state] of Object.entries(providerStates).filter(([name]) => selected(name))) { + if (!state.configured) blockers.push(name + " is not configured."); + if (!state.enabled) blockers.push(name + " is not enabled."); +} + +if (live) { + for (const name of ["model", "datapro", "web_search", "supabase"].filter(selected)) { + if (!checks[name]?.ok) blockers.push(name + " live check failed."); + } + if (selected("openviking") && !checks.openviking?.ok) blockers.push("openviking live check failed."); +} + +const report = { + schema_version: 1, + check_type: live ? onlyProvider ? "read_only_live_partial" : "read_only_live" : "configuration_only", + selected_provider: onlyProvider || null, + started_at: startedAt, + read_only_contract: { + business_data_writes: false, + openviking_writes: false, + supabase_writes: false, + model_request: live && selected("model"), + datapro_request: live && selected("datapro"), + web_search_request: live && selected("web_search"), + }, + runtime, + providers: providerStates, + checks: { + model: publicResult(checks.model), + datapro: publicResult(checks.datapro), + web_search: checks.web_search + ? { + ...publicResult(checks.web_search), + result_count: checks.web_search.result?.result_count ?? 0, + } + : null, + openviking: checks.openviking || null, + supabase: publicResult(checks.supabase), + }, + runtime_ready: !onlyProvider && blockers.length === 0, + blockers, + finished_at: new Date().toISOString(), +}; + +console.log(JSON.stringify(report, null, 2)); + +if (live && blockers.length) { + process.exitCode = 1; +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/bootstrap-workspace.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/bootstrap-workspace.mjs new file mode 100644 index 00000000..a60dd1d9 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/bootstrap-workspace.mjs @@ -0,0 +1,44 @@ +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const env = createEnvReader(); +const provider = createSupabaseProvider({ + env: { + ...env, + value(name, fallback = "") { + if (name === "SUPABASE_READ_ONLY") return "false"; + return env.value(name, fallback); + }, + }, +}); +const workspaceId = env.value("APP_WORKSPACE_ID").trim(); +const slug = env.value("APP_WORKSPACE_SLUG", "default").trim(); +const name = env.value("APP_WORKSPACE_NAME", "Sales Workbench").trim(); +const planMode = env.value("APP_WORKSPACE_PLAN_MODE", "standard").trim(); + +if (!UUID_PATTERN.test(workspaceId)) throw new Error("APP_WORKSPACE_ID must be a valid UUID."); +if (!/^[a-z0-9][a-z0-9-]{1,62}$/.test(slug)) throw new Error("APP_WORKSPACE_SLUG must contain 2-63 lowercase letters, numbers or hyphens."); +if (!name) throw new Error("APP_WORKSPACE_NAME is required."); +if (!new Set(["standard", "agent_plan"]).has(planMode)) throw new Error("APP_WORKSPACE_PLAN_MODE must be standard or agent_plan."); + +const quote = (value) => `'${String(value).replace(/'/g, "''")}'`; +const result = provider.executeSqlSync(` + insert into public.app_workspaces (id, slug, name, plan_mode, settings_json) + values (${quote(workspaceId)}::uuid, ${quote(slug)}, ${quote(name)}, ${quote(planMode)}, '{}'::jsonb) + on conflict (id) do update set + slug = excluded.slug, + name = excluded.name, + plan_mode = excluded.plan_mode, + updated_at = now() + returning id, slug, name, plan_mode, created_at, updated_at; +`); +if (!result.ok) throw new Error(result.error?.message || "Application workspace bootstrap failed."); + +const verify = provider.executeSqlSync(` + select id, slug, name, plan_mode + from public.app_workspaces + where id = ${quote(workspaceId)}::uuid; +`); +if (!verify.ok || verify.rows?.length !== 1) throw new Error(verify.error?.message || "Application workspace verification failed."); +console.log(JSON.stringify({ ok: true, workspace: verify.rows[0] }, null, 2)); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/check-release-secrets.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/check-release-secrets.mjs new file mode 100644 index 00000000..a1cdad54 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/check-release-secrets.mjs @@ -0,0 +1,136 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const defaultRoot = path.resolve(scriptDir, "../.."); + +const ignoredDirectories = new Set([ + ".git", + ".idea", + ".vscode", + "coverage", + "dist", + "node_modules", +]); + +const forbiddenSecretFiles = [ + /^\.env$/i, + /^\.env\.(?!example$|sample$)[^.]+$/i, + /^credentials(?:\.[^.]+)?$/i, + /^secrets?(?:\.[^.]+)?$/i, + /\.(?:key|pem|p12|pfx)$/i, +]; + +const contentRules = [ + { + id: "agent_plan_api_key", + pattern: /\bark-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}-[0-9a-f]{4,}\b/gi, + }, + { + id: "volcengine_access_key", + pattern: /\bAK(?:LT|TP)[A-Za-z0-9]{20,}\b/g, + }, + { + id: "jwt_or_supabase_key", + pattern: /\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b/g, + }, + { + id: "private_key", + pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g, + }, +]; + +const assignmentPattern = /^[ \t]*(AGENT_PLAN_API_KEY|ARK_API_KEY|VOLCENGINE_ACCESS_KEY_ID|VOLCENGINE_SECRET_ACCESS_KEY|SUPABASE_SERVICE_ROLE_KEY)[ \t]*=[ \t]*([^\r\n]*)[ \t]*$/gim; + +function normalizeAssignedValue(value) { + const withoutComment = String(value || "").replace(/\s+#.*$/, "").trim(); + return withoutComment.replace(/^(['"])(.*)\1$/, "$2").trim(); +} + +function isPlaceholder(value) { + const normalized = normalizeAssignedValue(value); + if (!normalized) return true; + if (/^(?:<.*>|\$\{.*\}|\*+|x+|your[-_ ]|replace[-_ ]|example|sample|test|mock)/i.test(normalized)) return true; + return normalized.length < 16; +} + +export function scanTextForSecrets(text, relativePath = "unknown") { + const findings = []; + + for (const rule of contentRules) { + rule.pattern.lastIndex = 0; + if (rule.pattern.test(text)) findings.push({ rule: rule.id, path: relativePath }); + } + + assignmentPattern.lastIndex = 0; + for (const match of text.matchAll(assignmentPattern)) { + if (!isPlaceholder(match[2])) { + findings.push({ rule: `configured_${match[1].toLowerCase()}`, path: relativePath }); + } + } + + return findings; +} + +function isForbiddenSecretFile(name) { + if (/\.example$|\.sample$/i.test(name)) return false; + return forbiddenSecretFiles.some((pattern) => pattern.test(name)); +} + +async function collectFiles(root, current = root, output = []) { + const entries = await fs.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) await collectFiles(root, path.join(current, entry.name), output); + continue; + } + if (entry.isFile()) output.push(path.join(current, entry.name)); + } + return output; +} + +export async function scanReleaseTree(root = defaultRoot) { + const absoluteRoot = path.resolve(root); + const files = await collectFiles(absoluteRoot); + const findings = []; + + for (const filePath of files) { + const relativePath = path.relative(absoluteRoot, filePath); + if (isForbiddenSecretFile(path.basename(filePath))) { + findings.push({ rule: "forbidden_secret_file", path: relativePath }); + continue; + } + + const stat = await fs.stat(filePath); + if (stat.size > 5 * 1024 * 1024) continue; + const bytes = await fs.readFile(filePath); + if (bytes.subarray(0, 4096).includes(0)) continue; + findings.push(...scanTextForSecrets(bytes.toString("utf8"), relativePath)); + } + + const unique = new Map(findings.map((finding) => [`${finding.rule}:${finding.path}`, finding])); + return [...unique.values()].sort((left, right) => left.path.localeCompare(right.path) || left.rule.localeCompare(right.rule)); +} + +async function main() { + const rootArgument = process.argv.find((argument) => argument.startsWith("--root=")); + const root = rootArgument ? rootArgument.slice("--root=".length) : defaultRoot; + const findings = await scanReleaseTree(root); + if (!findings.length) { + console.log("发布密钥扫描通过:未发现真实凭证或私钥文件。"); + return; + } + + console.error(`发布密钥扫描失败:发现 ${findings.length} 个风险位置。`); + for (const finding of findings) console.error(`- ${finding.rule}: ${finding.path}`); + process.exitCode = 1; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error?.message || String(error)); + process.exitCode = 1; + }); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/configure-supabase-data-api.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/configure-supabase-data-api.mjs new file mode 100644 index 00000000..89a91622 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/configure-supabase-data-api.mjs @@ -0,0 +1,52 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, readFileSync, writeFileSync } from "node:fs"; +import { createEnvReader, loadLocalEnv, localEnvUrl } from "../src/config/runtimeEnv.js"; + +function setEnvLine(content, name, value) { + const line = `${name}=${value}`; + const pattern = new RegExp(`^${name}=.*$`, "m"); + if (pattern.test(content)) return content.replace(pattern, line); + return `${content.trimEnd()}\n${line}\n`; +} + +const localEnv = loadLocalEnv(); +const env = createEnvReader(localEnv); +const workspaceId = env.value("SUPABASE_WORKSPACE_ID"); +const branchId = env.value("SUPABASE_BRANCH_ID"); +const apiUrl = env.value("SUPABASE_API_URL").replace(/\/$/, ""); +const command = env.value("SUPABASE_CLI_BIN", "byted-supabase-cli"); +if (!workspaceId || !branchId || !apiUrl) { + throw new Error("SUPABASE_WORKSPACE_ID, SUPABASE_BRANCH_ID and SUPABASE_API_URL are required."); +} + +const runtimeEnv = { ...process.env, ...localEnv }; +const result = spawnSync(command, [ + "projects", "api-keys", + "--workspace-id", workspaceId, + "--branch-id", branchId, + "-o", "json", +], { encoding: "utf8", env: runtimeEnv }); +if (result.status !== 0) throw new Error(result.stderr || "Unable to read Supabase API keys."); +const keys = JSON.parse(result.stdout || "[]"); +const serviceKey = keys.find((item) => item.name === "ServiceRoleKey")?.api_key; +if (!serviceKey) throw new Error("ServiceRoleKey was not returned for the configured Supabase branch."); + +const response = await fetch(`${apiUrl}/rest/v1/app_workspaces?select=id&limit=1`, { + headers: { apikey: serviceKey, Authorization: `Bearer ${serviceKey}` }, +}); +if (!response.ok) throw new Error(`Supabase Data API probe failed with HTTP ${response.status}.`); + +let content = readFileSync(localEnvUrl, "utf8"); +content = setEnvLine(content, "SUPABASE_SERVICE_ROLE_KEY", serviceKey); +content = setEnvLine(content, "SUPABASE_DATA_API_TIMEOUT_MS", "15000"); +writeFileSync(localEnvUrl, content, { encoding: "utf8", mode: 0o600 }); +chmodSync(localEnvUrl, 0o600); + +console.log(JSON.stringify({ + ok: true, + api_url: apiUrl, + key_name: "ServiceRoleKey", + key_type: "Service", + probe_status: response.status, + stored_in: "backend/.env.local", +}, null, 2)); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/doctor.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/doctor.mjs new file mode 100644 index 00000000..6085ff3e --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/doctor.mjs @@ -0,0 +1,30 @@ +import { getProviderStatus } from "../src/config/providerConfig.js"; +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createRuntimePolicy, publicRuntimePolicy } from "../src/config/runtimePolicy.js"; + +const env = createEnvReader(); +const runtimePolicy = createRuntimePolicy({ env }); +const providerStatus = getProviderStatus({ env, runtimePolicy }); +const requiredProviders = ["datapro", "web_search", "model", "openviking", "supabase"]; +const providers = Object.fromEntries(providerStatus.providers.map((provider) => [provider.id, { + status: provider.status, + run_enabled: provider.safe_config?.run_enabled ?? null, + missing: provider.missing, +} ])); +const providerBlockers = requiredProviders.filter((id) => providers[id]?.status !== "configured"); +const ok = runtimePolicy.ready && providerBlockers.length === 0; + +console.log(JSON.stringify({ + checked_at: new Date().toISOString(), + ok, + runtime: publicRuntimePolicy(runtimePolicy), + providers, + warnings: [], + blockers: [ + ...runtimePolicy.blockers, + ...providerBlockers.map((id) => `${id} is not configured`), + ], + live_check_command: "npm run doctor:live", +}, null, 2)); + +if (!ok) process.exitCode = 1; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/export-workspace.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/export-workspace.mjs new file mode 100644 index 00000000..d2bea44a --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/export-workspace.mjs @@ -0,0 +1,119 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { backendFetch, readAuthSession } from "./import-feishu-cli.mjs"; + +const DEFAULT_API_URL = "http://127.0.0.1:8787"; + +function usage() { + return ` +导出当前工作区的可迁移业务数据(仅 owner) + +用法: + node scripts/export-workspace.mjs [--api-url ] [--auth-session ] [--output ] + +输出包含企业、目标、公开档案、资料正文、同步游标和问答,属于私密业务数据。 +不会包含密钥、Provider 原文、OpenViking 内部 URI、Worker、租约或运行诊断。 +`; +} + +function optionValue(argv, index, name) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${name} 缺少参数值。`); + return value; +} + +function parseArgs(argv) { + const options = { + apiUrl: process.env.SALES_WORKBENCH_API_URL || "", + authSession: process.env.SALES_WORKBENCH_AUTH_SESSION + || path.join(os.homedir(), ".local", "state", "sales-intelligence-workbench", "cli-session.json"), + output: "", + help: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--help" || argument === "-h") options.help = true; + else if (argument === "--api-url") { + options.apiUrl = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--auth-session") { + options.authSession = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--output") { + options.output = optionValue(argv, index, argument); + index += 1; + } else { + throw new Error(`未知参数:${argument}`); + } + } + return options; +} + +function assertPrivateSession(filePath) { + const session = readAuthSession(filePath); + if (!session) throw new Error("未找到 CLI 登录态。请先运行 Skill 的 login.mjs。"); + const mode = fs.statSync(filePath).mode & 0o077; + if (mode !== 0) throw new Error("CLI 会话文件权限不安全;请将其权限改为 0600 后重试。"); + return session; +} + +function defaultOutput() { + const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); + return path.join( + os.homedir(), + ".local", + "state", + "sales-intelligence-workbench", + "exports", + `workspace-${stamp}.json`, + ); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + process.stdout.write(usage().trimStart()); + return; + } + const session = assertPrivateSession(options.authSession); + options.apiUrl = String(options.apiUrl || session.api_url || DEFAULT_API_URL).replace(/\/$/, ""); + if (!/^https?:\/\/[^/]+/i.test(options.apiUrl)) throw new Error("--api-url 不是有效的 HTTP(S) 地址。"); + + const response = await backendFetch(`${options.apiUrl}/api/admin/workspace-export`, { + method: "GET", + }, options); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const requestId = payload?.meta?.request_id ? `,请求ID ${payload.meta.request_id}` : ""; + throw new Error(`${payload?.error?.message || `导出失败(HTTP ${response.status})`}${requestId}`); + } + const exported = payload.data; + if (exported?.format !== "sales-intelligence-workbench-workspace-export") { + throw new Error("服务端没有返回有效的工作区业务数据包。"); + } + + const outputPath = path.resolve(options.output || defaultOutput()); + fs.mkdirSync(path.dirname(outputPath), { recursive: true, mode: 0o700 }); + fs.chmodSync(path.dirname(outputPath), 0o700); + fs.writeFileSync(outputPath, `${JSON.stringify(exported, null, 2)}\n`, { mode: 0o600, flag: "wx" }); + fs.chmodSync(outputPath, 0o600); + process.stdout.write(`${JSON.stringify({ + ok: true, + output: outputPath, + goal_count: exported.goals?.length || 0, + enterprise_count: exported.enterprises?.length || 0, + contains_private_business_data: true, + }, null, 2)}\n`); +} + +export { assertPrivateSession, parseArgs }; + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`${JSON.stringify({ ok: false, error: { message: error.message } }, null, 2)}\n`); + process.exitCode = 1; + }); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/import-feishu-cli.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/import-feishu-cli.mjs new file mode 100644 index 00000000..957587ff --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/import-feishu-cli.mjs @@ -0,0 +1,601 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const DEFAULT_API_URL = "http://127.0.0.1:8787"; +const DEFAULT_PAGE_SIZE = 20; + +function usage() { + return ` +Usage: + npm run feishu:import -- --company-id [sources] + +Sources: + --doc Import a Feishu/Lark doc as markdown. + --p2p-user Import direct messages with a person. + --chat-id Import messages from a chat. + --message-query Import message search results. + +Options: + --api-url Backend URL. Default: ${DEFAULT_API_URL} + --auth-session Local 0600 CLI session created by the Skill login command. + --start Explicit message start time. + --end Message end time. + --page-size Message page size, max 50. Default: ${DEFAULT_PAGE_SIZE} + --page-limit Page limit for chat pagination. Default: 1 + --title-prefix Prefix imported material titles. + --max-attempts Retry attempts for transient failures. Default: 3 + --retry-delay-ms Initial retry delay. Default: 800 + --no-incremental Ignore the saved backend checkpoint. + --resume-source Resume a paused source before importing. + --dry-run Fetch from Feishu but do not import to backend. + +Examples: + npm run feishu:import -- --company-id company_1 --p2p-user "联系人姓名" --start 2026-06-01 + npm run feishu:import -- --company-id company_1 --doc "https://example.feishu.cn/wiki/..." +`; +} + +function parseArgs(argv) { + const args = { + apiUrl: DEFAULT_API_URL, + companyId: "", + docs: [], + p2pUser: "", + chatId: "", + messageQuery: "", + start: "", + end: "", + pageSize: DEFAULT_PAGE_SIZE, + pageLimit: 1, + titlePrefix: "", + maxAttempts: 3, + retryDelayMs: 800, + incremental: true, + resumeSource: false, + dryRun: false, + authSession: process.env.SALES_WORKBENCH_AUTH_SESSION || "", + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const next = () => { + i += 1; + if (i >= argv.length) throw new Error(`Missing value for ${arg}`); + return argv[i]; + }; + + if (arg === "--help" || arg === "-h") args.help = true; + else if (arg === "--api-url") args.apiUrl = next(); + else if (arg === "--auth-session") args.authSession = next(); + else if (arg === "--company-id") args.companyId = next(); + else if (arg === "--doc") args.docs.push(next()); + else if (arg === "--p2p-user") args.p2pUser = next(); + else if (arg === "--chat-id") args.chatId = next(); + else if (arg === "--message-query") args.messageQuery = next(); + else if (arg === "--start") args.start = next(); + else if (arg === "--end") args.end = next(); + else if (arg === "--page-size") args.pageSize = Number(next()); + else if (arg === "--page-limit") args.pageLimit = Number(next()); + else if (arg === "--title-prefix") args.titlePrefix = next(); + else if (arg === "--max-attempts") args.maxAttempts = Number(next()); + else if (arg === "--retry-delay-ms") args.retryDelayMs = Number(next()); + else if (arg === "--no-incremental") args.incremental = false; + else if (arg === "--resume-source") args.resumeSource = true; + else if (arg === "--dry-run") args.dryRun = true; + else throw new Error(`Unknown argument: ${arg}`); + } + + if (args.help) return args; + if (!args.companyId) throw new Error("--company-id is required."); + if (!args.docs.length && !args.p2pUser && !args.chatId && !args.messageQuery) { + throw new Error("At least one source is required: --doc, --p2p-user, --chat-id, or --message-query."); + } + if (!Number.isFinite(args.pageSize) || args.pageSize < 1 || args.pageSize > 50) { + throw new Error("--page-size must be a number between 1 and 50."); + } + if (!Number.isFinite(args.pageLimit) || args.pageLimit < 1 || args.pageLimit > 40) { + throw new Error("--page-limit must be a number between 1 and 40."); + } + if (!Number.isFinite(args.maxAttempts) || args.maxAttempts < 1 || args.maxAttempts > 8) { + throw new Error("--max-attempts must be a number between 1 and 8."); + } + if (!Number.isFinite(args.retryDelayMs) || args.retryDelayMs < 0 || args.retryDelayMs > 30000) { + throw new Error("--retry-delay-ms must be a number between 0 and 30000."); + } + return args; +} + +function readAuthSession(filePath) { + if (!filePath) return null; + try { + const session = JSON.parse(fs.readFileSync(filePath, "utf8")); + return session?.access_token ? session : null; + } catch (error) { + if (error.code === "ENOENT") return null; + throw new Error(`Unable to read auth session: ${error.message}`); + } +} + +function writeAuthSession(filePath, session, apiUrl) { + if (!filePath) return; + fs.mkdirSync(path.dirname(path.resolve(filePath)), { recursive: true, mode: 0o700 }); + const issuedAt = Date.now(); + const current = readAuthSession(filePath) || {}; + const value = { + ...current, + api_url: apiUrl.replace(/\/$/, ""), + token_type: "bearer", + access_token: session.access_token, + refresh_token: session.refresh_token, + expires_in: Number(session.expires_in) || 3600, + issued_at: new Date(issuedAt).toISOString(), + expires_at: new Date(issuedAt + (Number(session.expires_in) || 3600) * 1000).toISOString(), + user: session.user || current.user || null, + }; + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + fs.chmodSync(filePath, 0o600); +} + +async function refreshAuthSession(options, current) { + if (!current?.refresh_token) return null; + const response = await fetch(`${options.apiUrl.replace(/\/$/, "")}/api/auth/cli-refresh`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: current.refresh_token }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) return null; + const session = payload.data || payload; + if (!session.access_token || !session.refresh_token) return null; + writeAuthSession(options.authSession, session, options.apiUrl); + return session; +} + +async function backendFetch(url, init, options, allowRefresh = true) { + const session = readAuthSession(options.authSession); + const headers = new Headers(init?.headers || {}); + if (session?.access_token) headers.set("Authorization", `Bearer ${session.access_token}`); + let response = await fetch(url, { ...init, headers }); + if (response.status !== 401 || !allowRefresh || !session?.refresh_token) return response; + const refreshed = await refreshAuthSession(options, session); + if (!refreshed?.access_token) return response; + const retryHeaders = new Headers(init?.headers || {}); + retryHeaders.set("Authorization", `Bearer ${refreshed.access_token}`); + response = await fetch(url, { ...init, headers: retryHeaders }); + return response; +} + +function retryable(error) { + const message = String(error?.message || error || ""); + return /timeout|timed out|network|fetch failed|temporar|connection reset|econn/i.test(message) + || /\b429\b|\b5\d\d\b/.test(message); +} + +async function withRetry(operation, options) { + let lastError; + let attempts = 0; + for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) { + attempts = attempt; + try { + return { value: await operation(), attempts: attempt }; + } catch (error) { + lastError = error; + if (attempt >= options.maxAttempts || !retryable(error)) break; + await delay(options.retryDelayMs * (2 ** (attempt - 1))); + } + } + const failure = lastError instanceof Error ? lastError : new Error(String(lastError)); + failure.attempts = attempts; + throw failure; +} + +async function runLark(args) { + const { stdout, stderr } = await execFileAsync("lark-cli", args, { + maxBuffer: 20 * 1024 * 1024, + }); + const text = stdout.trim(); + try { + return JSON.parse(text); + } catch { + const start = text.indexOf("{"); + const end = text.lastIndexOf("}"); + if (start >= 0 && end > start) return JSON.parse(text.slice(start, end + 1)); + throw new Error(`lark-cli returned non-JSON output: ${stderr || stdout}`); + } +} + +function textOf(value) { + if (value == null) return ""; + if (typeof value === "string") return value; + return JSON.stringify(value); +} + +function compact(value, max = 240) { + const text = textOf(value).replace(/\s+/g, " ").trim(); + if (text.length <= max) return text; + return `${text.slice(0, max - 1)}...`; +} + +function extractTitleFromDoc(content, fallback) { + const text = textOf(content); + const xmlTitle = text.match(/]*>(.*?)<\/title>/i)?.[1]; + if (xmlTitle) return compact(xmlTitle, 80); + const mdTitle = text.split(/\r?\n/).find((line) => /^#\s+/.test(line))?.replace(/^#\s+/, ""); + return mdTitle ? compact(mdTitle, 80) : fallback; +} + +function docExternalId(doc) { + return String(doc || "").match(/\/(?:wiki|docx)\/([^/?#]+)/i)?.[1] || String(doc || "").trim(); +} + +function extractDocUrl(doc) { + if (/^https?:\/\//.test(doc)) return doc; + return ""; +} + +function syncStateUrl(source, options) { + const url = new URL(`${options.apiUrl}/api/target-enterprises/${encodeURIComponent(options.companyId)}/materials/sync-state`); + url.searchParams.set("source_type", source.type); + url.searchParams.set("external_id", source.external_id); + url.searchParams.set("checkpoint_key", source.checkpoint_key || "latest"); + url.searchParams.set("display_name", source.display_name || source.external_id); + return url; +} + +async function getSyncState(source, options) { + if (!options.incremental) return null; + if (typeof options.syncStateLoader === "function") { + const state = await options.syncStateLoader(source); + if (state?.source?.status === "paused" && !options.resumeSource) { + throw new Error(`Sync source is paused: ${state.source_id}. Resume the source before importing.`); + } + return state; + } + const response = await backendFetch(syncStateUrl(source, options), {}, options); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(`Backend sync-state failed (${response.status}): ${JSON.stringify(payload)}`); + const state = payload.data || payload; + if (state.source?.status === "paused" && !options.resumeSource) { + throw new Error(`Sync source is paused: ${state.source_id}. Use --resume-source to continue.`); + } + return state; +} + +function checkpointStart(options, state) { + if (options.start) return options.start; + const value = String(state?.checkpoint?.checkpoint_value || "").trim(); + return /^\d{4}-\d{2}-\d{2}T/.test(value) ? value : ""; +} + +async function fetchDocMaterial(doc, options) { + const source = { + type: "feishu_doc", + external_id: docExternalId(doc), + display_name: `飞书云文档:${compact(docExternalId(doc), 60)}`, + checkpoint_key: "revision_id", + }; + await getSyncState(source, options); + const result = await runLark([ + "docs", "+fetch", "--api-version", "v2", "--as", "user", "--doc", doc, + "--doc-format", "markdown", "--format", "json", + ]); + if (!result.ok) throw new Error(`docs +fetch failed: ${JSON.stringify(result.error || result)}`); + + const document = result.data?.document || result.document || {}; + const content = document.content || result.data?.content || ""; + const title = extractTitleFromDoc(content, `飞书云文档:${compact(source.external_id, 40)}`); + source.display_name = title; + source.checkpoint_value = String(document.revision_id ?? result.data?.revision_id ?? ""); + source.version = source.checkpoint_value; + source.url = extractDocUrl(doc); + source.config = { + document_id: document.document_id || "", + revision_id: document.revision_id ?? null, + format: "markdown", + }; + return { + title: `${options.titlePrefix || ""}飞书云文档:${title}`, + source, + source_type: "feishu_doc", + source_url: source.url, + sync_mode: "full", + raw_text: content, + resume_source: options.resumeSource, + }; +} + +function normalizeUserId(value) { + return /^ou_[a-zA-Z0-9]+$/.test(value) ? value : ""; +} + +async function resolveUser(query) { + const direct = normalizeUserId(query); + if (direct) return { open_id: direct, localized_name: query, p2p_chat_id: "" }; + const result = await runLark([ + "contact", "+search-user", "--query", query, "--has-chatted", "--as", "user", "--format", "json", + ]); + const users = result.data?.users || result.users || []; + if (!users.length) throw new Error(`No Feishu user found for: ${query}`); + return users[0]; +} + +function senderName(message, targetUser) { + const sender = message.sender || {}; + if (sender.name) return sender.name; + if (targetUser?.open_id && sender.id === targetUser.open_id) return targetUser.localized_name || "对方"; + return "当前用户"; +} + +function messageItem(message, targetUser) { + return { + id: message.message_id || "", + occurred_at: message.create_time || null, + sender: senderName(message, targetUser), + content: textOf(message.content), + source_url: message.message_app_link || "", + deleted: Boolean(message.deleted), + }; +} + +function messageTimestamp(message) { + const raw = String(message?.create_time || "").trim(); + if (/^\d+$/.test(raw)) { + const value = Number(raw); + return raw.length <= 10 ? value * 1000 : value; + } + const parsed = Date.parse(raw); + return Number.isFinite(parsed) ? parsed : 0; +} + +async function listChatMessages({ chatId, userId, start, end, pageSize, pageLimit }) { + const messages = []; + let pageToken = ""; + for (let page = 0; page < pageLimit; page += 1) { + const args = [ + "im", "+chat-messages-list", chatId ? "--chat-id" : "--user-id", chatId || userId, + "--as", "user", "--order", "asc", "--page-size", String(pageSize), "--format", "json", + ]; + if (start) args.push("--start", start); + if (end) args.push("--end", end); + if (pageToken) args.push("--page-token", pageToken); + const result = await runLark(args); + if (!result.ok) throw new Error(`im +chat-messages-list failed: ${JSON.stringify(result.error || result)}`); + const data = result.data || result; + messages.push(...(data.messages || [])); + if (!data.has_more || !data.page_token) break; + pageToken = data.page_token; + } + return messages; +} + +function messageMaterial({ title, source, messages, targetUser, options, sourceUrl = "" }) { + if (!messages.length) return { skipped: true, reason: "no_new_messages", source }; + const orderedMessages = [...messages].sort((left, right) => { + const byTime = messageTimestamp(left) - messageTimestamp(right); + if (byTime) return byTime; + return String(left.message_id || "").localeCompare(String(right.message_id || "")); + }); + const first = orderedMessages[0]; + const last = orderedMessages[orderedMessages.length - 1]; + source.checkpoint_value = last.create_time || ""; + source.version = last.message_id || last.create_time || ""; + source.url = sourceUrl || first.message_app_link || ""; + source.config = { message_count: messages.length }; + return { + title: `${options.titlePrefix || ""}${title}`, + source, + source_type: source.type, + source_url: source.url, + sync_mode: "incremental", + occurred_at: first.create_time || null, + summary: `通过飞书 CLI 读取 ${messages.length} 条消息,时间范围 ${first.create_time || "未知"} 至 ${last.create_time || "未知"}。`, + source_items: orderedMessages.map((message) => messageItem(message, targetUser)), + resume_source: options.resumeSource, + }; +} + +async function fetchP2PMaterial(query, options) { + const user = await resolveUser(query); + const source = { + type: "feishu_p2p", + external_id: user.p2p_chat_id || user.open_id, + display_name: `飞书单聊:${user.localized_name || query}`, + checkpoint_key: "last_message_time", + }; + const state = await getSyncState(source, options); + const messages = await listChatMessages({ + chatId: user.p2p_chat_id || "", + userId: user.open_id, + start: checkpointStart(options, state), + end: options.end, + pageSize: options.pageSize, + pageLimit: options.pageLimit, + }); + return messageMaterial({ + title: `飞书单聊:${user.localized_name || query}`, + source, + messages, + targetUser: user, + options, + }); +} + +async function fetchChatMaterial(chatId, options) { + const source = { + type: "feishu_chat", + external_id: chatId, + display_name: `飞书群聊:${chatId}`, + checkpoint_key: "last_message_time", + }; + const state = await getSyncState(source, options); + const messages = await listChatMessages({ + chatId, + userId: "", + start: checkpointStart(options, state), + end: options.end, + pageSize: options.pageSize, + pageLimit: options.pageLimit, + }); + return messageMaterial({ title: `飞书群聊:${chatId}`, source, messages, targetUser: null, options }); +} + +async function fetchMessageSearchMaterial(query, options) { + const source = { + type: "feishu_search", + external_id: query, + display_name: `飞书消息搜索:${query}`, + checkpoint_key: "last_message_time", + }; + const state = await getSyncState(source, options); + const args = [ + "im", "+messages-search", "--query", query, "--as", "user", + "--page-size", String(options.pageSize), "--page-limit", String(options.pageLimit), "--format", "json", + ]; + const start = checkpointStart(options, state); + if (start) args.push("--start", start); + if (options.end) args.push("--end", options.end); + const result = await runLark(args); + if (!result.ok) throw new Error(`im +messages-search failed: ${JSON.stringify(result.error || result)}`); + const data = result.data || result; + const messages = data.messages || data.items || []; + return messageMaterial({ title: `飞书消息搜索:${query}`, source, messages, targetUser: null, options }); +} + +async function importMaterial(material, options) { + if (options.dryRun) { + return { + action: "dry_run", + material: { title: material.title }, + source: material.source, + }; + } + if (typeof options.materialImporter === "function") { + return options.materialImporter(material); + } + const response = await backendFetch(`${options.apiUrl}/api/target-enterprises/${encodeURIComponent(options.companyId)}/materials/import`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(material), + }, options); + const text = await response.text(); + let payload; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + payload = { raw: text }; + } + if (!response.ok) throw new Error(`Backend import failed (${response.status}): ${JSON.stringify(payload)}`); + return payload.data || payload; +} + +function descriptors(options) { + return [ + ...options.docs.map((doc) => ({ type: "feishu_doc", label: doc, fetch: () => fetchDocMaterial(doc, options) })), + ...(options.p2pUser ? [{ type: "feishu_p2p", label: options.p2pUser, fetch: () => fetchP2PMaterial(options.p2pUser, options) }] : []), + ...(options.chatId ? [{ type: "feishu_chat", label: options.chatId, fetch: () => fetchChatMaterial(options.chatId, options) }] : []), + ...(options.messageQuery ? [{ type: "feishu_search", label: options.messageQuery, fetch: () => fetchMessageSearchMaterial(options.messageQuery, options) }] : []), + ]; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(usage().trim()); + return; + } + + const result = await runFeishuImport(options); + console.log(JSON.stringify(result, null, 2)); + if (!result.ok) process.exitCode = 1; +} + +async function runFeishuImport(options) { + const imports = []; + for (const descriptor of descriptors(options)) { + const startedAt = Date.now(); + try { + const fetched = await withRetry(descriptor.fetch, options); + if (fetched.value.skipped) { + imports.push({ + source_type: descriptor.type, + source: descriptor.label, + action: "unchanged", + status: "skipped", + reason: fetched.value.reason, + fetch_attempts: fetched.attempts, + duration_ms: Date.now() - startedAt, + }); + continue; + } + const imported = await withRetry(() => importMaterial(fetched.value, options), options); + imports.push({ + source_type: descriptor.type, + source: descriptor.label, + title: fetched.value.title, + action: imported.value.action || "imported", + status: imported.value.openviking_record?.status || imported.value.material?.openviking_status || "ready", + imported_material_id: imported.value.material?.id || null, + source_id: imported.value.source?.id || fetched.value.source?.external_id || null, + content_hash: imported.value.material?.content_hash || null, + provider_run_id: imported.value.provider_run_id || null, + openviking_ref: imported.value.openviking_record?.raw_ref || null, + fetch_attempts: fetched.attempts, + import_attempts: imported.attempts, + duration_ms: Date.now() - startedAt, + }); + } catch (error) { + imports.push({ + source_type: descriptor.type, + source: descriptor.label, + action: "failed", + status: "failed", + attempts: error.attempts || 1, + duration_ms: Date.now() - startedAt, + error: { message: compact(error.message, 500) }, + }); + } + } + + const failed = imports.filter((item) => item.status === "failed").length; + return { + ok: failed === 0, + company_id: options.companyId, + source_count: imports.length, + summary: { + created: imports.filter((item) => item.action === "created").length, + updated: imports.filter((item) => item.action === "updated").length, + unchanged: imports.filter((item) => item.action === "unchanged").length, + failed, + }, + imports, + }; +} + +export { + backendFetch, + checkpointStart, + docExternalId, + extractDocUrl, + messageMaterial, + parseArgs, + readAuthSession, + retryable, + runFeishuImport, + withRetry, +}; + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(JSON.stringify({ + ok: false, + error: { message: error.message }, + }, null, 2)); + process.exit(1); + }); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/migrate-supabase.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/migrate-supabase.mjs new file mode 100644 index 00000000..785d959a --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/migrate-supabase.mjs @@ -0,0 +1,52 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; + +const rootDir = resolve(fileURLToPath(new URL("../..", import.meta.url))); +const migrationsDir = resolve(rootDir, "supabase/migrations"); +const shouldApply = process.argv.includes("--apply"); +const baseEnv = createEnvReader(); +const env = { + ...baseEnv, + value(name, fallback = "") { + if (name === "SUPABASE_READ_ONLY") return "false"; + return baseEnv.value(name, fallback); + }, +}; +const provider = createSupabaseProvider({ env }); + +if (!provider.isConfigured()) { + throw new Error("Supabase CLI persistence is not configured. Check AK/SK, SUPABASE_WORKSPACE_ID and SUPABASE_CLI_BIN."); +} + +const migrationFiles = readdirSync(migrationsDir) + .filter((name) => /^\d+_.+\.sql$/.test(name)) + .sort(); + +const tableCheck = provider.executeSqlSync("select to_regclass('public.schema_migrations') as migration_table;"); +if (!tableCheck.ok) throw new Error(tableCheck.error?.message || "Unable to inspect Supabase migrations."); + +let applied = new Set(); +if (tableCheck.rows?.[0]?.migration_table) { + const result = provider.executeSqlSync("select version from public.schema_migrations order by version;"); + if (!result.ok) throw new Error(result.error?.message || "Unable to read Supabase migrations."); + applied = new Set((result.rows || []).map((row) => String(row.version))); +} + +const pending = migrationFiles.filter((name) => !applied.has(name.split("_")[0])); +if (!shouldApply) { + console.log(JSON.stringify({ ok: pending.length === 0, applied: [...applied], pending }, null, 2)); + if (pending.length) process.exitCode = 1; +} else { + for (const name of pending) { + const sql = readFileSync(resolve(migrationsDir, name), "utf8"); + const result = provider.executeSqlSync(sql); + if (!result.ok) throw new Error(`${name}: ${result.error?.message || "migration failed"}`); + console.log(`applied ${name}`); + } + const verify = provider.executeSqlSync("select version, description, applied_at from public.schema_migrations order by version;"); + if (!verify.ok) throw new Error(verify.error?.message || "Unable to verify Supabase migrations."); + console.log(JSON.stringify({ ok: true, migrations: verify.rows || [] }, null, 2)); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/preflight-real.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/preflight-real.mjs new file mode 100644 index 00000000..2b928cac --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/preflight-real.mjs @@ -0,0 +1,187 @@ +import { createDataProProvider } from "../src/providers/dataProProvider.js"; +import { createModelProvider } from "../src/providers/modelProvider.js"; +import { createOpenVikingProvider } from "../src/providers/openVikingProvider.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; +import { createWebSearchProvider } from "../src/providers/webSearchProvider.js"; + +const liveProbeCompany = process.env.LIVE_PROBE_COMPANY || "北京火山引擎科技有限公司"; + +function safeError(result) { + if (!result?.error) return null; + return { + code: result.error.code || "error", + message: String(result.error.message || "").slice(0, 300), + http_status: result.http_status || null, + }; +} + +function status(ok, details = {}) { + return { + ok: Boolean(ok), + ...details, + }; +} + +async function checkModel() { + const provider = createModelProvider(); + if (!provider.isConfigured()) { + return status(false, { configured: false, error: { code: "missing_config", message: "MODEL_* 未配置完整。" } }); + } + const result = await provider.callJson({ + operation: "afp_preflight_model", + maxTokens: 60, + system: "你是连通性探针。只输出 JSON,不要输出 Markdown。", + payload: { + task: "请返回 {\"ok\":true,\"message\":\"model ready\"}", + output_schema: { ok: true, message: "model ready" }, + }, + }); + return status(result.ok, { + configured: true, + model: provider.modelName, + usage: result.usage || null, + request_id: result.request_id || null, + error: safeError(result), + }); +} + +async function checkDataPro() { + const provider = createDataProProvider(); + if (!provider.isConfigured()) { + return status(false, { configured: false, error: { code: "missing_config", message: "DATAPRO_* 未配置完整。" } }); + } + const result = await provider.callTool(`${liveProbeCompany} 企业工商信息`); + return status(result.ok, { + configured: true, + request_id: result.request_id || null, + summary: result.summary ? String(result.summary).slice(0, 220) : "", + error: safeError(result), + }); +} + +async function checkWebSearch() { + const provider = createWebSearchProvider(); + if (!provider.isConfigured()) { + return status(false, { configured: false, error: { code: "missing_config", message: "AGENT_PLAN_API_KEY 未配置。" } }); + } + const result = await provider.search({ + query: `${liveProbeCompany} 最新动态`, + count: 1, + need_summary: false, + }); + return status(result.ok, { + configured: true, + request_id: result.request_id || null, + result_count: result.result_count || 0, + first_result: result.results?.[0] + ? { + title: result.results[0].title, + url: result.results[0].url, + } + : null, + error: safeError(result), + }); +} + +async function checkOpenViking() { + const provider = createOpenVikingProvider(); + if (!provider.isConfigured()) { + return status(false, { configured: false, error: { code: "missing_config", message: "OpenViking 未配置。" } }); + } + const stamp = `afp-preflight-${Date.now()}`; + const health = await provider.health(); + if (!health.ok) { + return status(false, { + configured: true, + stage: "health", + error: safeError(health), + }); + } + const write = await provider.storeMemory([ + { + role: "user", + content: `AFP 预检测试记忆 ${stamp}。用于确认 OpenViking 当前库可以写入和检索,可在测试后清理。`, + }, + ]); + if (!write.ok) { + return status(false, { + configured: true, + stage: "write", + health: health.result || null, + error: safeError(write), + }); + } + const find = await provider.findMemories(stamp, { limit: 3 }); + const findPreview = JSON.stringify(find.result || null); + return status(find.ok, { + configured: true, + stage: find.ok ? "write_and_find" : "find", + stamp, + health: health.result || null, + write_ref: write.raw_ref || null, + find_ref: find.raw_ref || null, + find_exact_match: findPreview.includes(stamp), + find_result_preview: findPreview.slice(0, 500), + error: safeError(find), + }); +} + +async function checkSupabase() { + const provider = createSupabaseProvider(); + if (!provider.isConfigured()) { + return status(false, { + configured: false, + workspace_id: provider.workspaceId || "", + error: { code: "missing_config", message: "Supabase 工作区、AK/SK 或 skill 目录未配置完整。" }, + }); + } + const stamp = `afp-preflight-${Date.now()}`; + const result = await provider.executeSql(` + create temporary table afp_preflight_probe ( + id text primary key, + note text + ); + insert into afp_preflight_probe (id, note) values ('${stamp}', 'temporary write/read probe'); + select id, note from afp_preflight_probe where id = '${stamp}'; + `); + return status(result.ok, { + configured: true, + workspace_id: provider.workspaceId, + rows: result.rows || null, + error: safeError(result), + }); +} + +const checks = [ + ["model", checkModel], + ["datapro", checkDataPro], + ["web_search", checkWebSearch], + ["openviking", checkOpenViking], + ["supabase", checkSupabase], +]; + +const startedAt = new Date().toISOString(); +const results = {}; +for (const [name, fn] of checks) { + try { + results[name] = await fn(); + } catch (error) { + results[name] = status(false, { + error: { + code: "exception", + message: String(error?.message || error).slice(0, 300), + }, + }); + } +} + +const failed = Object.entries(results).filter(([, result]) => !result.ok).map(([name]) => name); +console.log(JSON.stringify({ + started_at: startedAt, + finished_at: new Date().toISOString(), + ok: failed.length === 0, + failed, + results, +}, null, 2)); + +if (failed.length) process.exitCode = 1; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/real-chain-check.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/real-chain-check.mjs new file mode 100644 index 00000000..59c71285 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/real-chain-check.mjs @@ -0,0 +1,7 @@ +process.stderr.write([ + "此旧脚本已停用:它曾使用内存仓库和 Mock Provider,不能作为真实链路验收证据。", + "最小只读 Provider 诊断请运行:npm run doctor:live", + "完整业务链路验收请运行:npm run verify:business -- --help", + "", +].join("\n")); +process.exitCode = 1; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/restore-supabase.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/restore-supabase.mjs new file mode 100644 index 00000000..7171a810 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/restore-supabase.mjs @@ -0,0 +1,148 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + RESTORE_ORDER, + WORKSPACE_TABLE_SPECS, + prepareRowsForRestore, + tableSpec, + validateBackupPackage, +} from "../src/backup/supabaseBackup.js"; +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseDataProvider } from "../src/providers/supabaseDataProvider.js"; + +function option(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : ""; +} + +function hasFlag(name) { + return process.argv.includes(name); +} + +async function readAll(provider, table, options = {}) { + const rows = []; + const pageSize = 500; + let offset = 0; + while (true) { + const page = await provider.select(table, { + select: options.select || "*", + filters: options.filters || {}, + order: options.order, + limit: pageSize, + offset, + }); + if (!Array.isArray(page)) throw new Error(`Data API returned a non-array response for ${table}.`); + rows.push(...page); + if (page.length < pageSize) break; + offset += pageSize; + } + return rows; +} + +async function writeBatches(provider, table, rows, onConflict) { + const batchSize = 200; + for (let index = 0; index < rows.length; index += batchSize) { + await provider.upsert(table, rows.slice(index, index + batchSize), { onConflict, returning: false }); + } +} + +const backupDir = resolve(option("--backup-dir") || ""); +if (!option("--backup-dir")) throw new Error("--backup-dir is required."); +const manifest = JSON.parse(readFileSync(resolve(backupDir, "manifest.json"), "utf8")); +const data = JSON.parse(readFileSync(resolve(backupDir, "data.json"), "utf8")); +validateBackupPackage(backupDir, manifest, data); + +if (!hasFlag("--apply")) { + console.log(JSON.stringify({ + ok: true, + mode: "validate-only", + backup_id: manifest.backup_id, + source: manifest.source, + required_migrations: manifest.required_migrations, + row_counts: manifest.row_counts, + checksums_verified: true, + apply_command: "npm run db:restore -- --backup-dir --target-workspace-id --target-branch-id --acknowledge-target --acknowledge-target-branch --target-app-workspace-id --apply", + }, null, 2)); + process.exit(0); +} + +const env = createEnvReader(); +const provider = createSupabaseDataProvider({ env }); +const targetCloudWorkspaceId = env.value("SUPABASE_WORKSPACE_ID"); +const targetBranchId = env.value("SUPABASE_BRANCH_ID"); +const requestedTarget = option("--target-workspace-id"); +const requestedTargetBranch = option("--target-branch-id"); +const acknowledgedTarget = option("--acknowledge-target"); +const acknowledgedTargetBranch = option("--acknowledge-target-branch"); +const targetAppWorkspaceId = option("--target-app-workspace-id") || env.value("APP_WORKSPACE_ID"); + +if (!provider.isConfigured() || !provider.isRunEnabled()) { + throw new Error("Configured and enabled target Supabase Data API access is required for restore."); +} +if (!targetCloudWorkspaceId || !targetBranchId || !targetAppWorkspaceId) { + throw new Error("Target SUPABASE_WORKSPACE_ID, SUPABASE_BRANCH_ID, and APP_WORKSPACE_ID are required."); +} +if (requestedTarget !== targetCloudWorkspaceId || acknowledgedTarget !== targetCloudWorkspaceId) { + throw new Error("Target confirmation failed. Both target arguments must exactly match configured SUPABASE_WORKSPACE_ID."); +} +if (requestedTargetBranch !== targetBranchId || acknowledgedTargetBranch !== targetBranchId) { + throw new Error("Target branch confirmation failed. Both branch arguments must exactly match configured SUPABASE_BRANCH_ID."); +} +if (targetCloudWorkspaceId === manifest.source.cloud_workspace_id && targetBranchId === manifest.source.branch_id) { + throw new Error("Restore to the source cloud workspace and branch is blocked. Configure a separate empty branch or workspace."); +} + +const appliedMigrations = await readAll(provider, "schema_migrations", { order: "version.asc" }); +const appliedVersions = new Set(appliedMigrations.map((entry) => entry.version)); +const missingMigrations = manifest.required_migrations.filter((version) => !appliedVersions.has(version)); +if (missingMigrations.length) { + throw new Error(`Target schema is missing migrations: ${missingMigrations.join(", ")}. Run db:migrate first.`); +} + +const targetWorkspaces = await provider.select("app_workspaces", { select: "id", limit: 2 }); +if (targetWorkspaces.some((row) => row.id !== targetAppWorkspaceId)) { + throw new Error("Target contains another application workspace. Restore requires a dedicated empty cloud workspace or branch."); +} +for (const spec of WORKSPACE_TABLE_SPECS) { + const existing = await provider.select(spec.table, { select: spec.table === "app_workspace_members" ? "user_id" : "id", limit: 1 }); + if (existing.length) throw new Error(`Target table ${spec.table} is not empty. Restore was not started.`); +} + +if (!targetWorkspaces.length) { + const workspaceRows = prepareRowsForRestore("app_workspaces", data.tables.app_workspaces || [], targetAppWorkspaceId); + if (workspaceRows.length !== 1) throw new Error("Backup does not contain exactly one application workspace."); + workspaceRows[0].slug = env.value("APP_WORKSPACE_SLUG", workspaceRows[0].slug); + workspaceRows[0].name = env.value("APP_WORKSPACE_NAME", workspaceRows[0].name); + await provider.insert("app_workspaces", workspaceRows, { returning: false }); +} + +const restoredCounts = { app_workspaces: 1, app_users: 0, app_workspace_members: 0 }; +for (const table of RESTORE_ORDER) { + const rows = prepareRowsForRestore(table, data.tables?.[table] || [], targetAppWorkspaceId); + if (rows.length) await writeBatches(provider, table, rows, tableSpec(table)?.onConflict || "id"); + restoredCounts[table] = rows.length; +} + +for (const table of RESTORE_ORDER) { + const spec = tableSpec(table); + const rows = await readAll(provider, table, { + filters: { workspace_id: `eq.${targetAppWorkspaceId}` }, + order: spec?.order, + }); + if (rows.length !== restoredCounts[table]) { + throw new Error(`Restore verification failed for ${table}: expected ${restoredCounts[table]}, got ${rows.length}.`); + } +} + +console.log(JSON.stringify({ + ok: true, + mode: "applied", + backup_id: manifest.backup_id, + target_cloud_workspace_id: targetCloudWorkspaceId, + target_branch_id: targetBranchId, + target_app_workspace_id: targetAppWorkspaceId, + restored_counts: restoredCounts, + checksums_verified: true, + auth_bindings_skipped: true, + provider_secrets_require_reconfiguration: true, +}, null, 2)); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-async-job-queue.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-async-job-queue.mjs new file mode 100644 index 00000000..0d8cd7d9 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-async-job-queue.mjs @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; + +const rootDir = resolve(fileURLToPath(new URL("../..", import.meta.url))); +const baseEnv = createEnvReader(); +const env = { + ...baseEnv, + value(name, fallback = "") { + if (name === "SUPABASE_READ_ONLY") return "false"; + return baseEnv.value(name, fallback); + }, +}; +const provider = createSupabaseProvider({ env }); + +if (!provider.isConfigured()) { + throw new Error("Supabase control-plane SQL is not configured."); +} + +const sql = readFileSync( + resolve(rootDir, "supabase/tests/202607230003_async_job_queue_smoke.sql"), + "utf8", +); +const result = provider.executeSqlSync(sql); +if (!result.ok) { + throw new Error(result.error?.message || "Asynchronous job queue smoke test failed."); +} + +process.stdout.write(`${JSON.stringify({ + ok: true, + check: "async_job_queue", + transaction: "rolled_back", + provider_calls: 0, +}, null, 2)}\n`); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-paid-workflow-guard.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-paid-workflow-guard.mjs new file mode 100644 index 00000000..92c4eeef --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-paid-workflow-guard.mjs @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; + +const rootDir = resolve(fileURLToPath(new URL("../..", import.meta.url))); +const baseEnv = createEnvReader(); +const env = { + ...baseEnv, + value(name, fallback = "") { + if (name === "SUPABASE_READ_ONLY") return "false"; + return baseEnv.value(name, fallback); + }, +}; +const provider = createSupabaseProvider({ env }); + +if (!provider.isConfigured()) { + throw new Error("Supabase control-plane SQL is not configured."); +} + +const sql = readFileSync( + resolve(rootDir, "supabase/tests/202607230002_paid_workflow_guard_smoke.sql"), + "utf8", +); +const result = provider.executeSqlSync(sql); +if (!result.ok) { + throw new Error(result.error?.message || "Paid workflow guard smoke test failed."); +} + +process.stdout.write(`${JSON.stringify({ + ok: true, + check: "paid_workflow_guard", + transaction: "rolled_back", + provider_calls: 0, +}, null, 2)}\n`); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-stage2-api.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-stage2-api.mjs new file mode 100644 index 00000000..b1432e2c --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-stage2-api.mjs @@ -0,0 +1,134 @@ +import { randomUUID } from "node:crypto"; +import { createApp } from "../src/app.js"; +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; + +process.env.REPOSITORY_MODE = "supabase"; + +const env = createEnvReader(); +const provider = createSupabaseProvider({ env }); +const workspaceId = env.value("APP_WORKSPACE_ID"); +const suffix = `${Date.now()}_${randomUUID().slice(0, 8)}`; +const goalName = `Stage 2 API 持久化测试 ${suffix}`; +let goalId = ""; +let firstServer = null; +let secondServer = null; +let primaryError = null; +let report = null; + +function sqlString(value) { + if (value === null || value === undefined || value === "") return "null"; + return `'${String(value).replace(/'/g, "''")}'`; +} + +function assertOk(condition, message) { + if (!condition) throw new Error(`Stage 2 API smoke assertion failed: ${message}`); +} + +function executeSql(sql) { + const result = provider.executeSqlSync(sql); + if (!result.ok) throw new Error(result.error?.message || "Supabase SQL failed."); + return result.rows || []; +} + +function listen(server) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve(server.address().port)); + }); +} + +function close(server) { + if (!server?.listening) return Promise.resolve(); + return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); +} + +async function request(baseUrl, method, path, body) { + const response = await fetch(`${baseUrl}${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(`${method} ${path} returned ${response.status}: ${JSON.stringify(payload)}`); + } + return payload; +} + +if (!provider.isConfigured() || !provider.isRunEnabled() || provider.readOnly) { + throw new Error("Writable Supabase configuration is required for the Stage 2 API smoke test."); +} +if (!workspaceId) throw new Error("APP_WORKSPACE_ID is required for the Stage 2 API smoke test."); + +try { + firstServer = createApp(); + const firstPort = await listen(firstServer); + const firstBaseUrl = `http://127.0.0.1:${firstPort}`; + const health = await request(firstBaseUrl, "GET", "/api/health"); + assertOk(health.data?.runtime_ready === true, "first app instance is not runtime-ready"); + + const created = await request(firstBaseUrl, "POST", "/api/sales-goals", { + name: goalName, + description: "仅用于 Stage 2 HTTP 持久化测试,结束后自动删除。", + keywords: ["stage2", "api-smoke"], + }); + goalId = created.data?.id || ""; + assertOk(goalId, "POST /api/sales-goals did not return an id"); + + const firstRead = await request(firstBaseUrl, "GET", "/api/sales-goals"); + assertOk(firstRead.data?.some((goal) => goal.id === goalId), "first app instance cannot read the created goal"); + await close(firstServer); + firstServer = null; + + secondServer = createApp(); + const secondPort = await listen(secondServer); + const secondBaseUrl = `http://127.0.0.1:${secondPort}`; + const secondRead = await request(secondBaseUrl, "GET", "/api/sales-goals"); + assertOk(secondRead.data?.some((goal) => goal.id === goalId), "fresh app instance did not reload the goal from Supabase"); + + const databaseRows = executeSql(` + select id, name + from public.sales_goals + where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(goalId)}; + `); + assertOk(databaseRows.length === 1, "created API record is missing from Supabase"); + + report = { + ok: true, + test_run: suffix, + verified: { + fail_closed_path: true, + http_create_and_read: true, + fresh_app_instance_reload: true, + direct_database_record: true, + }, + }; +} catch (error) { + primaryError = error; + throw error; +} finally { + await close(firstServer); + await close(secondServer); + if (goalId) { + const cleanup = provider.executeSqlSync(` + delete from public.sales_goals + where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(goalId)}; + `); + if (!cleanup.ok) { + const cleanupError = new Error(`Stage 2 API smoke cleanup failed: ${cleanup.error?.message || "unknown error"}`); + if (!primaryError) throw cleanupError; + console.error(cleanupError.message); + } else { + const remaining = executeSql(` + select count(*)::int as count + from public.sales_goals + where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(goalId)}; + `); + assertOk(Number(remaining[0]?.count || 0) === 0, "temporary API record was not cleaned up"); + if (report) report.cleanup_verified = true; + } + } +} + +console.log(JSON.stringify(report, null, 2)); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-stage2-backup-package.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-stage2-backup-package.mjs new file mode 100644 index 00000000..155cf696 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-stage2-backup-package.mjs @@ -0,0 +1,271 @@ +import { randomUUID } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createEnvReader, loadLocalEnv } from "../src/config/runtimeEnv.js"; +import { createSupabaseDataProvider } from "../src/providers/supabaseDataProvider.js"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(scriptDir, "../.."); +const env = createEnvReader(); +const provider = createSupabaseDataProvider({ env }); +const workspaceId = env.value("APP_WORKSPACE_ID"); +const suffix = `${Date.now()}_${randomUUID().slice(0, 8)}`; +const prefix = `s2_backup_${suffix}`; +const now = new Date().toISOString(); +const outputDir = resolve(repositoryRoot, "backups/private/supabase", `${prefix}_package`); +let report = null; +let primaryError = null; + +const ids = { + providerConnection: `${prefix}_provider`, + goal: `${prefix}_goal`, + company: `${prefix}_company`, + job: `${prefix}_job`, + run: `${prefix}_run`, + step: `${prefix}_step`, + target: `${prefix}_target`, + search: `${prefix}_search`, + progress: `${prefix}_progress`, + dossier: `${prefix}_dossier`, + citation: `${prefix}_citation`, + material: `${prefix}_material`, + openviking: `${prefix}_openviking`, + syncSource: `${prefix}_sync_source`, + checkpoint: `${prefix}_checkpoint`, + audit: `${prefix}_audit`, +}; + +function assertOk(condition, message) { + if (!condition) throw new Error(`Stage 2 backup package assertion failed: ${message}`); +} + +async function insert(table, row) { + await provider.insert(table, row, { returning: false }); +} + +async function remove(table, id) { + await provider.delete(table, { workspace_id: `eq.${workspaceId}`, id: `eq.${id}` }, { returning: false }); +} + +if (!workspaceId || !provider.isConfigured() || !provider.isRunEnabled()) { + throw new Error("Configured and enabled Supabase Data API access is required."); +} + +try { + await insert("provider_connections", { + id: ids.providerConnection, + workspace_id: workspaceId, + provider: `backup-smoke-${suffix}`, + status: "configured", + secret_ref: "secret://synthetic-test-only", + config_json: { synthetic: true }, + }); + await insert("sales_goals", { + id: ids.goal, + workspace_id: workspaceId, + name: "Stage 2 backup restore smoke", + description: "Synthetic data removed from the source after backup.", + keywords: ["stage2", "backup"], + payload_json: { synthetic: true }, + }); + await insert("sales_companies", { + id: ids.company, + workspace_id: workspaceId, + name: `Synthetic Restore Company ${suffix}`, + initial: "S", + industry: "automated_test", + location: "test", + tags: ["stage2", "backup"], + payload_json: { synthetic: true }, + }); + await insert("jobs", { + id: ids.job, + workspace_id: workspaceId, + job_type: "backup_restore_smoke", + status: "succeeded", + attempt_count: 1, + max_attempts: 1, + started_at: now, + finished_at: now, + payload_json: { synthetic: true }, + }); + await insert("provider_runs", { + id: ids.run, + workspace_id: workspaceId, + job_id: ids.job, + operation: "backup_restore_smoke", + status: "succeeded", + app_mode: "production", + entity_type: "company", + entity_id: ids.company, + started_at: now, + finished_at: now, + duration_ms: 1, + payload_json: { synthetic: true }, + }); + await insert("provider_run_steps", { + id: ids.step, + workspace_id: workspaceId, + provider_run_id: ids.run, + sequence: 1, + provider: "supabase", + operation: "backup_restore_smoke", + status: "succeeded", + input_summary: "Synthetic input.", + output_summary: "Synthetic output.", + attempts: 1, + started_at: now, + finished_at: now, + latency_ms: 1, + }); + await insert("sales_target_enterprises", { + id: ids.target, + workspace_id: workspaceId, + goal_id: ids.goal, + company_id: ids.company, + status: "new", + payload_json: { synthetic: true }, + }); + await insert("sales_company_search_results", { + id: ids.search, + workspace_id: workspaceId, + goal_id: ids.goal, + company_id: ids.company, + query: "synthetic backup restore query", + reason: "Automated verification only.", + payload_json: { synthetic: true }, + }); + await insert("sales_progress_snapshots", { + id: ids.progress, + workspace_id: workspaceId, + company_id: ids.company, + label: "new", + summary: "Synthetic progress snapshot.", + evidence: "automated_test", + payload_json: { synthetic: true }, + }); + await insert("sales_dossier_records", { + id: ids.dossier, + workspace_id: workspaceId, + company_id: ids.company, + title: "Synthetic dossier", + summary: "Automated restore verification.", + memory_summary: "Synthetic only.", + status: "completed", + provider_run_id: ids.run, + payload_json: { synthetic: true }, + }); + await insert("sales_dossier_citations", { + id: ids.citation, + workspace_id: workspaceId, + dossier_id: ids.dossier, + citation_no: "1", + label: "Synthetic citation", + source_kind: "automated_test", + url: "", + payload_json: { synthetic: true }, + }); + await insert("sales_materials", { + id: ids.material, + workspace_id: workspaceId, + company_id: ids.company, + title: "Synthetic material", + source_type: "automated_test", + content_hash: `sha256:${suffix}`, + summary: "Synthetic only.", + payload_json: { synthetic: true }, + }); + await insert("sales_openviking_refs", { + id: ids.openviking, + workspace_id: workspaceId, + company_id: ids.company, + related_type: "material", + related_id: ids.material, + ref_kind: "resource", + uri: `viking://synthetic/${suffix}`, + summary: "Synthetic only.", + payload_json: { synthetic: true }, + }); + await insert("sync_sources", { + id: ids.syncSource, + workspace_id: workspaceId, + source_type: "automated_test", + external_id: suffix, + display_name: "Synthetic sync source", + status: "active", + config_json: { synthetic: true }, + }); + await insert("sync_checkpoints", { + id: ids.checkpoint, + workspace_id: workspaceId, + source_id: ids.syncSource, + checkpoint_key: "cursor", + checkpoint_value: "synthetic-cursor", + content_hash: `sha256:${suffix}`, + last_success_at: now, + }); + await insert("audit_events", { + id: ids.audit, + workspace_id: workspaceId, + action: "backup_restore_smoke", + entity_type: "company", + entity_id: ids.company, + after_json: { synthetic: true }, + }); + + const child = spawnSync(process.execPath, [resolve(scriptDir, "backup-supabase.mjs"), "--output-dir", outputDir], { + cwd: resolve(scriptDir, ".."), + encoding: "utf8", + env: { ...process.env, ...loadLocalEnv() }, + }); + if (child.status !== 0) throw new Error(child.stderr || child.stdout || "Backup child process failed."); + const backup = JSON.parse(child.stdout.trim()); + const expectedTables = [ + "provider_connections", "sales_goals", "sales_companies", "jobs", "provider_runs", + "provider_run_steps", "sales_target_enterprises", "sales_company_search_results", + "sales_progress_snapshots", "sales_dossier_records", "sales_dossier_citations", + "sales_materials", "sales_openviking_refs", "sync_sources", + "sync_checkpoints", "audit_events", + ]; + for (const table of expectedTables) { + assertOk(backup.row_counts?.[table] >= 1, `backup did not capture ${table}`); + } + report = { + ok: true, + test_run: suffix, + backup_id: backup.backup_id, + output_dir: backup.output_dir, + verified_nonempty_tables: expectedTables, + checksums_verified: backup.checksums_verified === true, + }; +} catch (error) { + primaryError = error; + throw error; +} finally { + const cleanupTasks = [ + () => remove("provider_connections", ids.providerConnection), + () => remove("audit_events", ids.audit), + () => remove("sales_goals", ids.goal), + () => remove("sales_companies", ids.company), + () => remove("jobs", ids.job), + () => remove("sync_sources", ids.syncSource), + ]; + for (const cleanup of cleanupTasks) { + try { + await cleanup(); + } catch (error) { + if (!primaryError) throw error; + console.error(`Cleanup warning: ${error.message}`); + } + } + const remaining = await provider.select("sales_companies", { + select: "id", + filters: { workspace_id: `eq.${workspaceId}`, id: `eq.${ids.company}` }, + limit: 1, + }); + assertOk(remaining.length === 0, "synthetic source data was not cleaned up"); + if (report) report.source_cleanup_verified = true; +} + +console.log(JSON.stringify(report, null, 2)); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-stage2-data-api.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-stage2-data-api.mjs new file mode 100644 index 00000000..1e312cdc --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-stage2-data-api.mjs @@ -0,0 +1,166 @@ +import { randomUUID } from "node:crypto"; +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { ProviderRunStore } from "../src/observability/providerRunStore.js"; +import { createSupabaseDataProvider } from "../src/providers/supabaseDataProvider.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; +import { SupabaseDataRepository } from "../src/repositories/supabaseDataRepository.js"; + +const env = createEnvReader(); +const adminProvider = createSupabaseProvider({ env }); +const dataProvider = createSupabaseDataProvider({ env }); +const workspaceId = env.value("APP_WORKSPACE_ID"); +const suffix = `${Date.now()}_${randomUUID().slice(0, 8)}`; +const companyId = `s2_data_${suffix}_company`; +const dossierId = `s2_data_${suffix}_dossier`; +const rejectedDossierId = `s2_data_${suffix}_rejected`; +let runId = ""; +let primaryError = null; +let report = null; + +function sqlString(value) { + if (value === null || value === undefined || value === "") return "null"; + return `'${String(value).replace(/'/g, "''")}'`; +} + +function assertOk(condition, message) { + if (!condition) throw new Error(`Stage 2 Data API smoke assertion failed: ${message}`); +} + +function executeSql(sql) { + const result = adminProvider.executeSqlSync(sql); + if (!result.ok) throw new Error(result.error?.message || "Supabase SQL failed."); + return result.rows || []; +} + +if (!workspaceId) throw new Error("APP_WORKSPACE_ID is required."); +if (!dataProvider.isConfigured()) throw new Error("Supabase Data API configuration is required."); +if (!adminProvider.isConfigured() || !adminProvider.isRunEnabled() || adminProvider.readOnly) { + throw new Error("Writable Supabase admin configuration is required for cleanup verification."); +} + +const repository = new SupabaseDataRepository({ + env, + supabaseDataProvider: dataProvider, + workspaceId, +}); + +try { + const now = new Date().toISOString(); + const company = { + id: companyId, + name: `Stage 2 Data API Test ${suffix}`, + initial: "S", + industry: "automated_test", + location: "test", + tags: ["stage2", "data-api"], + progress: { + label: "新商机", + summary: "Data API transaction smoke test.", + evidence: "automated_test", + updated_at: now, + }, + dossier_ids: [], + material_ids: [], + qa_session_id: `sales-${companyId}`, + created_at: now, + updated_at: now, + }; + await repository.persistSalesCompany(company); + + const runStore = new ProviderRunStore({ repository, failOnPersistenceError: true }); + const run = await runStore.startRun({ + operation: "stage2_data_api_rpc_smoke", + app_mode: "production", + entity_type: "target_enterprise", + entity_id: companyId, + }); + runId = run.id; + const step = await runStore.startStep(run.id, { + provider: "supabase", + operation: "transaction_rpc", + input_summary: "Verify provider run RPC persistence.", + }); + await runStore.finishStep(run.id, step.id, { + ok: true, + output_summary: "Provider run RPC persisted.", + usage: { total_tokens: 0 }, + }); + await runStore.completeRun(run.id, { result_ref: `stage2-data-api:${suffix}` }); + + const dossier = { + id: dossierId, + company_id: companyId, + provider_run_id: run.id, + title: "Stage 2 Data API Transaction Test", + summary: "Temporary automated test record.", + memory_summary: "Removed after validation.", + body: [{ text: "Transactional dossier body.", citation_ids: ["1"] }], + citations: [{ id: "1", label: "Automated test citation", source_kind: "test", url: "" }], + created_at: now, + }; + await repository.persistSalesDossier(dossier); + + const persistedRun = await repository.getProviderRun(run.id); + const state = await repository.getSalesState(); + assertOk(persistedRun?.status === "succeeded", "provider run RPC did not persist the terminal state"); + assertOk(persistedRun?.steps?.length === 1, "provider run RPC did not persist its step"); + assertOk(state.dossiers[dossierId]?.citations?.length === 1, "dossier RPC did not persist its citation"); + assertOk(state.dossiers[dossierId]?.provider_run_id === run.id, "dossier RPC did not retain provider_run_id"); + + let rejected = false; + try { + await repository.persistSalesDossier({ + ...dossier, + id: rejectedDossierId, + company_id: `missing-${suffix}`, + }); + } catch (error) { + rejected = /company was not found/i.test(error.message); + } + assertOk(rejected, "invalid dossier transaction was not rejected"); + const rejectedRows = executeSql(` + select count(*)::int as count + from public.sales_dossier_records + where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(rejectedDossierId)}; + `); + assertOk(Number(rejectedRows[0]?.count || 0) === 0, "rejected dossier left a partial record"); + + report = { + ok: true, + test_run: suffix, + verified: { + data_api_company_write: true, + provider_run_transaction_rpc: true, + dossier_and_citations_transaction_rpc: true, + provider_run_link: true, + failed_transaction_left_no_partial_record: true, + }, + }; +} catch (error) { + primaryError = error; + throw error; +} finally { + const cleanup = adminProvider.executeSqlSync(` + delete from public.provider_runs + where workspace_id = ${sqlString(workspaceId)}::uuid + and (id = ${sqlString(runId)} or entity_id = ${sqlString(companyId)}); + delete from public.sales_companies + where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(companyId)}; + `); + if (!cleanup.ok) { + const cleanupError = new Error(`Stage 2 Data API smoke cleanup failed: ${cleanup.error?.message || "unknown error"}`); + if (!primaryError) throw cleanupError; + console.error(cleanupError.message); + } else { + const remaining = executeSql(` + select + (select count(*)::int from public.sales_companies where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(companyId)}) as companies, + (select count(*)::int from public.sales_dossier_records where workspace_id = ${sqlString(workspaceId)}::uuid and id in (${sqlString(dossierId)}, ${sqlString(rejectedDossierId)})) as dossiers, + (select count(*)::int from public.provider_runs where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(runId)}) as runs; + `)[0]; + assertOk(Number(remaining.companies) === 0 && Number(remaining.dossiers) === 0 && Number(remaining.runs) === 0, "temporary Data API records were not cleaned up"); + if (report) report.cleanup_verified = true; + } +} + +console.log(JSON.stringify(report, null, 2)); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-stage3-material-sync.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-stage3-material-sync.mjs new file mode 100644 index 00000000..b2eb5828 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/smoke-stage3-material-sync.mjs @@ -0,0 +1,219 @@ +import { randomUUID } from "node:crypto"; + +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseDataProvider } from "../src/providers/supabaseDataProvider.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; +import { SupabaseDataRepository } from "../src/repositories/supabaseDataRepository.js"; +import { + buildMaterialSyncIdentity, + makeMaterialContentHash, +} from "../src/sync/materialSync.js"; + +const env = createEnvReader(); +const adminProvider = createSupabaseProvider({ env }); +const dataProvider = createSupabaseDataProvider({ env }); +const workspaceId = env.value("APP_WORKSPACE_ID"); +const suffix = `${Date.now()}_${randomUUID().slice(0, 8)}`; +const companyId = `s3_sync_${suffix}_company`; +const externalId = `stage3-acceptance-${suffix}`; +const identity = buildMaterialSyncIdentity(companyId, { + title: "Stage 3 material sync acceptance", + source: { + type: "feishu_doc", + external_id: externalId, + }, +}); +const checkpointId = `${identity.source_id}:revision_id`; +let primaryError = null; +let report = null; + +function sqlString(value) { + if (value === null || value === undefined || value === "") return "null"; + return `'${String(value).replace(/'/g, "''")}'`; +} + +function assertOk(condition, message) { + if (!condition) throw new Error(`Stage 3 material sync smoke assertion failed: ${message}`); +} + +function executeSql(sql) { + const result = adminProvider.executeSqlSync(sql); + if (!result.ok) throw new Error(result.error?.message || "Supabase SQL failed."); + return result.rows || []; +} + +if (!workspaceId) throw new Error("APP_WORKSPACE_ID is required."); +if (!dataProvider.isConfigured()) throw new Error("Supabase Data API configuration is required."); +if (!adminProvider.isConfigured() || !adminProvider.isRunEnabled() || adminProvider.readOnly) { + throw new Error("Writable Supabase admin configuration is required for cleanup verification."); +} + +const repository = new SupabaseDataRepository({ + env, + supabaseDataProvider: dataProvider, + workspaceId, +}); + +try { + const now = new Date().toISOString(); + const company = { + id: companyId, + name: `Stage 3 Sync Test ${suffix}`, + initial: "S", + industry: "automated_test", + location: "test", + tags: ["stage3", "material-sync"], + progress: { + label: "新商机", + summary: "Stage 3 material sync acceptance.", + evidence: "automated_test", + updated_at: now, + }, + dossier_ids: [], + material_ids: [], + qa_session_id: `sales-${companyId}`, + created_at: now, + updated_at: now, + }; + await repository.persistSalesCompany(company); + + await repository.persistSyncSource({ + id: identity.source_id, + source_type: identity.source_type, + external_id: identity.external_id, + display_name: identity.display_name, + status: "active", + config: { format: "markdown", acceptance_test: true }, + last_synced_at: now, + created_at: now, + updated_at: now, + }); + + const firstContent = "Stage 3 material sync acceptance version 1."; + const firstHash = makeMaterialContentHash({ + title: "Stage 3 material sync acceptance", + text: firstContent, + }); + const firstMaterial = { + id: identity.material_id, + company_id: companyId, + title: "Stage 3 material sync acceptance", + source_type: identity.source_type, + source_url: "", + source_id: identity.source_id, + source_version: "1", + content_hash: firstHash, + summary: firstContent, + text: firstContent, + openviking_uri: "viking://resources/sales-workbench/stage3-acceptance/material.md", + openviking_status: "indexed", + last_synced_at: now, + created_at: now, + updated_at: now, + }; + await repository.persistSalesMaterial(firstMaterial); + await repository.persistSyncCheckpoint({ + id: checkpointId, + source_id: identity.source_id, + checkpoint_key: "revision_id", + checkpoint_value: "1", + content_hash: firstHash, + last_success_at: now, + created_at: now, + updated_at: now, + }); + + const firstState = await repository.getSalesState(); + assertOk(firstState.sync_sources[identity.source_id]?.status === "active", "sync source was not persisted"); + assertOk(firstState.sync_checkpoints[checkpointId]?.checkpoint_value === "1", "initial checkpoint was not persisted"); + assertOk(firstState.materials[identity.material_id]?.source_id === identity.source_id, "material was not linked to its source"); + assertOk(firstState.companies[companyId]?.material_ids?.includes(identity.material_id), "company did not expose the synced material"); + + const secondNow = new Date(Date.now() + 1000).toISOString(); + const secondContent = "Stage 3 material sync acceptance version 2."; + const secondHash = makeMaterialContentHash({ + title: firstMaterial.title, + text: secondContent, + }); + await repository.persistSalesMaterial({ + ...firstMaterial, + source_version: "2", + content_hash: secondHash, + summary: secondContent, + text: secondContent, + last_synced_at: secondNow, + updated_at: secondNow, + }); + await repository.persistSyncCheckpoint({ + id: checkpointId, + source_id: identity.source_id, + checkpoint_key: "revision_id", + checkpoint_value: "2", + content_hash: secondHash, + last_success_at: secondNow, + updated_at: secondNow, + }); + + const secondState = await repository.getSalesState(); + const materialRows = executeSql(` + select count(*)::int as count + from public.sales_materials + where workspace_id = ${sqlString(workspaceId)}::uuid + and company_id = ${sqlString(companyId)} + and source_id = ${sqlString(identity.source_id)} + and deleted_at is null; + `); + assertOk(Number(materialRows[0]?.count || 0) === 1, "source update created a duplicate material row"); + assertOk(secondState.materials[identity.material_id]?.source_version === "2", "material version was not updated"); + assertOk(secondState.materials[identity.material_id]?.content_hash === secondHash, "material content hash was not updated"); + assertOk(secondState.sync_checkpoints[checkpointId]?.checkpoint_value === "2", "checkpoint was not advanced"); + + await repository.softDeleteSalesMaterial(identity.material_id, secondNow); + const deletedState = await repository.getSalesState(); + assertOk(!deletedState.materials[identity.material_id], "soft-deleted material remained in business reads"); + + report = { + ok: true, + test_run: suffix, + verified: { + stable_source_and_material_identity: true, + source_material_foreign_key: true, + checkpoint_persistence: true, + same_row_update_without_duplicate: true, + soft_delete_filtered_from_reads: true, + }, + }; +} catch (error) { + primaryError = error; + throw error; +} finally { + const cleanup = adminProvider.executeSqlSync(` + delete from public.sales_companies + where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(companyId)}; + delete from public.sync_sources + where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(identity.source_id)}; + `); + if (!cleanup.ok) { + const cleanupError = new Error(`Stage 3 material sync smoke cleanup failed: ${cleanup.error?.message || "unknown error"}`); + if (!primaryError) throw cleanupError; + console.error(cleanupError.message); + } else { + const remaining = executeSql(` + select + (select count(*)::int from public.sales_companies where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(companyId)}) as companies, + (select count(*)::int from public.sales_materials where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(identity.material_id)}) as materials, + (select count(*)::int from public.sync_sources where workspace_id = ${sqlString(workspaceId)}::uuid and id = ${sqlString(identity.source_id)}) as sources, + (select count(*)::int from public.sync_checkpoints where workspace_id = ${sqlString(workspaceId)}::uuid and source_id = ${sqlString(identity.source_id)}) as checkpoints; + `)[0]; + assertOk( + Number(remaining.companies) === 0 + && Number(remaining.materials) === 0 + && Number(remaining.sources) === 0 + && Number(remaining.checkpoints) === 0, + "temporary material sync records were not cleaned up", + ); + if (report) report.cleanup_verified = true; + } +} + +console.log(JSON.stringify(report, null, 2)); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-business-chain.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-business-chain.mjs new file mode 100644 index 00000000..6e08db44 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-business-chain.mjs @@ -0,0 +1,524 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; + +import { backendFetch, readAuthSession } from "./import-feishu-cli.mjs"; + +const DEFAULT_API_URL = "http://127.0.0.1:8787"; +const TERMINAL_JOB_STATUSES = new Set(["succeeded", "failed", "cancelled"]); +const PRIVATE_KEYS = new Set([ + "access_token", + "api_key", + "lease_token", + "openviking_ref", + "openviking_uri", + "password", + "professional_source_ref", + "prompt", + "raw_ref", + "refresh_token", + "secret", + "secret_key", + "service_role_key", + "worker_id", +]); + +function usage() { + return ` +真实业务链路验收(会写入业务数据并产生 AFP/Token) + +用法: + npm run verify:business -- \\ + --goal-id <销售目标ID> \\ + --company-query <完整企业名称> \\ + --question <基于档案的验收问题> \\ + --confirm-live + +也可以验证已入池企业: + npm run verify:business -- \\ + --enterprise-id <企业ID> \\ + --question <基于档案的验收问题> \\ + --confirm-live + +选项: + --candidate-id <候选企业ID> 搜索结果不能按完整名称唯一匹配时,显式选择候选。 + --api-url 工作台 API 地址。 + --auth-session login.mjs 创建的 0600 CLI 会话文件。 + --timeout-ms 等待异步档案任务的最长时间,默认 300000。 + --poll-ms 任务轮询间隔,默认 1000。 + --confirm-live 必填;确认调用真实 Provider 并保留生成的业务数据。 + +安全约束: + 1. 必须使用已获授权的测试企业;脚本不会自动删除企业、档案或问答。 + 2. 不接受 API Key、Service Role 或密码作为命令行参数。 + 3. 只有真实 Provider Run、逐段引用和持久化检查全部通过,才会输出 ok=true。 +`; +} + +function optionValue(argv, index, name) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${name} 缺少参数值。`); + return value; +} + +function parseArgs(argv) { + const options = { + apiUrl: process.env.SALES_WORKBENCH_API_URL || "", + authSession: process.env.SALES_WORKBENCH_AUTH_SESSION + || path.join(os.homedir(), ".local", "state", "sales-intelligence-workbench", "cli-session.json"), + goalId: "", + companyQuery: "", + candidateId: "", + enterpriseId: "", + question: "", + timeoutMs: 300_000, + pollMs: 1_000, + confirmLive: false, + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--help" || argument === "-h") options.help = true; + else if (argument === "--confirm-live") options.confirmLive = true; + else if (argument === "--api-url") { + options.apiUrl = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--auth-session") { + options.authSession = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--goal-id") { + options.goalId = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--company-query") { + options.companyQuery = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--candidate-id") { + options.candidateId = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--enterprise-id") { + options.enterpriseId = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--question") { + options.question = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--timeout-ms") { + options.timeoutMs = Number(optionValue(argv, index, argument)); + index += 1; + } else if (argument === "--poll-ms") { + options.pollMs = Number(optionValue(argv, index, argument)); + index += 1; + } else { + throw new Error(`未知参数:${argument}`); + } + } + + if (options.help) return options; + if (!options.confirmLive) { + throw new Error("必须提供 --confirm-live,确认本次会调用真实 Provider、产生 AFP/Token 并保留业务数据。"); + } + if (Boolean(options.companyQuery) === Boolean(options.enterpriseId)) { + throw new Error("--company-query 与 --enterprise-id 必须且只能提供一个。"); + } + if (options.companyQuery && !options.goalId) { + throw new Error("使用 --company-query 时必须提供 --goal-id。"); + } + if (options.candidateId && !options.companyQuery) { + throw new Error("--candidate-id 只能与 --company-query 一起使用。"); + } + if (!options.question.trim()) { + throw new Error("必须提供 --question,以验证 Supabase 档案、OpenViking 资料召回与会话记忆,以及模型问答。"); + } + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 10_000 || options.timeoutMs > 900_000) { + throw new Error("--timeout-ms 必须在 10000 到 900000 之间。"); + } + if (!Number.isFinite(options.pollMs) || options.pollMs < 250 || options.pollMs > 5_000) { + throw new Error("--poll-ms 必须在 250 到 5000 之间。"); + } + return options; +} + +function normalizeIdentity(value) { + return String(value || "") + .normalize("NFKC") + .toLowerCase() + .replace(/[\s·•()()[\]【】_-]+/g, ""); +} + +function selectCandidate(candidates, options) { + if (!Array.isArray(candidates) || !candidates.length) { + throw new Error("专业数据集没有返回可选择的企业候选。"); + } + let matches = []; + if (options.candidateId) { + matches = candidates.filter((candidate) => candidate.id === options.candidateId); + } else { + const expected = normalizeIdentity(options.companyQuery); + matches = candidates.filter((candidate) => normalizeIdentity(candidate.name) === expected); + } + if (matches.length !== 1) { + const visibleCandidates = candidates.slice(0, 8) + .map((candidate) => `${candidate.name || "未命名企业"} (${candidate.id || "无ID"})`) + .join(";"); + throw new Error( + `无法唯一确定企业主体。请核对完整企业名称,或使用 --candidate-id 显式选择。候选:${visibleCandidates || "无"}`, + ); + } + const selected = matches[0]; + if (selected.identity_status !== "verified") { + throw new Error(`候选企业 ${selected.name || selected.id} 未通过专业数据集主体核验,不能进入生产验收。`); + } + return selected; +} + +function parsePayload(text) { + try { + return text ? JSON.parse(text) : {}; + } catch { + return {}; + } +} + +async function apiRequest(options, method, endpoint, body) { + const response = await backendFetch(`${options.apiUrl}${endpoint}`, { + method, + headers: body === undefined ? {} : { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }, options); + const payload = parsePayload(await response.text()); + if (!response.ok) { + const code = payload?.error?.code || `http_${response.status}`; + const message = payload?.error?.message || "工作台 API 请求失败。"; + const requestId = payload?.meta?.request_id ? `,请求ID ${payload.meta.request_id}` : ""; + throw new Error(`${code}: ${message}${requestId}`); + } + return payload.data; +} + +function assertPrivateSession(filePath) { + const session = readAuthSession(filePath); + if (!session) { + throw new Error("未找到有效 CLI 登录态。请先运行 Skill 的 login.mjs,密码不要发送到聊天或命令行参数。"); + } + const mode = fs.statSync(filePath).mode & 0o077; + if (mode !== 0) throw new Error("CLI 会话文件权限不安全;请将其权限改为 0600 后重试。"); + return session; +} + +async function pollJob(options, jobId, request = apiRequest) { + const deadline = Date.now() + options.timeoutMs; + let previousStage = ""; + while (Date.now() < deadline) { + const job = await request(options, "GET", `/api/jobs/${encodeURIComponent(jobId)}`); + if (job.stage !== previousStage) { + previousStage = job.stage; + process.stderr.write(`任务 ${job.id}:${job.stage_label || job.stage}(${job.progress ?? 0}%)\n`); + } + if (TERMINAL_JOB_STATUSES.has(job.status)) { + if (job.status !== "succeeded") { + throw new Error(`档案任务未成功:${job.status}${job.error?.code ? ` (${job.error.code})` : ""}`); + } + return job; + } + await delay(options.pollMs); + } + throw new Error(`等待档案任务超时(${options.timeoutMs}ms);任务仍可能在后台运行,请按 job_id 查询。`); +} + +function collectPrivatePaths(value, prefix = "$", result = []) { + if (Array.isArray(value)) { + value.forEach((item, index) => collectPrivatePaths(item, `${prefix}[${index}]`, result)); + return result; + } + if (!value || typeof value !== "object") return result; + for (const [key, nested] of Object.entries(value)) { + const nextPath = `${prefix}.${key}`; + if (PRIVATE_KEYS.has(key.toLowerCase())) result.push(nextPath); + collectPrivatePaths(nested, nextPath, result); + } + return result; +} + +function assertPublicPayload(value, label) { + const privatePaths = collectPrivatePaths(value); + if (privatePaths.length) { + throw new Error(`${label} 暴露了内部字段:${privatePaths.slice(0, 8).join("、")}`); + } +} + +function validateCitedParagraphs(paragraphs, citations, label) { + if (!Array.isArray(paragraphs) || !paragraphs.length) throw new Error(`${label}没有可验收的正文段落。`); + if (!Array.isArray(citations) || !citations.length) throw new Error(`${label}没有真实引用来源。`); + const allowedIds = new Set(citations.map((citation) => String(citation.id))); + for (const [index, paragraph] of paragraphs.entries()) { + const ids = Array.isArray(paragraph.citation_ids) ? paragraph.citation_ids.map(String) : []; + if (!String(paragraph.text || "").trim()) throw new Error(`${label}第 ${index + 1} 段正文为空。`); + if (!ids.length) throw new Error(`${label}第 ${index + 1} 段缺少引用。`); + const unknown = ids.filter((id) => !allowedIds.has(id)); + if (unknown.length) throw new Error(`${label}第 ${index + 1} 段引用了不存在的来源:${unknown.join("、")}`); + } +} + +function validateDossier(dossier, enterpriseId) { + assertPublicPayload(dossier, "档案公开响应"); + if (!dossier?.id || dossier.company_id !== enterpriseId) throw new Error("档案与目标企业不匹配。"); + validateCitedParagraphs(dossier.body, dossier.citations, "档案"); + const sourceKinds = [...new Set(dossier.citations.map((citation) => citation.source_kind).filter(Boolean))]; + if (!sourceKinds.some((kind) => /专业数据|工商|招投标/.test(kind))) { + throw new Error("档案缺少专业数据来源,不能作为生产验收结果。"); + } + if (!sourceKinds.some((kind) => /联网搜索|公开|新闻|公告|媒体|官网/.test(kind))) { + throw new Error("档案缺少联网公开来源,不能作为生产验收结果。"); + } + return { sourceKinds, citationCount: dossier.citations.length, paragraphCount: dossier.body.length }; +} + +function validateQa(result) { + assertPublicPayload(result, "资料问答公开响应"); + const message = result?.message; + if (!message?.id || message.role !== "assistant") throw new Error("资料问答没有返回有效的助手消息。"); + if (message.insufficient) throw new Error("资料问答返回资料不足,完整业务链路未通过。"); + validateCitedParagraphs(message.paragraphs, message.citations, "资料问答"); + return { message, citationCount: message.citations.length }; +} + +function assertProviderRun(run, expectedProviders, label) { + assertPublicPayload(run, `${label} Provider Run`); + if (!run?.id || !["succeeded", "succeeded_with_issues"].includes(run.status)) { + throw new Error(`${label} Provider Run 未成功。`); + } + const missing = []; + const failed = []; + for (const provider of expectedProviders) { + const steps = (run.steps || []).filter((step) => step.provider === provider); + if (!steps.length) { + missing.push(provider); + continue; + } + if (!steps.some((step) => step.status === "succeeded")) failed.push(provider); + } + if (missing.length || failed.length) { + throw new Error( + `${label} Provider 未完整通过` + + `${missing.length ? `;缺少:${missing.join("、")}` : ""}` + + `${failed.length ? `;未成功:${failed.join("、")}` : ""}`, + ); + } +} + +function assertDossierPersistenceBoundary(run) { + const step = (run?.steps || []).find((candidate) => ( + candidate.provider === "openviking" + && candidate.operation === "store_dossier_memory" + )); + if (!step) { + throw new Error("最新档案缺少 OpenViking 存储边界证据。"); + } + if (step.status !== "skipped" || !/Supabase/.test(String(step.output_summary || ""))) { + throw new Error("最新档案未遵守 Supabase 持久化、OpenViking 不重复存档的边界。"); + } +} + +function providerRunSummary(run) { + return { + id: run.id, + operation: run.operation, + status: run.status, + duration_ms: run.duration_ms, + steps: (run.steps || []).map((step) => ({ + provider: step.provider, + operation: step.operation, + status: step.status, + attempts: step.attempts, + latency_ms: step.latency_ms, + usage: step.usage || null, + error_code: step.error?.code || null, + })), + }; +} + +function usageSummary(runs) { + const summary = { + model: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + provider_attempts: {}, + }; + for (const run of runs.filter(Boolean)) { + for (const step of run.steps || []) { + summary.provider_attempts[step.provider] = (summary.provider_attempts[step.provider] || 0) + + Math.max(1, Number(step.attempts || 1)); + if (step.provider !== "model" || !step.usage) continue; + summary.model.prompt_tokens += Number(step.usage.prompt_tokens || 0); + summary.model.completion_tokens += Number(step.usage.completion_tokens || 0); + summary.model.total_tokens += Number(step.usage.total_tokens || 0); + } + } + return summary; +} + +async function findProviderRun(options, input, request = apiRequest) { + if (input.runId) { + return request(options, "GET", `/api/provider-runs/${encodeURIComponent(input.runId)}`); + } + const query = new URLSearchParams({ + operation: input.operation, + entity_id: input.entityId, + limit: "20", + }); + const runs = await request(options, "GET", `/api/provider-runs?${query}`); + const startedAfter = Date.parse(input.startedAfter || ""); + const match = (runs || []).find((run) => input.jobId && run.job_id === input.jobId) + || (runs || []).find((run) => !Number.isFinite(startedAfter) || Date.parse(run.started_at || "") >= startedAfter - 5_000); + if (!match) throw new Error(`找不到 ${input.operation} 的 Provider Run 证据。`); + return match; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + process.stdout.write(usage().trimStart()); + return; + } + + const session = assertPrivateSession(options.authSession); + options.apiUrl = String(options.apiUrl || session.api_url || DEFAULT_API_URL).replace(/\/$/, ""); + if (!/^https?:\/\/[^/]+/i.test(options.apiUrl)) throw new Error("--api-url 不是有效的 HTTP(S) 地址。"); + + process.stderr.write("开始真实业务验收:会保留 Supabase 企业/档案记录和 OpenViking 问答 Session,并产生真实 AFP/Token。\n"); + const runs = []; + let company; + let searchRun = null; + + if (options.companyQuery) { + const goals = await apiRequest(options, "GET", "/api/sales-goals"); + if (!(goals || []).some((goal) => goal.id === options.goalId)) { + throw new Error(`销售目标不存在或当前用户无权访问:${options.goalId}`); + } + const candidates = await apiRequest( + options, + "POST", + `/api/sales-goals/${encodeURIComponent(options.goalId)}/company-search`, + { query: options.companyQuery }, + ); + const selected = selectCandidate(candidates, options); + searchRun = await findProviderRun(options, { + runId: selected.provider_run_id, + operation: "sales_company_search", + entityId: options.goalId, + }); + assertProviderRun(searchRun, ["datapro", "web_search"], "企业搜索"); + runs.push(searchRun); + company = await apiRequest( + options, + "POST", + `/api/sales-goals/${encodeURIComponent(options.goalId)}/target-enterprises`, + { company_id: selected.id }, + ); + } else { + company = await apiRequest(options, "GET", `/api/target-enterprises/${encodeURIComponent(options.enterpriseId)}`); + } + + assertPublicPayload(company, "企业公开响应"); + if (!company?.id || company.identity_status !== "verified") { + throw new Error("目标企业未通过专业数据集主体核验,不能继续生产验收。"); + } + + const dossierStartedAt = new Date().toISOString(); + const dossierResponse = await apiRequest( + options, + "POST", + `/api/target-enterprises/${encodeURIComponent(company.id)}/dossiers`, + { idempotency_key: `release-acceptance-${Date.now()}` }, + ); + let dossierJob = null; + let dossierId = dossierResponse?.detail?.id || dossierResponse?.id || ""; + if (dossierResponse?.job_type || ["queued", "running"].includes(dossierResponse?.status)) { + dossierJob = await pollJob(options, dossierResponse.id); + dossierId = dossierJob.result?.dossier_id || ""; + if (dossierJob.result?.action !== "created") { + throw new Error("档案证据未变化,模型和持久化写入没有完整执行;本次不能作为完整生产验收。"); + } + } + if (!dossierId) throw new Error("档案任务成功但没有返回 dossier_id。"); + const dossier = await apiRequest(options, "GET", `/api/dossiers/${encodeURIComponent(dossierId)}`); + const dossierChecks = validateDossier(dossier, company.id); + const dossierRun = await findProviderRun(options, { + operation: "sales_dossier_generation", + entityId: company.id, + jobId: dossierJob?.id || dossierResponse?.job_id || "", + startedAfter: dossierStartedAt, + }); + assertProviderRun(dossierRun, ["datapro", "web_search", "model", "supabase"], "最新档案"); + assertDossierPersistenceBoundary(dossierRun); + runs.push(dossierRun); + + const qaResult = await apiRequest( + options, + "POST", + `/api/target-enterprises/${encodeURIComponent(company.id)}/qa`, + { question: options.question }, + ); + const qaChecks = validateQa(qaResult); + const qaRun = await findProviderRun(options, { + runId: qaResult.provider_run_id, + operation: "sales_qa", + entityId: company.id, + }); + assertProviderRun(qaRun, ["openviking", "model", "supabase"], "资料问答"); + runs.push(qaRun); + + process.stdout.write(`${JSON.stringify({ + ok: true, + mode: "real_business_chain", + finished_at: new Date().toISOString(), + writes_retained: true, + goal_id: options.goalId || company.goal_id || null, + enterprise: { + id: company.id, + name: company.name, + identity_status: company.identity_status, + }, + company_search: searchRun ? providerRunSummary(searchRun) : { status: "not_run_existing_enterprise" }, + dossier: { + id: dossier.id, + job_id: dossierJob?.id || null, + version_no: dossier.version_no, + citation_count: dossierChecks.citationCount, + paragraph_count: dossierChecks.paragraphCount, + source_kinds: dossierChecks.sourceKinds, + provider_run: providerRunSummary(dossierRun), + }, + qa: { + message_id: qaChecks.message.id, + citation_count: qaChecks.citationCount, + provider_run: providerRunSummary(qaRun), + }, + usage: usageSummary(runs), + }, null, 2)}\n`); +} + +export { + apiRequest, + assertDossierPersistenceBoundary, + assertProviderRun, + collectPrivatePaths, + findProviderRun, + normalizeIdentity, + parseArgs, + pollJob, + selectCandidate, + usageSummary, + validateDossier, + validateQa, +}; + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`${JSON.stringify({ + ok: false, + error: { message: error.message }, + }, null, 2)}\n`); + process.exitCode = 1; + }); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-openviking-qa-boundary.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-openviking-qa-boundary.mjs new file mode 100644 index 00000000..605b6de4 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-openviking-qa-boundary.mjs @@ -0,0 +1,54 @@ +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; + +const env = createEnvReader(); +const provider = createSupabaseProvider({ env }); + +if (!provider.isConfigured()) { + throw new Error("Supabase persistence is not configured."); +} + +function query(sql, label) { + const result = provider.executeSqlSync(sql); + if (!result.ok) throw new Error(`${label}: ${result.error?.message || "query failed"}`); + return result.rows || []; +} + +const [state] = query( + ` + select + to_regclass('public.sales_qa_messages') as active_table, + to_regclass('public.sales_qa_messages_legacy') as legacy_table, + has_table_privilege('anon', 'public.sales_qa_messages_legacy', 'select') as anon_select, + has_table_privilege('authenticated', 'public.sales_qa_messages_legacy', 'select') as authenticated_select, + has_table_privilege('service_role', 'public.sales_qa_messages_legacy', 'select') as service_role_select + `, + "Unable to inspect the QA storage boundary", +); +const [count] = query( + "select count(*)::integer as legacy_rows from public.sales_qa_messages_legacy", + "Unable to count legacy QA rows", +); +const [migration] = query( + "select version, description, applied_at from public.schema_migrations where version = '202607280001'", + "Unable to inspect the QA boundary migration", +); + +const checks = { + migration_applied: migration?.version === "202607280001", + active_table_removed: state?.active_table === null, + legacy_table_present: String(state?.legacy_table || "").endsWith("sales_qa_messages_legacy"), + anon_blocked: state?.anon_select === false, + authenticated_blocked: state?.authenticated_select === false, + service_role_can_audit: state?.service_role_select === true, +}; +const ok = Object.values(checks).every(Boolean); + +console.log(JSON.stringify({ + ok, + checks, + legacy_rows: Number(count?.legacy_rows || 0), + migration: migration || null, +}, null, 2)); + +if (!ok) process.exitCode = 1; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-release-local.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-release-local.mjs new file mode 100644 index 00000000..8b4f1e57 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-release-local.mjs @@ -0,0 +1,75 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const backendDir = path.resolve(scriptDir, ".."); +const projectRoot = path.resolve(backendDir, ".."); +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + +const steps = [ + { + name: "前端 JavaScript 语法", + command: process.execPath, + args: ["--check", "frontend/app.js"], + cwd: projectRoot, + }, + { + name: "前端文本格式化语法", + command: process.execPath, + args: ["--check", "frontend/text-format.js"], + cwd: projectRoot, + }, + { + name: "后端自动化测试", + command: npmCommand, + args: ["test"], + cwd: backendDir, + }, + { + name: "发布密钥扫描", + command: npmCommand, + args: ["run", "release:secrets"], + cwd: backendDir, + }, + { + name: "Skill 分发包一致性", + command: process.execPath, + args: ["skills/sales-intelligence-workbench/scripts/sync-assets.mjs", "--check"], + cwd: projectRoot, + }, + { + name: "Skill 隔离生命周期", + command: process.execPath, + args: ["skills/sales-intelligence-workbench/scripts/self-test.mjs"], + cwd: projectRoot, + }, +]; + +function runStep(step, index) { + console.log(`\n[${index + 1}/${steps.length}] ${step.name}`); + const result = spawnSync(step.command, step.args, { + cwd: step.cwd, + env: { + ...process.env, + NO_COLOR: process.env.NO_COLOR || "1", + }, + stdio: "inherit", + }); + + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${step.name} 未通过(退出码 ${result.status ?? "unknown"})。`); + } +} + +console.log("开始离线发布验收。本流程不访问外部 Provider,也不会产生 AFP。"); + +try { + steps.forEach(runStep); + console.log(`\n离线发布验收通过:${steps.length}/${steps.length} 项完成。`); +} catch (error) { + console.error(`\n离线发布验收失败:${error?.message || String(error)}`); + process.exitCode = 1; +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-supabase-security-boundary.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-supabase-security-boundary.mjs new file mode 100644 index 00000000..57b11cd2 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-supabase-security-boundary.mjs @@ -0,0 +1,103 @@ +import { createEnvReader } from "../src/config/runtimeEnv.js"; +import { createSupabaseProvider } from "../src/providers/supabaseProvider.js"; + +const env = createEnvReader(); +const provider = createSupabaseProvider({ env }); + +if (!provider.isConfigured()) { + throw new Error("Supabase persistence is not configured."); +} + +function query(sql, label) { + const result = provider.executeSqlSync(sql); + if (!result.ok) throw new Error(`${label}: ${result.error?.message || "query failed"}`); + return result.rows || []; +} + +const managementRoutines = [ + "persist_sales_dossier", + "persist_provider_run", + "reserve_paid_workflow", + "finish_paid_workflow", + "get_paid_workflow_usage", + "enqueue_sales_job", + "claim_sales_job", + "heartbeat_sales_job", + "release_sales_job_claim", + "request_cancel_sales_job", + "acknowledge_cancel_sales_job", + "retry_sales_job", +]; +const routineList = managementRoutines.map((name) => `'${name}'`).join(", "); +const platformManagedTables = new Set(["health_check"]); + +const tables = query( + ` + select + c.relname as table_name, + pg_get_userbyid(c.relowner) as owner, + c.relrowsecurity as rls_enabled, + has_table_privilege('anon', c.oid, 'select') as anon_select, + has_table_privilege('authenticated', c.oid, 'select') as authenticated_select + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + where n.nspname = 'public' and c.relkind in ('r', 'p') + order by c.relname + `, + "Unable to inspect public table RLS", +); +const ordinaryExecuteGrants = query( + ` + select distinct routine_name, grantee + from information_schema.routine_privileges + where routine_schema = 'public' + and routine_name in (${routineList}) + and privilege_type = 'EXECUTE' + and grantee in ('PUBLIC', 'anon', 'authenticated') + order by routine_name, grantee + `, + "Unable to inspect ordinary-role RPC grants", +); +const serviceRoleExecuteGrants = query( + ` + select distinct routine_name + from information_schema.routine_privileges + where routine_schema = 'public' + and routine_name in (${routineList}) + and privilege_type = 'EXECUTE' + and grantee = 'service_role' + order by routine_name + `, + "Unable to inspect service-role RPC grants", +); + +const projectTables = tables.filter((table) => !platformManagedTables.has(table.table_name)); +const platformTables = tables.filter((table) => platformManagedTables.has(table.table_name)); +const tablesWithoutRls = projectTables + .filter((table) => table.rls_enabled !== true) + .map((table) => table.table_name); +const exposedPlatformTables = platformTables + .filter((table) => table.anon_select === true || table.authenticated_select === true) + .map((table) => table.table_name); +const serviceRoleRoutines = new Set(serviceRoleExecuteGrants.map((row) => row.routine_name)); +const missingServiceRoleGrants = managementRoutines.filter((name) => !serviceRoleRoutines.has(name)); +const checks = { + project_public_tables_use_rls: tablesWithoutRls.length === 0, + platform_managed_tables_fail_closed: exposedPlatformTables.length === 0, + ordinary_roles_cannot_execute_management_rpcs: ordinaryExecuteGrants.length === 0, + service_role_can_execute_management_rpcs: missingServiceRoleGrants.length === 0, +}; +const ok = Object.values(checks).every(Boolean); + +console.log(JSON.stringify({ + ok, + checks, + inspected_project_tables: projectTables.length, + platform_managed_tables: platformTables, + tables_without_rls: tablesWithoutRls, + exposed_platform_tables: exposedPlatformTables, + ordinary_execute_grants: ordinaryExecuteGrants, + missing_service_role_grants: missingServiceRoleGrants, +}, null, 2)); + +if (!ok) process.exitCode = 1; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/agents/dossierAgent.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/agents/dossierAgent.js new file mode 100644 index 00000000..6a0939e2 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/agents/dossierAgent.js @@ -0,0 +1,1385 @@ +import { + extractGroundingDates, + extractGroundingNumbers, + evidenceSpanErrors, + groundedTextErrors, +} from "../evidence/claimGrounding.js"; +import { + extractCriticalClaims, + hasHighRiskAssertion, +} from "../evidence/salesEvidence.js"; + +const SECTION_DEFINITIONS = Object.freeze([ + ["company_overview", "企业与业务概览"], + ["business_dynamics", "经营与业务动态"], + ["recent_public_updates", "近期公开动态"], + ["risk_attention", "风险与关注事项"], + ["sales_opportunity", "销售机会判断"], + ["recommended_actions", "建议行动"], +]); + +const PLAN_FUNCTION_NAME = "plan_sales_dossier"; +const MAX_AGENT_CITATIONS = 10; +const MAX_PROFESSIONAL_CITATIONS = 5; +const MAX_PUBLIC_CITATIONS = 5; +const PROFESSIONAL_SUMMARY_CHARS = 700; +const PUBLIC_SUMMARY_CHARS = 500; +const MAX_EVIDENCE_IDS_PER_SECTION = 3; +const MAX_EVIDENCE_ATOMS_PER_SECTION = 6; +const MAX_PLAN_ITEM_CHARS = 600; +const SUBJECT_BOUNDARY_TERMS = /(?:品牌|集团|相关业务|在华业务|中国业务|公开信息显示)/u; +const ANALYTICAL_RISK_TERMS = /(?:应|需|建议|核验|确认|关注|评估|避免|前置|待明确|待沟通|对接前)/u; + +const OUTPUT_BUDGET = Object.freeze({ + summary_max_chars: 160, + section_max_chars: 1000, + paragraph_max_chars: 600, + paragraphs_per_section: "1", + memory_summary_max_chars: 200, + recommended_action_count: "1", +}); + +function compact(value, maxLength) { + const normalized = String(value || "").replace(/\s+/gu, " ").trim(); + if (normalized.length <= maxLength) return normalized; + return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`; +} + +function sourceRank(citation = {}) { + const qualityTier = Number(citation.quality_tier); + const qualityScore = Number.isFinite(qualityTier) ? Math.max(0, 5 - qualityTier) * 10 : 0; + const freshnessScore = citation.freshness === "current" ? 18 : citation.freshness === "recent" ? 10 : 0; + const officialScore = citation.official ? 12 : 0; + const datedScore = citation.published_at ? 4 : 0; + return qualityScore + freshnessScore + officialScore + datedScore; +} + +function ranked(citations = []) { + return [...citations].sort((left, right) => ( + sourceRank(right) - sourceRank(left) + || String(right.published_at || "").localeCompare(String(left.published_at || "")) + || String(left.id || "").localeCompare(String(right.id || "")) + )); +} + +function citationIndependenceKey(citation = {}) { + return String( + citation.independence_key + || `${citation.source_kind || "source"}:${citation.id || citation.label || ""}`, + ); +} + +function distinctCitationCount(citations = []) { + return new Set(citations.map(citationIndependenceKey).filter(Boolean)).size; +} + +function sameCriticalClaim(left = {}, right = {}) { + return left.field === right.field + && left.normalized_value === right.normalized_value; +} + +function atomCriticalClaims(atom = {}) { + return extractCriticalClaims(String(atom.quote || "")); +} + +const SPECIFIC_RISK_TERMS = [ + "行政处罚", + "司法诉讼", + "失信被执行", + "限制高消费", + "经营异常", + "监管处罚", + "产品召回", + "安全事故", + "供应中断", + "交付延期", +]; + +function atomRiskSignature(atom = {}) { + const value = String(atom.quote || ""); + return { + terms: SPECIFIC_RISK_TERMS.filter((term) => value.includes(term)), + dates: extractGroundingDates(value), + numbers: extractGroundingNumbers(value), + }; +} + +function sameRiskSignature(left = {}, right = {}) { + if (!left.terms.length || !left.terms.every((term) => right.terms.includes(term))) return false; + if (left.dates.length && !left.dates.every((date) => right.dates.includes(date))) return false; + if (left.numbers.length && !left.numbers.every((number) => right.numbers.includes(number))) return false; + return true; +} + +function criticalClaimSupportingAtoms(atom = {}, atoms = [], citationById = new Map()) { + const claims = atomCriticalClaims(atom); + const riskSignature = atomRiskSignature(atom); + if (!claims.length && !riskSignature.terms.length) return []; + return atoms.filter((candidate) => { + const citation = citationById.get(String(candidate.citation_id || "")); + if (!citation) return false; + const candidateClaims = atomCriticalClaims(candidate); + if (claims.length) { + return claims.every((claim) => candidateClaims.some((candidateClaim) => ( + sameCriticalClaim(claim, candidateClaim) + ))); + } + return sameRiskSignature(riskSignature, atomRiskSignature(candidate)); + }); +} + +function hasSufficientCriticalClaimSupport(atom = {}, atoms = [], citationById = new Map()) { + const claims = atomCriticalClaims(atom); + if (!claims.length && !hasHighRiskAssertion(atom.quote)) return true; + const supporters = criticalClaimSupportingAtoms(atom, atoms, citationById); + const supportingCitations = [...new Map(supporters.map((candidate) => { + const citation = citationById.get(String(candidate.citation_id || "")); + return [String(candidate.citation_id || ""), citation]; + })).values()].filter(Boolean); + return distinctCitationCount(supportingCitations) >= 2 + && supportingCitations.some((citation) => Number(citation.quality_tier) === 1); +} + +export function buildDossierSourceUsageRequirements(citations = []) { + const professional = citations.filter((citation) => citation.source_kind === "专业数据集"); + const publicSources = citations.filter((citation) => citation.source_kind === "联网搜索"); + const availableDistinct = distinctCitationCount(citations); + const availableProfessional = distinctCitationCount(professional); + const availablePublic = distinctCitationCount(publicSources); + return { + available_distinct_source_count: availableDistinct, + required_distinct_source_count: 0, + available_professional_source_count: availableProfessional, + required_professional_source_count: 0, + available_public_source_count: availablePublic, + required_public_source_count: 0, + }; +} + +export function dossierSourceUsageErrors( + citationIds = [], + citations = [], + requirements = buildDossierSourceUsageRequirements(citations), + path = "整份档案", +) { + const citationById = new Map(citations.map((citation) => [String(citation?.id || ""), citation])); + const used = [...new Set(citationIds.map(String))] + .map((id) => citationById.get(id)) + .filter(Boolean); + const usedProfessional = used.filter((citation) => citation.source_kind === "专业数据集"); + const usedPublic = used.filter((citation) => citation.source_kind === "联网搜索"); + const actual = { + total: distinctCitationCount(used), + professional: distinctCitationCount(usedProfessional), + public: distinctCitationCount(usedPublic), + }; + const errors = []; + if (actual.total < Number(requirements.required_distinct_source_count || 0)) { + errors.push( + `${path}仅覆盖 ${actual.total} 个独立来源,当前证据允许覆盖至少 ${requirements.required_distinct_source_count} 个`, + ); + } + if (actual.professional < Number(requirements.required_professional_source_count || 0)) { + errors.push( + `${path}仅覆盖 ${actual.professional} 个独立专业来源,当前证据允许覆盖至少 ${requirements.required_professional_source_count} 个`, + ); + } + if (actual.public < Number(requirements.required_public_source_count || 0)) { + errors.push( + `${path}仅覆盖 ${actual.public} 个独立公开来源,当前证据允许覆盖至少 ${requirements.required_public_source_count} 个`, + ); + } + return errors; +} + +function selectedPolicy(policy = {}, selectedIds = new Set()) { + return Object.fromEntries(Object.entries(policy).map(([key, ids]) => [ + key, + (Array.isArray(ids) ? ids : []).map(String).filter((id) => selectedIds.has(id)), + ])); +} + +function compactCitation(citation = {}) { + const isPublic = citation.source_kind === "联网搜索"; + return { + id: String(citation.id || ""), + source_kind: String(citation.source_kind || ""), + label: compact(citation.label, 160), + summary: compact( + citation.summary || citation.excerpt, + isPublic ? PUBLIC_SUMMARY_CHARS : PROFESSIONAL_SUMMARY_CHARS, + ), + published_at: citation.published_at || null, + source_quality_label: compact(citation.source_quality_label, 60), + freshness_label: compact(citation.freshness_label, 60), + entity_match: compact(citation.entity_match, 40), + independence_key: compact(citation.independence_key, 160), + conflict_fields: (Array.isArray(citation.conflict_fields) ? citation.conflict_fields : []) + .map((item) => compact(item, 80)) + .filter(Boolean) + .slice(0, 6), + }; +} + +function compactEvidenceAtom(atom = {}) { + return { + id: String(atom.id || ""), + quote: compact(atom.quote, 360), + source_kind: String(atom.source_kind || ""), + source_type: String(atom.source_type || ""), + title: compact(atom.title, 160), + published_at: atom.published_at || null, + entity_match: compact(atom.entity_match, 40), + reliability: compact(atom.reliability, 40), + conflict_fields: (Array.isArray(atom.conflict_fields) ? atom.conflict_fields : []) + .map((item) => compact(item, 80)) + .filter(Boolean) + .slice(0, 6), + selection_scope: atom.selection_scope === "cross_section_grounding" + ? "cross_section_grounding" + : "section_candidate", + }; +} + +function evidenceAtomOrder(left, right) { + return Number(right.score || 0) - Number(left.score || 0) + || String(left.id || "").localeCompare(String(right.id || "")); +} + +function atomHasUsableEntityMatch(atom = {}, section = "") { + const entityMatch = String(atom.entity_match || ""); + if (section === "company_overview") return entityMatch === "verified"; + if (section === "risk_attention" && atom.selection_scope !== "cross_section_grounding") { + return ["verified", "company_scoped"].includes(entityMatch); + } + return entityMatch && entityMatch !== "unverified"; +} + +function sectionEvidenceCandidates(atoms = [], section = "") { + const usable = atoms + .filter((atom) => atom?.id && atom?.citation_id && atom?.quote) + .filter((atom) => atomHasUsableEntityMatch(atom, section)); + const direct = usable + .filter((atom) => ( + Array.isArray(atom.section_candidates) + && atom.section_candidates.includes(section) + )) + .sort(evidenceAtomOrder) + .map((atom) => ({ ...atom, selection_scope: "section_candidate" })); + if (direct.length) return direct; + + const professional = usable.filter((atom) => atom.source_kind === "professional"); + const publicSources = usable.filter((atom) => atom.source_kind === "public"); + let fallback = []; + if (section === "recent_public_updates") { + fallback = [ + ...professional.filter((atom) => atom.published_at || atom.source_updated_at), + ...professional, + ...publicSources, + ]; + } else if (section === "risk_attention") { + fallback = [ + ...professional.filter((atom) => ( + Array.isArray(atom.section_candidates) + && atom.section_candidates.includes("business_dynamics") + )), + ...professional.filter((atom) => ( + Array.isArray(atom.section_candidates) + && atom.section_candidates.includes("company_overview") + )), + ...professional, + ...publicSources, + ]; + } else { + fallback = [...professional, ...publicSources]; + } + return [...new Map( + fallback.map((atom) => [String(atom.id), atom]), + ).values()].map((atom) => ({ + ...atom, + selection_scope: "cross_section_grounding", + })); +} + +const SECTION_REQUIRED_SOURCE_POLICY = Object.freeze({ + company_overview: "business_database_ids", + business_dynamics: "business_dynamics_ids", + recent_public_updates: "web_search_ids", + risk_attention: "risk_database_ids", +}); + +function policyConstrainedSectionCandidates(candidates = [], section = "", policy = {}) { + const policyKey = SECTION_REQUIRED_SOURCE_POLICY[section]; + const policyValues = policyKey && Array.isArray(policy?.[policyKey]) + ? policy[policyKey] + : section === "business_dynamics" && Array.isArray(policy?.market_database_ids) + ? policy.market_database_ids + : []; + const requiredCitationIds = new Set( + policyValues + .map(String) + .filter(Boolean), + ); + if (!requiredCitationIds.size) return candidates; + return candidates.filter((atom) => requiredCitationIds.has(String(atom.citation_id || ""))); +} + +/** + * Build a deterministic, bounded evidence projection for the two-stage agent. + * The durable evidence pack remains complete and is still used by server-side + * validators after the model calls finish. + */ +export function buildDossierAgentContext({ + citations = [], + evidencePolicy = {}, + evidenceConflicts = [], + sourceSelectionPolicy = {}, + evidenceAtoms = [], + evidenceCoverage = {}, +} = {}) { + const excludedEntityCitationIds = new Set( + (Array.isArray(sourceSelectionPolicy.excluded_entity_citation_ids) + ? sourceSelectionPolicy.excluded_entity_citation_ids + : []) + .map(String) + .filter(Boolean), + ); + const usable = ranked(citations.filter((citation) => ( + citation?.id + && citation?.summary + && !excludedEntityCitationIds.has(String(citation.id)) + ))); + const byId = new Map(usable.map((citation) => [String(citation.id), citation])); + const usableAtoms = (Array.isArray(evidenceAtoms) ? evidenceAtoms : []) + .filter((atom) => atom?.id && atom?.citation_id && atom?.quote) + .filter((atom) => byId.has(String(atom.citation_id))); + const professional = usable.filter((citation) => citation.source_kind === "专业数据集"); + const publicSources = usable.filter((citation) => citation.source_kind === "联网搜索"); + const selected = []; + const selectedIds = new Set(); + + const add = (citation) => { + const id = String(citation?.id || ""); + if (!id || selectedIds.has(id) || selected.length >= MAX_AGENT_CITATIONS) return; + selectedIds.add(id); + selected.push(citation); + }; + const addPolicyHead = (key) => { + const first = (Array.isArray(sourceSelectionPolicy[key]) ? sourceSelectionPolicy[key] : []) + .map(String) + .map((id) => byId.get(id)) + .find(Boolean); + add(first); + }; + + // Preserve at least one strong source for every report section before the + // global context cap is filled. Otherwise a low-ranked but indispensable + // risk or recent source can be dropped even though collection succeeded. + SECTION_DEFINITIONS.forEach(([key]) => { + const head = sectionEvidenceCandidates(usableAtoms, key)[0]; + add(byId.get(String(head?.citation_id || ""))); + }); + addPolicyHead("business_database_ids"); + addPolicyHead("risk_database_ids"); + addPolicyHead("business_dynamics_ids"); + addPolicyHead("market_database_ids"); + professional.slice(0, MAX_PROFESSIONAL_CITATIONS).forEach(add); + publicSources.slice(0, MAX_PUBLIC_CITATIONS).forEach(add); + usable.forEach(add); + + const selectedProfessional = selected.filter((citation) => citation.source_kind === "专业数据集"); + const selectedPublic = selected.filter((citation) => citation.source_kind === "联网搜索"); + const compactCitations = selected.map(compactCitation); + const sourceUsageRequirements = buildDossierSourceUsageRequirements(compactCitations); + const policy = selectedPolicy(sourceSelectionPolicy, selectedIds); + const conflicts = evidenceConflicts + .map((conflict) => ({ + field: compact(conflict?.field, 80), + field_label: compact(conflict?.field_label, 120), + evidence_ids: [ + ...new Set((conflict?.values || []) + .flatMap((value) => value?.evidence_ids || []) + .map(String) + .filter((id) => selectedIds.has(id))), + ], + })) + .filter((conflict) => conflict.field && conflict.evidence_ids.length >= 2); + const selectedAtomCandidates = usableAtoms + .filter((atom) => selectedIds.has(String(atom.citation_id))); + const selectedCitationById = new Map( + selected.map((citation) => [String(citation.id || ""), citation]), + ); + const selectedAtoms = selectedAtomCandidates.filter((atom) => ( + hasSufficientCriticalClaimSupport(atom, selectedAtomCandidates, selectedCitationById) + )); + const evidenceBySection = Object.fromEntries(SECTION_DEFINITIONS.map(([key]) => { + const coverageIds = new Set( + Array.isArray(evidenceCoverage?.[key]?.atom_ids) + ? evidenceCoverage[key].atom_ids.map(String) + : [], + ); + let candidates = sectionEvidenceCandidates(selectedAtoms, key) + .filter((atom) => ( + atom.selection_scope === "cross_section_grounding" + || !coverageIds.size + || coverageIds.has(String(atom.id)) + )); + candidates = policyConstrainedSectionCandidates(candidates, key, policy); + return [key, candidates.slice(0, MAX_EVIDENCE_ATOMS_PER_SECTION)]; + })); + const normalizedCoverage = Object.fromEntries(SECTION_DEFINITIONS.map(([key]) => { + const original = evidenceCoverage?.[key]; + const atoms = evidenceBySection[key] || []; + const usesCrossSectionGrounding = atoms.some((atom) => ( + atom.selection_scope === "cross_section_grounding" + )); + if (atoms.length && (original?.status === "missing" || usesCrossSectionGrounding)) { + return [key, { + status: "partial", + atom_ids: atoms.map((atom) => String(atom.id)), + reasons: [...new Set([ + ...(Array.isArray(original?.reasons) ? original.reasons : []), + "cross_section_grounded_fallback", + ])], + }]; + } + return [key, original || { + status: atoms.length ? "supported" : "missing", + atom_ids: atoms.map((atom) => String(atom.id)), + reasons: atoms.length ? [] : ["no_relevant_atoms"], + }]; + })); + + return { + citations: compactCitations, + evidencePolicy: { + source_counts: { + professional: selectedProfessional.length, + public: selectedPublic.length, + }, + conflict_count: conflicts.length, + warnings: (Array.isArray(evidencePolicy?.warnings) ? evidencePolicy.warnings : []) + .map((warning) => compact(warning, 160)) + .filter(Boolean) + .slice(0, 6), + }, + evidenceConflicts: conflicts, + sourceSelectionPolicy: policy, + sourceUsageRequirements, + evidenceBySection, + evidenceCoverage: normalizedCoverage, + outputBudget: OUTPUT_BUDGET, + metrics: { + available_citation_count: usable.length, + selected_citation_count: compactCitations.length, + professional_count: selectedProfessional.length, + public_count: selectedPublic.length, + excluded_unsupported_critical_atom_count: selectedAtomCandidates.length - selectedAtoms.length, + excluded_unrelated_entity_citation_count: excludedEntityCitationIds.size, + selected_atom_count: new Set( + Object.values(evidenceBySection).flat().map((atom) => String(atom.id)), + ).size, + serialized_chars: JSON.stringify(compactCitations).length, + }, + }; +} + +function sectionPlanSchema(evidenceIds = [], description = "") { + return { + type: "object", + additionalProperties: false, + properties: { + text: { + type: "string", + minLength: 8, + maxLength: MAX_PLAN_ITEM_CHARS, + description, + }, + evidence_ids: { + type: "array", + minItems: 1, + maxItems: MAX_EVIDENCE_IDS_PER_SECTION, + uniqueItems: true, + items: { + type: "string", + enum: [...new Set(evidenceIds.map(String).filter(Boolean))], + }, + description: "直接支撑本章正文、且属于本章节允许集合的 Evidence Atom ID。", + }, + }, + required: ["text", "evidence_ids"], + }; +} + +export function buildDossierPlanSchema( + evidenceIdsBySection = {}, + sectionKeys = SECTION_DEFINITIONS.map(([key]) => key), +) { + const selectedKeys = new Set(sectionKeys.map(String)); + const selectedSections = SECTION_DEFINITIONS.filter(([key]) => selectedKeys.has(key)); + const properties = Object.fromEntries(selectedSections.map(([key]) => [ + key, + sectionPlanSchema( + Array.isArray(evidenceIdsBySection?.[key]) ? evidenceIdsBySection[key] : [], + key === "recommended_actions" + ? "一个可直接展示的完整行动段落,写清动作、对象和待核验事项。" + : "一个可直接展示的完整正文段落,只表达本章最重要且有直接证据的内容。", + ), + ])); + return { + type: "object", + additionalProperties: false, + properties: { + sections: { + type: "object", + additionalProperties: false, + properties, + required: selectedSections.map(([key]) => key), + }, + }, + required: ["sections"], + }; +} + +function planItemsForSection(plan = {}, key) { + const item = plan?.sections?.[key]; + return item && typeof item === "object" && item.text ? [item] : []; +} + +function normalizeClaimText(value) { + return String(value || "").replace(/\s+/gu, " ").trim(); +} + +function normalizeSectionClaimText(section, value) { + const normalized = normalizeClaimText(value); + if (section !== "company_overview") return normalized; + return normalized.replace( + /[,,]?(?:并|同时)(?:还)?(?:延伸|扩展)(?:到|至)/gu, + ",并包括", + ); +} + +function normalizedPlan( + parsed = {}, + evidenceAtoms = [], + citations = [], + allowedEvidenceBySection = {}, + sourceUsageRequirements = {}, + ignoredEntityNames = [], +) { + const citationById = new Map(citations.map((item) => [String(item?.id || ""), item])); + const atomById = new Map(evidenceAtoms.map((item) => [String(item?.id || ""), item])); + const errors = []; + const seen = new Set(); + const sections = {}; + + SECTION_DEFINITIONS.forEach(([key, title]) => { + const item = parsed?.sections?.[key]; + const text = normalizeSectionClaimText(key, item?.text); + const itemPath = `${title}第 1 条`; + const rawEvidenceIds = Array.isArray(item?.evidence_ids) + ? item.evidence_ids.map(String).filter(Boolean) + : []; + const evidenceIds = [...new Set(rawEvidenceIds)]; + const allowed = new Set( + Array.isArray(allowedEvidenceBySection?.[key]) + ? allowedEvidenceBySection[key].map(String) + : [], + ); + if (!item || typeof item !== "object") errors.push(`${title}必须提交一个完整章节对象`); + if (!text) errors.push(`${itemPath}内容为空`); + if (text.length > MAX_PLAN_ITEM_CHARS) { + errors.push(`${itemPath}超过 ${MAX_PLAN_ITEM_CHARS} 个字符`); + } + if (text && !/[。!?]$/u.test(text)) errors.push(`${itemPath}不是完整句子`); + if (!rawEvidenceIds.length) errors.push(`${itemPath}缺少 Evidence ID`); + if (rawEvidenceIds.length > MAX_EVIDENCE_IDS_PER_SECTION) { + errors.push(`${itemPath}最多使用 ${MAX_EVIDENCE_IDS_PER_SECTION} 个 Evidence ID`); + } + if (rawEvidenceIds.length !== evidenceIds.length) errors.push(`${itemPath}包含重复 Evidence ID`); + + const validAtoms = []; + for (const evidenceId of evidenceIds.slice(0, MAX_EVIDENCE_IDS_PER_SECTION)) { + const atom = atomById.get(evidenceId); + if (!atom) { + errors.push(`${itemPath}包含无效 Evidence ID:${evidenceId}`); + continue; + } + if (!allowed.has(evidenceId)) { + errors.push(`${itemPath}的 Evidence ID ${evidenceId} 不属于本章节允许集合`); + continue; + } + validAtoms.push(atom); + } + const textCriticalClaims = extractCriticalClaims(text); + if (textCriticalClaims.length && validAtoms.length) { + const candidateSupporters = (Array.isArray(allowedEvidenceBySection?.[key]) + ? allowedEvidenceBySection[key] + : []) + .map((id) => atomById.get(String(id || ""))) + .filter(Boolean) + .filter((atom) => { + const claims = atomCriticalClaims(atom); + return textCriticalClaims.every((claim) => claims.some((candidateClaim) => ( + sameCriticalClaim(claim, candidateClaim) + ))); + }); + const currentCitationIds = new Set(validAtoms.map((atom) => String(atom.citation_id || ""))); + for (const supporter of candidateSupporters) { + if (validAtoms.length >= MAX_EVIDENCE_IDS_PER_SECTION) break; + const citationId = String(supporter.citation_id || ""); + if (currentCitationIds.has(citationId)) continue; + validAtoms.push(supporter); + currentCitationIds.add(citationId); + const supportingCitations = validAtoms + .map((atom) => citationById.get(String(atom.citation_id || ""))) + .filter(Boolean); + if ( + distinctCitationCount(supportingCitations) >= 2 + && supportingCitations.some((citation) => Number(citation.quality_tier) === 1) + ) break; + } + } + const evidenceSpans = validAtoms.map((atom, index) => { + const citationId = String(atom.citation_id || ""); + const quote = normalizeClaimText(atom.quote); + const citation = citationById.get(citationId); + if (!citation) { + errors.push(`${itemPath}第 ${index + 1} 个 Evidence Atom 缺少对应引用`); + } else { + errors.push(...evidenceSpanErrors( + { citation_id: citationId, quote }, + citation, + `${itemPath}第 ${index + 1} 个 Evidence Atom`, + )); + } + return { + evidence_id: String(atom.id), + citation_id: citationId, + quote, + }; + }); + const citationIds = [...new Set( + evidenceSpans + .map((span) => span.citation_id) + .filter((id) => citationById.has(id)), + )]; + if (!validAtoms.length) errors.push(`${itemPath}缺少本章节允许的 Evidence Atom`); + if (!citationIds.length) errors.push(`${itemPath}缺少有效引用`); + errors.push(...groundedTextErrors({ + text, + evidenceTexts: evidenceSpans.map((span) => span.quote).filter(Boolean), + path: itemPath, + requireEventFamily: [ + "company_overview", + "business_dynamics", + "recent_public_updates", + ].includes(key) || (key === "risk_attention" && !ANALYTICAL_RISK_TERMS.test(text)), + ignoredEntityNames, + })); + const requiresSubjectBoundary = [ + "company_overview", + "business_dynamics", + "recent_public_updates", + "risk_attention", + ].includes(key) + && validAtoms.some((atom) => atom.entity_match === "alias_scoped") + && !validAtoms.some((atom) => atom.entity_match === "verified") + && !SUBJECT_BOUNDARY_TERMS.test(text); + const displayText = requiresSubjectBoundary ? `公开信息显示,${text}` : text; + const identity = displayText + .toLowerCase() + .replace(/[\s,。;:!?、,.!?;:'"“”‘’()()【】[\]《》<>-]/gu, ""); + if (identity && seen.has(identity)) errors.push(`${title}包含与其他章节重复的规划内容`); + if (identity) seen.add(identity); + sections[key] = { + id: `${key}_1`, + text: displayText, + evidence_ids: validAtoms.map((atom) => String(atom.id)), + citation_ids: citationIds, + evidence_spans: evidenceSpans, + }; + }); + + const plannedCitationIds = SECTION_DEFINITIONS.flatMap(([key]) => ( + planItemsForSection({ sections }, key).flatMap((item) => item.citation_ids || []) + )); + errors.push(...dossierSourceUsageErrors( + plannedCitationIds, + citations, + sourceUsageRequirements, + "事实规划", + )); + + return { plan: { sections }, errors }; +} + +function evidenceIdCombinations(values = [], maxItems = MAX_EVIDENCE_IDS_PER_SECTION) { + const unique = [...new Set(values.map(String).filter(Boolean))]; + const combinations = []; + const visit = (start, selected) => { + if (selected.length) combinations.push([...selected]); + if (selected.length >= maxItems) return; + for (let index = start; index < unique.length; index += 1) { + selected.push(unique[index]); + visit(index + 1, selected); + selected.pop(); + } + }; + visit(0, []); + return combinations; +} + +function sectionPlanningErrors(errors = [], title = "") { + return errors.filter((error) => String(error || "").startsWith(title)); +} + +function reselectGroundingEvidence( + parsed = {}, + evidenceAtoms = [], + citations = [], + allowedEvidenceBySection = {}, + sourceUsageRequirements = {}, + ignoredEntityNames = [], +) { + const next = JSON.parse(JSON.stringify(parsed || {})); + const knownAtomIds = new Set(evidenceAtoms.map((atom) => String(atom?.id || "")).filter(Boolean)); + let evaluated = normalizedPlan( + next, + evidenceAtoms, + citations, + allowedEvidenceBySection, + sourceUsageRequirements, + ignoredEntityNames, + ); + let changed = 0; + + for (const [key, title] of SECTION_DEFINITIONS) { + const currentSectionErrors = sectionPlanningErrors(evaluated.errors, title); + if (!currentSectionErrors.some((error) => /未出现在证据片段中|缺少可核验的证据片段/u.test(error))) { + continue; + } + const allowedIds = Array.isArray(allowedEvidenceBySection?.[key]) + ? allowedEvidenceBySection[key] + : []; + const allowedSet = new Set(allowedIds.map(String)); + const currentEvidenceIds = Array.isArray(next?.sections?.[key]?.evidence_ids) + ? next.sections[key].evidence_ids.map(String).filter(Boolean) + : []; + if ( + !currentEvidenceIds.length + || currentEvidenceIds.length > MAX_EVIDENCE_IDS_PER_SECTION + || new Set(currentEvidenceIds).size !== currentEvidenceIds.length + || currentEvidenceIds.some((id) => !knownAtomIds.has(id) || !allowedSet.has(id)) + ) { + continue; + } + const candidates = evidenceIdCombinations(allowedIds); + if (!candidates.length || !next?.sections?.[key]) continue; + + let best = null; + for (const evidenceIds of candidates) { + const candidateParsed = JSON.parse(JSON.stringify(next)); + candidateParsed.sections[key].evidence_ids = evidenceIds; + const candidateEvaluation = normalizedPlan( + candidateParsed, + evidenceAtoms, + citations, + allowedEvidenceBySection, + sourceUsageRequirements, + ignoredEntityNames, + ); + const candidateSectionErrors = sectionPlanningErrors(candidateEvaluation.errors, title); + if (candidateSectionErrors.length >= currentSectionErrors.length) continue; + const candidateScore = ( + candidateSectionErrors.length * 10_000 + + candidateEvaluation.errors.length * 100 + + evidenceIds.length + ); + if (!best || candidateScore < best.score) { + best = { + score: candidateScore, + parsed: candidateParsed, + evaluated: candidateEvaluation, + }; + } + } + if (!best) continue; + Object.assign(next, best.parsed); + evaluated = best.evaluated; + changed += 1; + } + return { parsed: next, evaluated, changed }; +} + +function reduceUnsupportedDatePrecision(parsed = {}, errors = [], evidenceAtoms = []) { + const next = JSON.parse(JSON.stringify(parsed || {})); + const atomById = new Map(evidenceAtoms.map((item) => [String(item?.id || ""), item])); + let changed = 0; + for (const error of errors) { + const match = String(error || "").match( + /^(.+?)第 (\d+) 条中的日期 (20\d{2})-(\d{2})-(\d{2}) 未出现在证据片段中$/u, + ); + if (!match) continue; + const [, title, itemNumberRaw, year, monthRaw, dayRaw] = match; + const section = SECTION_DEFINITIONS.find(([, sectionTitle]) => sectionTitle === title); + if (!section) continue; + const [key] = section; + if (Number(itemNumberRaw) !== 1) continue; + const item = next?.sections?.[key]; + if (!item) continue; + const support = (Array.isArray(item.evidence_ids) ? item.evidence_ids : []) + .map((id) => atomById.get(String(id || ""))) + .map((atom) => normalizeClaimText(atom?.quote)) + .filter(Boolean) + .join(" "); + const month = Number(monthRaw); + const day = Number(dayRaw); + const supportsMonthDay = [ + `${month}月${day}日`, + `${String(month).padStart(2, "0")}月${String(day).padStart(2, "0")}日`, + `${month}-${day}`, + `${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`, + `${month}/${day}`, + `${String(month).padStart(2, "0")}/${String(day).padStart(2, "0")}`, + ].some((value) => support.includes(value)); + if (!supportsMonthDay) continue; + const original = normalizeClaimText(item.text); + const replacement = `${month}月${day}日`; + const chineseDate = new RegExp(`${year}年0?${month}月0?${day}日`, "gu"); + const numericDate = new RegExp(`${year}[-/.]0?${month}[-/.]0?${day}`, "gu"); + const repaired = original.replace(chineseDate, replacement).replace(numericDate, replacement); + if (repaired === original) continue; + item.text = repaired; + changed += 1; + } + return { parsed: next, changed }; +} + +function generalizeUnsupportedActionAcronyms(parsed = {}, errors = []) { + const next = JSON.parse(JSON.stringify(parsed || {})); + const action = next?.sections?.recommended_actions; + if (!action || typeof action !== "object") return { parsed: next, changed: 0 }; + let changed = 0; + for (const error of errors) { + const match = String(error || "").match( + /^建议行动第 (\d+) 条中的实体 ([A-Z][A-Z0-9-]{2,}) 未出现在证据片段中$/u, + ); + if (!match || Number(match[1]) !== 1) continue; + const token = match[2].replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const original = normalizeClaimText(action.text); + const repaired = original + .replace(new RegExp(`\\b${token}\\b`, "gu"), "相关业务") + .replace(/相关业务业务/gu, "相关业务"); + if (repaired === original) continue; + action.text = repaired; + changed += 1; + } + return { parsed: next, changed }; +} + +function generalizeUnsupportedAnalyticalEvents(parsed = {}, errors = []) { + const next = JSON.parse(JSON.stringify(parsed || {})); + const allowedSections = new Set(["risk_attention", "sales_opportunity", "recommended_actions"]); + const replacements = new Map([ + ["合作", "对接"], + ["交付", "项目推进"], + ["签约", "事项确认"], + ["合同", "商务事项"], + ["部署", "应用"], + ["上线", "应用"], + ["落地", "实施"], + ]); + let changed = 0; + for (const error of errors) { + const match = String(error || "").match( + /^(.+?)第 (\d+) 条中的事件表述“([^”]+)”未出现在证据片段中$/u, + ); + if (!match || Number(match[2]) !== 1) continue; + const section = SECTION_DEFINITIONS.find(([, title]) => title === match[1]); + const replacement = replacements.get(match[3]); + if (!section || !replacement || !allowedSections.has(section[0])) continue; + const item = next?.sections?.[section[0]]; + if (!item || typeof item !== "object") continue; + const original = normalizeClaimText(item.text); + if (section[0] === "risk_attention" && !ANALYTICAL_RISK_TERMS.test(original)) continue; + const repaired = original + .split(match[3]).join(replacement) + .replace(/对接对接/gu, "对接") + .replace(/应用应用/gu, "应用") + .replace(/实施实施/gu, "实施"); + if (repaired === original) continue; + item.text = repaired; + changed += 1; + } + return { parsed: next, changed }; +} + +function boundedCompleteText(values = [], maxLength = 160) { + const candidates = values.map(normalizeClaimText).filter(Boolean); + let result = ""; + for (const candidate of candidates) { + const next = result ? `${result} ${candidate}` : candidate; + if (next.length <= maxLength) { + result = next; + continue; + } + if (result) break; + const slice = candidate.slice(0, Math.max(1, maxLength - 1)).trimEnd(); + const boundaries = ["。", "!", "?", ";", ","] + .map((mark) => slice.lastIndexOf(mark)); + const boundary = Math.max(...boundaries); + if (boundary >= 12) { + result = slice.slice(0, boundary + 1).replace(/[,;]$/u, "。"); + } else { + result = `${slice.replace(/[,;:、\s]+$/u, "")}。`; + } + break; + } + return result; +} + +function compiledDossierStructureErrors(submission = {}, { requirePlanItemIds = true } = {}) { + const errors = []; + const body = Array.isArray(submission?.body) ? submission.body : []; + if (body.length !== SECTION_DEFINITIONS.length) { + errors.push(`档案必须完整保留 ${SECTION_DEFINITIONS.length} 个固定章节`); + } + SECTION_DEFINITIONS.forEach(([, title], index) => { + const section = body[index] || {}; + const segments = Array.isArray(section.segments) ? section.segments : []; + const citationIds = Array.isArray(section.citation_ids) ? section.citation_ids : []; + if (!String(section.text || "").startsWith(`${title}:`)) { + errors.push(`${title}缺少固定章节标题`); + } + if (!segments.length) errors.push(`${title}缺少完整正文`); + if (!citationIds.length) errors.push(`${title}缺少可核验引用`); + if (/暂无|资料不足|未检索到|没有返回/u.test(String(section.text || ""))) { + errors.push(`${title}不能使用缺省占位内容代替正常正文`); + } + segments.forEach((segment, segmentIndex) => { + const path = `${title}第 ${segmentIndex + 1} 段`; + if (!normalizeClaimText(segment?.text)) errors.push(`${path}内容为空`); + if (!/[。!?]$/u.test(normalizeClaimText(segment?.text))) { + errors.push(`${path}不是完整句子`); + } + if ( + requirePlanItemIds + && (!Array.isArray(segment?.plan_item_ids) || !segment.plan_item_ids.length) + ) { + errors.push(`${path}缺少事实规划关联`); + } + if (!Array.isArray(segment?.citation_ids) || !segment.citation_ids.length) { + errors.push(`${path}缺少可核验引用`); + } + }); + }); + if (String(submission?.summary || "").length > OUTPUT_BUDGET.summary_max_chars) { + errors.push(`档案摘要超过 ${OUTPUT_BUDGET.summary_max_chars} 个字符`); + } + if (String(submission?.memory_summary || "").length > OUTPUT_BUDGET.memory_summary_max_chars) { + errors.push(`记忆摘要超过 ${OUTPUT_BUDGET.memory_summary_max_chars} 个字符`); + } + return [...new Set(errors)]; +} + +/** + * Compile the approved evidence plan into the public six-section dossier + * without another stochastic model-writing pass. Every successful dossier + * therefore keeps the fixed chapter contract and derives citations only from + * the plan items that already passed grounding checks. + */ +export function compileDossierFromPlan(plan = {}) { + const body = SECTION_DEFINITIONS.map(([key, title]) => { + const segments = planItemsForSection(plan, key).map((item) => ({ + text: normalizeClaimText(item?.text), + plan_item_ids: [String(item?.id || "")].filter(Boolean), + citation_ids: [...new Set( + (Array.isArray(item?.citation_ids) ? item.citation_ids : []).map(String).filter(Boolean), + )], + })); + return { + text: `${title}:${segments.map((segment) => segment.text).join("\n\n")}`, + citation_ids: [...new Set(segments.flatMap((segment) => segment.citation_ids))], + segments, + }; + }); + const recentAndOpportunity = [ + ...planItemsForSection(plan, "recent_public_updates"), + ...planItemsForSection(plan, "sales_opportunity"), + ].map((item) => item.text); + const memoryCandidates = [ + ...planItemsForSection(plan, "company_overview"), + ...planItemsForSection(plan, "recent_public_updates"), + ...planItemsForSection(plan, "sales_opportunity"), + ].map((item) => item.text); + const submission = { + summary: boundedCompleteText(recentAndOpportunity, OUTPUT_BUDGET.summary_max_chars), + body, + memory_summary: boundedCompleteText(memoryCandidates, OUTPUT_BUDGET.memory_summary_max_chars), + }; + return { + submission, + errors: compiledDossierStructureErrors(submission), + }; +} + +function shouldRetryCall(result) { + if (result?.ok) return false; + return Boolean(result?.error?.retryable) || [ + "incomplete_response", + "invalid_function_arguments", + "missing_function_call", + "unexpected_function_call", + ].includes(String(result?.error?.code || "")); +} + +function planningRepairDirectives(errors = []) { + const grouped = new Map(); + for (const error of errors) { + const raw = String(error || ""); + const itemMatch = raw.match(/^(.+?)第 (\d+) 条/u); + if (!itemMatch) continue; + const [, section, itemNumberRaw] = itemMatch; + const key = `${section}:${itemNumberRaw}`; + const existing = grouped.get(key) || { + section, + item_number: Number(itemNumberRaw), + unsupported_event_terms: [], + unsupported_numbers: [], + unsupported_dates: [], + unsupported_entities: [], + unsupported_organizations: [], + requires_supported_organization: false, + instruction: "删除不受支持的值或断言,或改选 quote 中逐字包含该值且直接支撑正文的 Evidence Atom;不得近似、补全、改写后保留或虚构替代值。", + }; + + const captures = [ + ["unsupported_event_terms", raw.match(/中的事件表述“([^”]+)”未出现在证据片段中$/u)?.[1]], + ["unsupported_numbers", raw.match(/中的数值 (.+?) 未出现在证据片段中$/u)?.[1]], + ["unsupported_dates", raw.match(/中的日期 (.+?) 未出现在证据片段中$/u)?.[1]], + ["unsupported_entities", raw.match(/中的实体 (.+?) 未出现在证据片段中$/u)?.[1]], + ["unsupported_organizations", raw.match(/中的机构名称“([^”]+)”未出现在证据片段中$/u)?.[1]], + ]; + let recognized = false; + for (const [field, value] of captures) { + if (!value) continue; + recognized = true; + if (!existing[field].includes(value)) existing[field].push(value); + } + if (/中的机构名称未出现在证据片段中$/u.test(raw)) { + existing.requires_supported_organization = true; + recognized = true; + } + if (!recognized) continue; + grouped.set(key, existing); + } + return [...grouped.values()].slice(0, SECTION_DEFINITIONS.length); +} + +function planningRepairSectionKeys(errors = []) { + const keys = new Set( + SECTION_DEFINITIONS + .filter(([, title]) => errors.some((error) => String(error || "").includes(title))) + .map(([key]) => key), + ); + for (const error of errors) { + const index = Number(String(error || "").match(/^body\[(\d+)\]/u)?.[1]); + if (Number.isInteger(index) && SECTION_DEFINITIONS[index]) { + keys.add(SECTION_DEFINITIONS[index][0]); + } + } + return keys.size ? [...keys] : SECTION_DEFINITIONS.map(([key]) => key); +} + +function planningFacingValidationErrors(errors = []) { + return errors.map((error) => { + const value = String(error || ""); + const segmentMatch = value.match(/^body\[(\d+)\]\.segments\[(\d+)\](.*)$/u); + if (segmentMatch) { + const section = SECTION_DEFINITIONS[Number(segmentMatch[1])]; + if (section) return `${section[1]}第 ${Number(segmentMatch[2]) + 1} 条${segmentMatch[3]}`; + } + const sectionMatch = value.match(/^body\[(\d+)\](.*)$/u); + if (sectionMatch) { + const section = SECTION_DEFINITIONS[Number(sectionMatch[1])]; + if (section) return `${section[1]}${sectionMatch[2]}`; + } + return value; + }); +} + +function planningRepairPreviousPlan(previousPlan = {}, sectionKeys = []) { + const sanitized = JSON.parse(JSON.stringify(previousPlan || {})); + if (!sanitized.sections || typeof sanitized.sections !== "object") sanitized.sections = {}; + for (const key of sectionKeys) { + sanitized.sections[key] = { + text: "", + evidence_ids: [], + }; + } + return sanitized; +} + +function planningForbiddenGroundingValues(directives = []) { + return [...new Set(directives.flatMap((directive) => [ + ...(directive.unsupported_event_terms || []), + ...(directive.unsupported_numbers || []), + ...(directive.unsupported_dates || []), + ...(directive.unsupported_entities || []), + ...(directive.unsupported_organizations || []), + ]).map(String).filter(Boolean))].slice(0, 24); +} + +function mergePlanningRepair(previousPlan = {}, repair = {}, sectionKeys = []) { + const merged = JSON.parse(JSON.stringify(previousPlan || {})); + if (!merged.sections || typeof merged.sections !== "object") merged.sections = {}; + for (const key of sectionKeys) { + const section = repair?.sections?.[key]; + if (section && typeof section === "object") merged.sections[key] = section; + } + return merged; +} + +export class DossierAgent { + constructor({ callModel, validate, maxCalls = 3 }) { + this.callModel = callModel; + this.validate = validate; + this.maxCalls = Math.max(1, Math.min(Number(maxCalls) || 3, 3)); + } + + async run(input = {}) { + const context = buildDossierAgentContext(input); + const evidenceIdsBySection = Object.fromEntries( + SECTION_DEFINITIONS.map(([key]) => [ + key, + (context.evidenceBySection[key] || []).map((atom) => String(atom.id)), + ]), + ); + const coverageErrors = SECTION_DEFINITIONS.flatMap(([key, title]) => ( + evidenceIdsBySection[key].length + ? [] + : [`${title}证据覆盖不足,缺少可用于本章节的 Evidence Atom`] + )); + if (coverageErrors.length) { + return { + ok: false, + stage: "evidence_coverage", + result: null, + context_metrics: context.metrics, + validation_errors: coverageErrors, + }; + } + let callCount = 0; + let planErrors = []; + let lastResult = null; + let planningAttempts = 0; + let previousPlanSubmission = null; + let failureStage = "planning"; + const ignoredEntityNames = [ + input.company?.name, + input.company?.legal_name, + ].filter(Boolean); + + const maxPlanningAttempts = this.maxCalls; + while (planningAttempts < maxPlanningAttempts && callCount < this.maxCalls) { + const revisingPlan = previousPlanSubmission !== null; + const repairDirectives = revisingPlan ? planningRepairDirectives(planErrors) : []; + const repairSectionKeys = revisingPlan + ? planningRepairSectionKeys(planErrors) + : SECTION_DEFINITIONS.map(([key]) => key); + const repairPreviousPlan = revisingPlan + ? planningRepairPreviousPlan(previousPlanSubmission, repairSectionKeys) + : null; + const forbiddenGroundingValues = revisingPlan + ? planningForbiddenGroundingValues(repairDirectives) + : []; + const planParameters = buildDossierPlanSchema(evidenceIdsBySection, repairSectionKeys); + planningAttempts += 1; + callCount += 1; + lastResult = await this.callModel({ + attempt: callCount, + operation: revisingPlan ? "sales_dossier_agent_replan" : "sales_dossier_agent_plan", + system: [ + ...input.instructions, + `你必须调用 ${PLAN_FUNCTION_NAME},一次提交六个章节可直接展示的正文与 Evidence ID,不能输出普通文本。`, + "固定六个章节必须全部保留且每章恰好提交 1 个完整段落,任何章节都不能删除、留空或用“暂无”“资料不足”等占位句代替。", + "每章 text 必须是可以直接进入报告正文的完整段落;可以包含 1-3 个紧密相关的完整句子,但只能围绕本章一个主要主题。建议行动章必须写清动作、对象和待核验事项。", + "采用紧凑规划:六章合计只提交 6 个段落,不得把同一事实拆成多个条目,也不得为凑长度添加弱相关内容。", + "每章只能返回 text 和 evidence_ids。不得输出 quote、citation_id、URL、引用位置、Evidence Atom 原文副本或其他字段。", + "evidence_ids 必须来自本章 allowed_evidence,且只选择直接支撑正文的最少 Atom。quote、citation_id、segment 和最终引用全部由服务端从 Atom 确定性派生。", + "allowed_evidence 的 selection_scope=section_candidate 表示证据直接匹配本章;selection_scope=cross_section_grounding 表示仅可基于已核验主体或经营事实作保守分析和核验建议,不得扩写成来源没有陈述的近期事件、风险事实、采购意向、预算或客户需求。", + "source_usage_requirements 只描述当前可用来源,不设置全局引用数量门槛。每条内容只使用直接支撑它的最少来源,把专业来源和公开来源分配到最匹配的章节,不得为覆盖数量加入弱相关引用。", + "正文中出现的完整日期、数值、机构和事件必须逐项出现在所选 Evidence Atom 的 quote 中;若 Atom 只有月日而没有年份,不得在正文补全年份。", + "事实章节必须沿用 Atom quote 中已经出现的事件关系词;不得把“入选、候选、公示、采购”改写或升级成“合作、签约、合同、交付、部署、上线、落地、发布产品”等更强关系。", + "信息量由证据决定:不得因为企业规模、章节字数或 Schema 上限而添加无来源内容,也不得用通用套话凑数量。", + "销售机会判断和建议行动只能由所选 Atom 中的事实直接推出,不能把销售建议写成客户已经存在的需求或预算。", + "同一事实只能规划到最匹配的一个章节。搜索标题、问句、关键词列表和检索状态都不是事实。", + ...(revisingPlan ? [ + "上一版规划或确定性组装结果没有通过质量门禁;previous_plan 保留其他已合格章节,但被点名章节的旧正文和证据 ID 已由服务端清空,防止复制已知错误。只重写 planning_errors 点名章节,不能新增证据外事实。", + "repair_directives 是必须逐项满足的修订合同。不得原样保留任何 unsupported_event_terms、unsupported_numbers、unsupported_dates、unsupported_entities 或 unsupported_organizations;只有重新选择的 Evidence Atom quote 确实逐字包含该值并直接支撑正文时,才允许继续使用。", + "forbidden_grounding_values 是上一版未获所选证据支持的值;本次重写不得再次输出这些值。若 allowed_evidence 中确有该值,也必须选择包含它的 Evidence ID 后才能使用。", + "本次只提交 repair_section_keys 指定的章节补丁,不得重复输出其他章节。服务端会把补丁与 previous_plan 的其余已合格章节确定性合并后重新执行完整六章门禁。", + "错误点名机构名称时,只能使用所选 Atom quote 中逐字出现的完整机构名称;找不到完整名称就删除该机构和对应断言,不得使用简称、补全名称或近义实体。", + "planning_errors 若指出高风险事实缺少双来源或关键数字未获得双来源一致支持,必须删除该高风险事实和数字,改写本章其他可由单个 Atom 直接支持的普通事实;不得只更换 Evidence ID 后保留原断言。", + "修订时仍必须保留六个完整章节;不能通过删除章节、清空章节或改写成缺省占位句来规避错误。", + ] : []), + ].join("\n"), + payload: { + task: revisingPlan ? "修订企业销售档案章节正文" : "生成企业销售档案章节正文", + company: input.company, + evidence_by_section: Object.fromEntries(repairSectionKeys.map((key) => [ + key, + { + title: SECTION_DEFINITIONS.find(([candidate]) => candidate === key)?.[1] || key, + coverage_status: context.evidenceCoverage[key]?.status || "missing", + coverage_reasons: context.evidenceCoverage[key]?.reasons || [], + allowed_evidence: (context.evidenceBySection[key] || []).map(compactEvidenceAtom), + }, + ])), + evidence_policy: context.evidencePolicy, + evidence_conflicts: context.evidenceConflicts, + source_selection_policy: context.sourceSelectionPolicy, + source_usage_requirements: context.sourceUsageRequirements, + ...(revisingPlan ? { + previous_plan: repairPreviousPlan, + planning_errors: planErrors, + repair_directives: repairDirectives, + forbidden_grounding_values: forbiddenGroundingValues, + repair_section_keys: repairSectionKeys, + } : {}), + }, + functionName: PLAN_FUNCTION_NAME, + functionDescription: "提交固定六章的可展示正文和每章使用的 Evidence Atom ID。", + parameters: planParameters, + maxTokens: 2400, + }); + if (!lastResult?.ok) { + planErrors = [`事实规划提交失败:${lastResult?.error?.code || "provider_error"}`]; + if ( + planningAttempts < maxPlanningAttempts + && callCount < this.maxCalls + && shouldRetryCall(lastResult) + ) continue; + return { + ok: false, + stage: "planning", + result: lastResult, + context_metrics: context.metrics, + validation_errors: planErrors, + }; + } + previousPlanSubmission = revisingPlan + ? mergePlanningRepair(previousPlanSubmission, lastResult.parsed, repairSectionKeys) + : lastResult.parsed; + let planned = normalizedPlan( + previousPlanSubmission, + input.evidenceAtoms, + input.citations, + evidenceIdsBySection, + context.sourceUsageRequirements, + ignoredEntityNames, + ); + const evidenceReselected = reselectGroundingEvidence( + previousPlanSubmission, + input.evidenceAtoms, + input.citations, + evidenceIdsBySection, + context.sourceUsageRequirements, + ignoredEntityNames, + ); + if (evidenceReselected.changed > 0) { + previousPlanSubmission = evidenceReselected.parsed; + planned = evidenceReselected.evaluated; + } + const dateReduced = reduceUnsupportedDatePrecision( + previousPlanSubmission, + planned.errors, + input.evidenceAtoms, + ); + if (dateReduced.changed > 0) { + previousPlanSubmission = dateReduced.parsed; + planned = normalizedPlan( + previousPlanSubmission, + input.evidenceAtoms, + input.citations, + evidenceIdsBySection, + context.sourceUsageRequirements, + ignoredEntityNames, + ); + } + const actionGeneralized = generalizeUnsupportedActionAcronyms( + previousPlanSubmission, + planned.errors, + ); + if (actionGeneralized.changed > 0) { + previousPlanSubmission = actionGeneralized.parsed; + planned = normalizedPlan( + previousPlanSubmission, + input.evidenceAtoms, + input.citations, + evidenceIdsBySection, + context.sourceUsageRequirements, + ignoredEntityNames, + ); + } + const analyticalEventGeneralized = generalizeUnsupportedAnalyticalEvents( + previousPlanSubmission, + planned.errors, + ); + if (analyticalEventGeneralized.changed > 0) { + const candidate = normalizedPlan( + analyticalEventGeneralized.parsed, + input.evidenceAtoms, + input.citations, + evidenceIdsBySection, + context.sourceUsageRequirements, + ignoredEntityNames, + ); + if (candidate.errors.length < planned.errors.length) { + previousPlanSubmission = analyticalEventGeneralized.parsed; + planned = candidate; + } + } + planErrors = planned.errors; + if (planErrors.length) { + failureStage = "planning"; + continue; + } + const compiled = compileDossierFromPlan(planned.plan); + const validated = this.validate(compiled.submission); + const validationErrors = planningFacingValidationErrors([ + ...compiled.errors, + ...(validated.errors || []), + ...compiledDossierStructureErrors({ + ...compiled.submission, + body: validated.body, + }, { requirePlanItemIds: false }), + ]); + if (!validationErrors.length) { + return { + ok: true, + stage: "complete", + result: lastResult, + submission: { + ...compiled.submission, + body: validated.body, + }, + approved_plan: planned.plan, + context_metrics: context.metrics, + validation_errors: [], + }; + } + planErrors = [...new Set(validationErrors)]; + failureStage = "validation"; + } + + return { + ok: false, + stage: failureStage, + result: lastResult, + context_metrics: context.metrics, + validation_errors: planErrors, + }; + } +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/app.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/app.js new file mode 100644 index 00000000..51189ab1 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/app.js @@ -0,0 +1,98 @@ +import http from "node:http"; +import { fileURLToPath } from "node:url"; +import { getProviderStatus } from "./config/providerConfig.js"; +import { createEnvReader } from "./config/runtimeEnv.js"; +import { createRuntimePolicy } from "./config/runtimePolicy.js"; +import { createWebSearchProvider } from "./providers/webSearchProvider.js"; +import { createModelProvider } from "./providers/modelProvider.js"; +import { createDataProProvider } from "./providers/dataProProvider.js"; +import { createOpenVikingProvider } from "./providers/openVikingProvider.js"; +import { createSupabaseDataProvider } from "./providers/supabaseDataProvider.js"; +import { SupabaseDataRepository } from "./repositories/supabaseDataRepository.js"; +import { AdminStatusService } from "./services/adminStatusService.js"; +import { ProviderService } from "./services/providerService.js"; +import { FeishuImportTaskService } from "./services/feishuImportTaskService.js"; +import { SalesService } from "./services/salesService.js"; +import { createRouter } from "./routes/index.js"; +import { createStaticFrontend } from "./frontend/staticFrontend.js"; +import { createAuthService } from "./security/authService.js"; +import { createRateLimiters } from "./security/rateLimiter.js"; + +const defaultFrontendDir = fileURLToPath(new URL("../../frontend/", import.meta.url)); + +export function createRuntimeContext(options = {}) { + const env = options.env || createEnvReader(); + const runtimePolicy = options.runtimePolicy || createRuntimePolicy({ env }); + const webSearchProvider = createWebSearchProvider({ env }); + const modelProvider = createModelProvider({ env }); + const dataProProvider = createDataProProvider({ env }); + const openVikingProvider = createOpenVikingProvider({ env }); + const supabaseDataProvider = createSupabaseDataProvider({ env }); + const providerStatus = () => getProviderStatus({ env, runtimePolicy }); + const providerService = new ProviderService({ + getProviderStatus: providerStatus, + webSearchProvider, + modelProvider, + dataProProvider, + openVikingProvider, + supabaseDataProvider, + }); + const salesRepository = supabaseDataProvider.isConfigured() + ? new SupabaseDataRepository({ + env, + supabaseDataProvider, + workspaceId: env.value("APP_WORKSPACE_ID"), + }) + : null; + const salesService = new SalesService({ env, runtimePolicy, dataProProvider, webSearchProvider, modelProvider, openVikingProvider, repository: salesRepository }); + const feishuImportTaskService = new FeishuImportTaskService({ + env, + runtimePolicy, + salesService, + }); + const adminStatusService = new AdminStatusService({ env, runtimePolicy, getProviderStatus: providerStatus }); + const authService = options.authService || createAuthService({ env, dataProvider: supabaseDataProvider }); + const rateLimiters = options.rateLimiters || createRateLimiters(env); + return { + env, + runtimePolicy, + providerStatus, + salesRepository, + providerService, + salesService, + feishuImportTaskService, + adminStatusService, + authService, + rateLimiters, + }; +} + +export function createApp(options = {}) { + const context = options.context || createRuntimeContext(options); + const { + env, + runtimePolicy, + providerService, + salesService, + feishuImportTaskService, + adminStatusService, + authService, + rateLimiters, + } = context; + const staticFrontend = createStaticFrontend({ + rootDir: env.value("FRONTEND_DIR", defaultFrontendDir), + }); + const router = createRouter(providerService, { + salesService, + feishuImportTaskService, + adminStatusService, + runtimePolicy, + staticFrontend, + authService, + rateLimiters, + env, + }); + const server = http.createServer(router); + server.runtimeContext = context; + return server; +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/backup/supabaseBackup.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/backup/supabaseBackup.js new file mode 100644 index 00000000..951afa95 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/backup/supabaseBackup.js @@ -0,0 +1,100 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { resolve, sep } from "node:path"; + +export const BACKUP_FORMAT_VERSION = 1; + +export const WORKSPACE_TABLE_SPECS = [ + { table: "app_workspace_members", order: "user_id.asc", onConflict: "workspace_id,user_id", authBound: true }, + { table: "provider_connections", order: "id.asc", onConflict: "id" }, + { table: "sales_goals", order: "id.asc", onConflict: "id" }, + { table: "sales_companies", order: "id.asc", onConflict: "id" }, + { table: "jobs", order: "id.asc", onConflict: "id" }, + { table: "provider_runs", order: "id.asc", onConflict: "id" }, + { table: "provider_run_steps", order: "id.asc", onConflict: "id" }, + { table: "sales_target_enterprises", order: "id.asc", onConflict: "id" }, + { table: "sales_company_search_results", order: "id.asc", onConflict: "id" }, + { table: "sales_progress_snapshots", order: "id.asc", onConflict: "id" }, + { table: "sales_dossier_records", order: "id.asc", onConflict: "id" }, + { table: "sales_dossier_citations", order: "id.asc", onConflict: "id" }, + { table: "sales_materials", order: "id.asc", onConflict: "id" }, + { table: "sales_openviking_refs", order: "id.asc", onConflict: "id" }, + { table: "sync_sources", order: "id.asc", onConflict: "id" }, + { table: "sync_checkpoints", order: "id.asc", onConflict: "id" }, + { table: "audit_events", order: "id.asc", onConflict: "id" }, +]; + +export const RESTORE_ORDER = [ + "provider_connections", + "sales_goals", + "sales_companies", + "jobs", + "provider_runs", + "provider_run_steps", + "sales_target_enterprises", + "sales_company_search_results", + "sales_progress_snapshots", + "sales_dossier_records", + "sales_dossier_citations", + "sales_materials", + "sales_openviking_refs", + "sync_sources", + "sync_checkpoints", + "audit_events", +]; + +const USER_REFERENCE_FIELDS = ["created_by", "updated_by", "actor_user_id"]; + +export function sha256File(filePath) { + return createHash("sha256").update(readFileSync(filePath)).digest("hex"); +} + +export function prepareRowsForRestore(table, rows, targetWorkspaceId) { + if (["app_users", "app_workspace_members"].includes(table)) return []; + + return rows.map((sourceRow) => { + const row = structuredClone(sourceRow); + if (table === "app_workspaces") { + row.id = targetWorkspaceId; + } else if (Object.hasOwn(row, "workspace_id")) { + row.workspace_id = targetWorkspaceId; + } + + for (const field of USER_REFERENCE_FIELDS) { + if (Object.hasOwn(row, field)) row[field] = null; + } + if (table === "sales_companies") delete row.normalized_name; + if (table === "provider_connections") { + row.secret_ref = null; + row.status = "needs_reconfiguration"; + } + return row; + }); +} + +export function validateBackupPackage(backupDir, manifest, data) { + if (manifest.format_version !== BACKUP_FORMAT_VERSION || data.format_version !== BACKUP_FORMAT_VERSION) { + throw new Error(`Unsupported backup format. Expected version ${BACKUP_FORMAT_VERSION}.`); + } + if (manifest.backup_id !== data.backup_id) throw new Error("Backup manifest and data identifiers do not match."); + + for (const [table, expected] of Object.entries(manifest.row_counts || {})) { + const actual = Array.isArray(data.tables?.[table]) ? data.tables[table].length : -1; + if (actual !== expected) throw new Error(`Backup row count mismatch for ${table}: expected ${expected}, got ${actual}.`); + } + + const root = resolve(backupDir); + for (const file of manifest.files || []) { + const filePath = resolve(root, file.path); + if (filePath !== root && !filePath.startsWith(`${root}${sep}`)) { + throw new Error(`Backup manifest contains an unsafe path: ${file.path}.`); + } + const actualHash = sha256File(filePath); + if (actualHash !== file.sha256) throw new Error(`Backup checksum mismatch for ${file.path}.`); + } + return true; +} + +export function tableSpec(table) { + return WORKSPACE_TABLE_SPECS.find((entry) => entry.table === table) || null; +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/config/providerConfig.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/config/providerConfig.js new file mode 100644 index 00000000..5c7cbadb --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/config/providerConfig.js @@ -0,0 +1,191 @@ +import { existsSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { createEnvReader } from "./runtimeEnv.js"; +import { createRuntimePolicy, publicRuntimePolicy } from "./runtimePolicy.js"; + +const DEFAULTS = { + DATAPRO_MCP_URL: "https://datapro.hqd.cn-beijing.volces.com/mcp", + WEB_SEARCH_BASE_URL: "https://open.feedcoopapi.com/search_api/web_search", + OPENVIKING_AGENT_ID: "default", + SUPABASE_REGION: "cn-beijing", +}; + +function unique(values) { + return [...new Set(values)]; +} + +function commandExists(command) { + const value = String(command || "").trim(); + if (!value) return false; + if (value.includes("/")) return existsSync(value); + return String(process.env.PATH || "") + .split(delimiter) + .filter(Boolean) + .some((directory) => existsSync(join(directory, value))); +} + +function configStatus(env, requiredEnv, acceptedEnv = requiredEnv) { + if (!requiredEnv.length) return "ready"; + return requiredEnv.every((name) => env.hasAny(Array.isArray(name) ? name : [name])) ? "configured" : "missing_config"; +} + +function missingGroups(env, requiredEnv) { + return requiredEnv + .filter((name) => !env.hasAny(Array.isArray(name) ? name : [name])) + .map((name) => (Array.isArray(name) ? name.join(" or ") : name)); +} + +function provider(id, label, options) { + const { + status, + mode, + role, + required_env = [], + optional_env = [], + configured_from = [], + missing = [], + notes = [], + safe_config = {}, + } = options; + return { + id, + label, + status, + mode, + role, + required_env, + optional_env, + configured_from: unique(configured_from), + missing, + notes, + safe_config, + }; +} + +export function getProviderStatus(options = {}) { + const env = options.env || createEnvReader(); + const runtimePolicy = options.runtimePolicy || createRuntimePolicy({ env }); + const repositoryMode = env.value("REPOSITORY_MODE") || "supabase"; + + const webSearchRequired = [["WEB_SEARCH_API_KEY", "AGENT_PLAN_API_KEY", "ASK_ECHO_SEARCH_INFINITY_API_KEY"]]; + const dataProRequired = [["DATAPRO_API_KEY", "AGENT_PLAN_API_KEY"]]; + const supabaseRequired = ["SUPABASE_API_URL", "SUPABASE_SERVICE_ROLE_KEY", "APP_WORKSPACE_ID"]; + const supabaseAdminEnv = ["VOLCENGINE_ACCESS_KEY", "VOLCENGINE_SECRET_KEY", "SUPABASE_WORKSPACE_ID", "SUPABASE_BRANCH_ID", "SUPABASE_CLI_BIN"]; + const openVikingCliConfigPath = env.value("OPENVIKING_CLI_CONFIG") + || (process.env.HOME ? join(process.env.HOME, ".openviking", "ovcli.conf") : ""); + const openVikingConfigExists = Boolean(openVikingCliConfigPath && existsSync(openVikingCliConfigPath)); + const openVikingCliPath = env.value("OPENVIKING_CLI") || (process.env.HOME ? join(process.env.HOME, "bin", "ov") : "ov"); + const openVikingCliExists = commandExists(openVikingCliPath); + const openVikingHttpConfigured = env.hasAny(["OPENVIKING_API_KEY", "OPENVIKING_BEARER_TOKEN"]) + && env.hasAny(["OPENVIKING_BASE_URL"]); + const openVikingConfigured = openVikingHttpConfigured || openVikingConfigExists || openVikingCliExists; + const modelRequired = [["ARK_API_KEY", "VOLCENGINE_ARK_API_KEY", "MODEL_API_KEY", "AGENT_PLAN_API_KEY"]]; + + const providers = [ + provider("web_search", "联网搜索 Provider", { + status: configStatus(env, webSearchRequired), + mode: "real", + role: "公开来源发现:新闻、官网、文档、价格页、发布记录", + required_env: ["AGENT_PLAN_API_KEY(WEB_SEARCH_API_KEY 可作为高级覆盖)"], + optional_env: ["WEB_SEARCH_BASE_URL", "WEB_SEARCH_TRAFFIC_TAG", "WEB_SEARCH_MAX_COUNT", "WEB_SEARCH_RUN_ENABLED", "WEB_SEARCH_TIMEOUT_MS", "WEB_SEARCH_MAX_RETRIES"], + configured_from: env.sources(["WEB_SEARCH_API_KEY", "AGENT_PLAN_API_KEY", "ASK_ECHO_SEARCH_INFINITY_API_KEY", "WEB_SEARCH_BASE_URL", "WEB_SEARCH_TRAFFIC_TAG", "WEB_SEARCH_MAX_COUNT", "WEB_SEARCH_RUN_ENABLED", "WEB_SEARCH_TIMEOUT_MS", "WEB_SEARCH_MAX_RETRIES"]), + missing: missingGroups(env, webSearchRequired), + notes: ["状态接口只检查配置,不发起搜索请求。", "主流程调用由 WEB_SEARCH_RUN_ENABLED 控制,避免日常测试消耗额度。"], + safe_config: { + base_url: env.value("WEB_SEARCH_BASE_URL") || DEFAULTS.WEB_SEARCH_BASE_URL, + traffic_tag: env.value("WEB_SEARCH_TRAFFIC_TAG", "skill_web_search_common"), + max_count: env.number("WEB_SEARCH_MAX_COUNT", 3), + run_enabled: ["1", "true", "yes", "on"].includes(String(env.value("WEB_SEARCH_RUN_ENABLED", "false")).toLowerCase()), + timeout_ms: env.number("WEB_SEARCH_TIMEOUT_MS", 20000), + max_retries: env.number("WEB_SEARCH_MAX_RETRIES", 1), + }, + }), + provider("datapro", "DataPro Provider", { + status: configStatus(env, dataProRequired), + mode: "real", + role: "企业主体、工商事实、风险和知识产权数据核验", + required_env: ["DATAPRO_API_KEY or AGENT_PLAN_API_KEY"], + optional_env: ["DATAPRO_MCP_URL", "DATAPRO_RUN_ENABLED", "DATAPRO_MAX_SOURCES", "DATAPRO_TIMEOUT_MS", "DATAPRO_MAX_RETRIES"], + configured_from: env.sources(["DATAPRO_API_KEY", "AGENT_PLAN_API_KEY", "DATAPRO_MCP_URL", "DATAPRO_RUN_ENABLED", "DATAPRO_MAX_SOURCES", "DATAPRO_TIMEOUT_MS", "DATAPRO_MAX_RETRIES"]), + missing: missingGroups(env, dataProRequired), + notes: ["状态接口只检查配置,不调用 dataPro_search。", "主流程调用由 DATAPRO_RUN_ENABLED 控制,真实查询会消耗 AFP。"], + safe_config: { + mcp_url: env.value("DATAPRO_MCP_URL") || DEFAULTS.DATAPRO_MCP_URL, + run_enabled: ["1", "true", "yes", "on"].includes(String(env.value("DATAPRO_RUN_ENABLED", "false")).toLowerCase()), + max_sources: env.number("DATAPRO_MAX_SOURCES", 4), + timeout_ms: env.number("DATAPRO_TIMEOUT_MS", 45000), + max_retries: env.number("DATAPRO_MAX_RETRIES", 1), + }, + }), + provider("supabase", "Supabase Repository / Provider", { + status: configStatus(env, supabaseRequired), + mode: "real", + role: "业务状态持久化、SQL、Storage、Edge Functions 管理", + required_env: supabaseRequired, + optional_env: [...supabaseAdminEnv, "SUPABASE_READ_ONLY", "SUPABASE_RUN_ENABLED", "SUPABASE_TIMEOUT_MS", "SUPABASE_DATA_API_TIMEOUT_MS", "REPOSITORY_MODE"], + configured_from: env.sources([...supabaseRequired, ...supabaseAdminEnv, "SUPABASE_READ_ONLY", "SUPABASE_RUN_ENABLED", "SUPABASE_TIMEOUT_MS", "SUPABASE_DATA_API_TIMEOUT_MS", "REPOSITORY_MODE"]), + missing: missingGroups(env, supabaseRequired), + notes: ["状态接口不返回 Supabase API keys。", "销售工作台运行时使用 Data API;CLI 凭据仅用于迁移、备份和管理。"], + safe_config: { + workspace_id: env.value("SUPABASE_WORKSPACE_ID") || null, + branch_id: env.value("SUPABASE_BRANCH_ID") || null, + read_only: env.value("SUPABASE_READ_ONLY") || null, + region: env.value("VOLCENGINE_REGION") || DEFAULTS.SUPABASE_REGION, + run_enabled: ["1", "true", "yes", "on"].includes(String(env.value("SUPABASE_RUN_ENABLED", "false")).toLowerCase()), + app_workspace_id: env.value("APP_WORKSPACE_ID") || null, + cli_bin: env.value("SUPABASE_CLI_BIN") || "byted-supabase-cli", + data_api_timeout_ms: env.number("SUPABASE_DATA_API_TIMEOUT_MS", 15000), + }, + }), + provider("openviking", "OpenViking Provider", { + status: openVikingConfigured ? "configured" : "missing_config", + mode: "real", + role: "飞书资料正文、资料问答 Session、长期记忆与企业内资料召回", + required_env: ["OpenViking CLI 配置(默认 ~/.openviking/ovcli.conf),或 OPENVIKING_BASE_URL + OPENVIKING_API_KEY"], + optional_env: ["OPENVIKING_CLI", "OPENVIKING_CLI_CONFIG", "OPENVIKING_AGENT_ID", "OPENVIKING_RUN_ENABLED", "OPENVIKING_SALES_ROOT_URI", "OPENVIKING_FIND_LIMIT", "OPENVIKING_TIMEOUT_MS"], + configured_from: openVikingConfigured ? unique([...env.sources(["OPENVIKING_API_KEY", "OPENVIKING_BEARER_TOKEN", "OPENVIKING_BASE_URL", "OPENVIKING_CLI", "OPENVIKING_CLI_CONFIG", "OPENVIKING_AGENT_ID", "OPENVIKING_RUN_ENABLED", "OPENVIKING_SALES_ROOT_URI", "OPENVIKING_FIND_LIMIT", "OPENVIKING_TIMEOUT_MS"]), openVikingConfigExists ? "local_openviking_cli_config" : null, openVikingCliExists ? "local_openviking_cli" : null].filter(Boolean)) : [], + missing: openVikingConfigured ? [] : ["OpenViking CLI 配置,或 OPENVIKING_BASE_URL + OPENVIKING_API_KEY"], + notes: ["Agent Plan 套餐控制 AFP 抵扣,Agent 记忆(OpenViking)数据面仍使用内部访问凭证认证。", "状态接口不写入资料或会话。", "OpenViking 不承担企业、档案、任务和权限数据库角色。", "飞书正文与资料问答记忆写入由 OPENVIKING_RUN_ENABLED 控制。"], + safe_config: { + cli_path: openVikingCliPath, + agent_id: env.value("OPENVIKING_AGENT_ID") || DEFAULTS.OPENVIKING_AGENT_ID, + run_enabled: ["1", "true", "yes", "on"].includes(String(env.value("OPENVIKING_RUN_ENABLED", "false")).toLowerCase()), + sales_root_uri: env.value("OPENVIKING_SALES_ROOT_URI") || "viking://resources/sales-workbench", + find_limit: env.number("OPENVIKING_FIND_LIMIT", 3), + timeout_ms: env.number("OPENVIKING_TIMEOUT_MS", 120000), + }, + }), + provider("model", "Model Provider", { + status: configStatus(env, modelRequired), + mode: "real", + role: "基于 sources / facts 生成结构化变化卡、报告和问答", + required_env: ["AGENT_PLAN_API_KEY(MODEL_API_KEY 可作为高级覆盖)"], + optional_env: ["MODEL_NAME", "MODEL_BASE_URL", "MODEL_RUN_ENABLED", "MODEL_MAX_CARDS", "MODEL_MAX_TOKENS", "MODEL_TIMEOUT_MS"], + configured_from: env.sources(["ARK_API_KEY", "VOLCENGINE_ARK_API_KEY", "MODEL_API_KEY", "AGENT_PLAN_API_KEY", "MODEL_NAME", "MODEL_BASE_URL", "MODEL_RUN_ENABLED", "MODEL_MAX_CARDS", "MODEL_MAX_TOKENS", "MODEL_TIMEOUT_MS"]), + missing: missingGroups(env, modelRequired), + notes: ["模型输出必须经过后端 JSON 校验。", "主流程调用由 MODEL_RUN_ENABLED 控制,避免日常测试消耗额度。"], + safe_config: { + model_name: env.value("MODEL_NAME") || null, + base_url: env.value("MODEL_BASE_URL") || null, + run_enabled: ["1", "true", "yes", "on"].includes(String(env.value("MODEL_RUN_ENABLED", "false")).toLowerCase()), + max_cards: env.number("MODEL_MAX_CARDS", 2), + timeout_ms: env.number("MODEL_TIMEOUT_MS", 90000), + }, + }), + ]; + + return { + generated_at: new Date().toISOString(), + runtime: publicRuntimePolicy(runtimePolicy), + environment: { + local_env_loaded: env.hasLocalEnv, + local_env_path: "backend/.env.local", + }, + repository: { + active: repositoryMode, + status: configStatus(env, supabaseRequired), + notes: ["使用 Supabase Data API Repository,业务状态按 Workspace 读取并写回。"], + }, + providers, + }; +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/config/runtimeEnv.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/config/runtimeEnv.js new file mode 100644 index 00000000..99ac4d4c --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/config/runtimeEnv.js @@ -0,0 +1,55 @@ +import { existsSync, readFileSync } from "node:fs"; + +export const localEnvUrl = new URL("../../.env.local", import.meta.url); + +function parseEnvValue(value) { + const trimmed = value.trim(); + if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +export function loadLocalEnv() { + if (!existsSync(localEnvUrl)) return {}; + const content = readFileSync(localEnvUrl, "utf8"); + const env = {}; + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const separatorIndex = line.indexOf("="); + if (separatorIndex < 1) continue; + const key = line.slice(0, separatorIndex).trim(); + const value = parseEnvValue(line.slice(separatorIndex + 1)); + env[key] = value; + } + return env; +} + +export function createEnvReader(localEnv = loadLocalEnv()) { + return { + hasLocalEnv: existsSync(localEnvUrl), + value(name, fallback = "") { + return process.env[name] || localEnv[name] || fallback; + }, + number(name, fallback) { + const value = Number(this.value(name)); + return Number.isFinite(value) ? value : fallback; + }, + source(name) { + if (process.env[name]) return "process.env"; + if (localEnv[name]) return "backend/.env.local"; + return null; + }, + sources(names) { + return names.map((name) => this.source(name)).filter(Boolean); + }, + hasAny(names) { + return names.some((name) => Boolean(this.value(name))); + }, + hasAll(names) { + return names.every((name) => Boolean(this.value(name))); + }, + }; +} + diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/config/runtimePolicy.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/config/runtimePolicy.js new file mode 100644 index 00000000..76e11acc --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/config/runtimePolicy.js @@ -0,0 +1,142 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { createEnvReader } from "./runtimeEnv.js"; + +const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); + +function isEnabled(value) { + return TRUE_VALUES.has(String(value || "").trim().toLowerCase()); +} + +function positiveInteger(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function validTimeZone(value) { + try { + new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date()); + return true; + } catch { + return false; + } +} + +function parseAbsoluteUrl(value) { + try { + return new URL(String(value || "").trim()); + } catch { + return null; + } +} + +function parseOrigins(value) { + return String(value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + .map(parseAbsoluteUrl); +} + +export function createRuntimePolicy(options = {}) { + const env = options.env || createEnvReader(); + const repositoryMode = String(env.value("REPOSITORY_MODE", "supabase")).trim().toLowerCase(); + const providerRuns = { + datapro: isEnabled(env.value("DATAPRO_RUN_ENABLED", "false")), + web_search: isEnabled(env.value("WEB_SEARCH_RUN_ENABLED", "false")), + model: isEnabled(env.value("MODEL_RUN_ENABLED", "false")), + openviking: isEnabled(env.value("OPENVIKING_RUN_ENABLED", "false")), + }; + const blockers = []; + const httpAuthEnabled = isEnabled(env.value("HTTP_AUTH_ENABLED", "true")); + const paidWorkflowLimits = Object.freeze({ + max_concurrent: positiveInteger(env.value("PAID_WORKFLOW_MAX_CONCURRENCY", "2"), 0), + daily_limit: positiveInteger(env.value("PAID_WORKFLOW_DAILY_LIMIT", "100"), 0), + timezone: String(env.value("PAID_WORKFLOW_BUDGET_TIMEZONE", "Asia/Shanghai") || "").trim(), + stale_after_seconds: positiveInteger(env.value("PAID_WORKFLOW_STALE_AFTER_SECONDS", "1800"), 0), + }); + const asyncJobsEnabled = isEnabled(env.value("ASYNC_JOBS_ENABLED", "true")); + const providerCircuitBreaker = Object.freeze({ + enabled: isEnabled(env.value("PROVIDER_CIRCUIT_BREAKER_ENABLED", "true")), + failure_threshold: positiveInteger(env.value("PROVIDER_CIRCUIT_BREAKER_FAILURE_THRESHOLD", "5"), 0), + cooldown_seconds: positiveInteger(env.value("PROVIDER_CIRCUIT_BREAKER_COOLDOWN_SECONDS", "60"), 0), + }); + + if (repositoryMode !== "supabase") blockers.push("REPOSITORY_MODE must be supabase"); + if (isEnabled(env.value("SUPABASE_READ_ONLY", "false"))) blockers.push("SUPABASE_READ_ONLY must be false"); + if (!env.hasAll(["SUPABASE_API_URL", "SUPABASE_SERVICE_ROLE_KEY", "APP_WORKSPACE_ID"])) { + blockers.push("Supabase Data API configuration is incomplete"); + } + if (!httpAuthEnabled) blockers.push("HTTP_AUTH_ENABLED must be true"); + if (!paidWorkflowLimits.max_concurrent) blockers.push("PAID_WORKFLOW_MAX_CONCURRENCY must be greater than 0"); + if (!paidWorkflowLimits.daily_limit) blockers.push("PAID_WORKFLOW_DAILY_LIMIT must be greater than 0"); + if (!paidWorkflowLimits.stale_after_seconds) blockers.push("PAID_WORKFLOW_STALE_AFTER_SECONDS must be greater than 0"); + if (!asyncJobsEnabled) blockers.push("ASYNC_JOBS_ENABLED must be true"); + if (!providerCircuitBreaker.enabled) blockers.push("PROVIDER_CIRCUIT_BREAKER_ENABLED must be true"); + if (!providerCircuitBreaker.failure_threshold) { + blockers.push("PROVIDER_CIRCUIT_BREAKER_FAILURE_THRESHOLD must be greater than 0"); + } + if (!providerCircuitBreaker.cooldown_seconds) { + blockers.push("PROVIDER_CIRCUIT_BREAKER_COOLDOWN_SECONDS must be greater than 0"); + } + if (positiveInteger(env.value("JOB_WORKER_LEASE_SECONDS", "600"), 0) < 60) { + blockers.push("JOB_WORKER_LEASE_SECONDS must be at least 60"); + } + if (!validTimeZone(paidWorkflowLimits.timezone)) blockers.push("PAID_WORKFLOW_BUDGET_TIMEZONE is invalid"); + + const host = String(env.value("HOST", "127.0.0.1")).trim().toLowerCase(); + const loopbackOnly = ["127.0.0.1", "::1", "localhost"].includes(host); + const trustProxy = isEnabled(env.value("TRUST_PROXY", "false")); + const secureCookie = isEnabled(env.value("AUTH_COOKIE_SECURE", "false")); + if ((!loopbackOnly || trustProxy) && !secureCookie) { + blockers.push("public or proxied deployments require AUTH_COOKIE_SECURE=true"); + } + if (trustProxy) { + const allowedOrigins = parseOrigins(env.value("ALLOWED_ORIGINS", "")); + if (!allowedOrigins.length || allowedOrigins.some((origin) => !origin || origin.protocol !== "https:")) { + blockers.push("proxied deployments require explicit HTTPS ALLOWED_ORIGINS"); + } + } + + if (!env.hasAny(["DATAPRO_API_KEY", "AGENT_PLAN_API_KEY"]) || !providerRuns.datapro) { + blockers.push("an enabled DataPro provider is required"); + } + if (!env.hasAny(["WEB_SEARCH_API_KEY", "AGENT_PLAN_API_KEY", "ASK_ECHO_SEARCH_INFINITY_API_KEY"]) || !providerRuns.web_search) { + blockers.push("an enabled web search provider is required"); + } + if (!env.hasAny(["MODEL_API_KEY", "AGENT_PLAN_API_KEY", "ARK_API_KEY", "VOLCENGINE_ARK_API_KEY"]) || !providerRuns.model) { + blockers.push("an enabled model provider is required"); + } + const openVikingCli = env.value("OPENVIKING_CLI") || (process.env.HOME ? join(process.env.HOME, "bin", "ov") : ""); + const openVikingCliConfig = env.value("OPENVIKING_CLI_CONFIG") + || (process.env.HOME ? join(process.env.HOME, ".openviking", "ovcli.conf") : ""); + const openVikingConfigured = ( + env.hasAny(["OPENVIKING_API_KEY", "OPENVIKING_BEARER_TOKEN"]) + && env.hasAny(["OPENVIKING_BASE_URL"]) + ) + || Boolean(openVikingCliConfig && existsSync(openVikingCliConfig)) + || Boolean(env.value("OPENVIKING_CLI") && openVikingCli && existsSync(openVikingCli)); + if (!openVikingConfigured || !providerRuns.openviking) { + blockers.push("an enabled OpenViking provider is required"); + } + return Object.freeze({ + fail_closed: true, + repository_mode: repositoryMode, + provider_runs: Object.freeze(providerRuns), + paid_workflow_limits: paidWorkflowLimits, + provider_circuit_breaker: providerCircuitBreaker, + async_jobs_enabled: asyncJobsEnabled, + http_auth_enabled: httpAuthEnabled, + blockers: Object.freeze(blockers), + ready: blockers.length === 0, + }); +} + +export function publicRuntimePolicy(policy) { + return { + ready: policy.ready, + fail_closed: true, + repository_mode: policy.repository_mode, + blockers: [...policy.blockers], + }; +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/evidence/claimGrounding.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/evidence/claimGrounding.js new file mode 100644 index 00000000..70e47f62 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/evidence/claimGrounding.js @@ -0,0 +1,270 @@ +const EVENT_FAMILIES = Object.freeze([ + ["procurement", /中标|招标|采购|成交|候选|公示|入选|供应商/u], + ["cooperation", /合作|签署|协议|合同|战略伙伴/u], + ["delivery", /部署|上线|交付|投产|量产|扩产|建设|落地/u], + ["product", /发布|推出|升级|更新|研发|产品|解决方案/u], + ["finance", /融资|投资|回购|营收|收入|利润|估值/u], + ["risk", /处罚|诉讼|失信|异常|召回|事故|整改|监管/u], +]); + +const COMMON_UPPERCASE_TOKENS = new Set([ + "AI", + "API", + "B2B", + "CRM", + "ERP", + "HTTP", + "HTTPS", + "IT", + "RAG", + "SaaS", + "SQL", +]); + +function compact(value, maxLength = 4000) { + return String(value || "") + .normalize("NFKC") + .replace(/[\u0000-\u001F\u007F]/gu, " ") + .replace(/\s+/gu, " ") + .trim() + .slice(0, maxLength); +} + +function comparable(value) { + return compact(value, 8000) + .toLowerCase() + .replace(/\s+/gu, ""); +} + +function validCalendarDate(year, month, day) { + const timestamp = Date.UTC(Number(year), Number(month) - 1, Number(day)); + if (!Number.isFinite(timestamp)) return ""; + const date = new Date(timestamp); + if ( + date.getUTCFullYear() !== Number(year) + || date.getUTCMonth() + 1 !== Number(month) + || date.getUTCDate() !== Number(day) + ) { + return ""; + } + return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +export function extractGroundingDates(value) { + const input = compact(value, 12000); + const dates = new Set(); + const patterns = [ + /(? date.split("-").map((part) => String(Number(part)))), + ); + const numbers = new Set(); + const matches = input.matchAll(/(?= 3 || hasMaterialUnit) { + if (!dates.has(String(Number(digits)))) numbers.add(normalized); + } + } + return [...numbers]; +} + +function extractUppercaseAnchors(value) { + return [...new Set( + compact(value, 12000) + .match(/\b[A-Z][A-Z0-9-]{2,}\b/gu) || [], + )].filter((token) => !COMMON_UPPERCASE_TOKENS.has(token)); +} + +function extractOrganizationAnchorGroups(value) { + const input = compact(value, 12000); + const groups = []; + const suffixPattern = /(银行|大学|学院|研究院|委员会|法院|交易所|政府|集团)/gu; + for (const match of input.matchAll(suffixPattern)) { + const suffix = match[0]; + const start = match.index || 0; + const prefix = input + .slice(Math.max(0, start - 10), start) + .match(/[\p{Script=Han}]{2,10}$/u)?.[0] || ""; + if (prefix.length < 2) continue; + if ( + /(?:可能|或将|预计|将|会|易|可|仍)?(?:受|受到|影响|面向|针对|涉及|属于|依赖于|服务于|联系|核验|确认|关注|评估|建议|跟进|通过|基于|来自|进入|覆盖|支持|帮助|推动)$/u.test(prefix) + || /^(?:相关|所属|目标|客户|企业|公司|业务|产业|上述)$/u.test(prefix) + ) { + continue; + } + const candidates = []; + for (let length = 2; length <= Math.min(prefix.length, 8); length += 1) { + candidates.push(`${prefix.slice(-length)}${suffix}`); + } + groups.push([...new Set(candidates)]); + } + return groups; +} + +function eventFamilies(value) { + const input = compact(value, 12000); + return EVENT_FAMILIES + .filter(([, pattern]) => pattern.test(input)) + .map(([name]) => name); +} + +export function extractGroundingOrganizations(value) { + return [...new Set( + extractOrganizationAnchorGroups(value) + .map((candidates) => candidates.at(-1)) + .filter(Boolean), + )]; +} + +export function extractGroundingEventFamilies(value) { + return eventFamilies(value); +} + +function eventFamilyTerm(value, family) { + const input = compact(value, 12000); + const entry = EVENT_FAMILIES.find(([name]) => name === family); + return entry ? input.match(entry[1])?.[0] || "" : ""; +} + +function withoutIgnoredEntityNames(value, entityNames = []) { + let output = compact(value, 12000); + const names = [...new Set(entityNames.map((item) => compact(item, 200)).filter(Boolean))] + .sort((left, right) => right.length - left.length); + for (const name of names) output = output.split(name).join(" "); + return output; +} + +function appearsInSupport(anchor, supportTexts) { + const normalized = comparable(anchor); + return Boolean(normalized && supportTexts.some((value) => comparable(value).includes(normalized))); +} + +function numericAppearsInSupport(anchor, supportTexts) { + const normalized = comparable(anchor).replace(/[,,]/gu, ""); + return Boolean(normalized && supportTexts.some((value) => ( + comparable(value).replace(/[,,]/gu, "").includes(normalized) + ))); +} + +/** + * Deterministic claim-level guardrail. + * + * It does not pretend to solve full natural-language entailment. Instead it + * blocks the highest-risk forms of unsupported expansion that can be checked + * without another model call: new dates, material numbers, named uppercase + * entities, organization names and event-family changes. + */ +export function groundedTextErrors({ + text, + evidenceTexts = [], + path = "内容", + requireEventFamily = false, + checkOrganizations = true, + ignoredEntityNames = [], +} = {}) { + const content = compact(text, 12000); + const support = evidenceTexts.map((item) => compact(item, 12000)).filter(Boolean); + const errors = []; + if (!content || !support.length) return [`${path}缺少可核验的证据片段`]; + + for (const date of extractGroundingDates(content)) { + if (!appearsInSupport(date, support)) { + const chineseDate = date.replace(/^(\d{4})-(\d{2})-(\d{2})$/u, (_, year, month, day) => ( + `${year}年${Number(month)}月${Number(day)}日` + )); + if (!appearsInSupport(chineseDate, support)) errors.push(`${path}中的日期 ${date} 未出现在证据片段中`); + } + } + for (const number of extractGroundingNumbers(content)) { + if (!numericAppearsInSupport(number, support)) { + errors.push(`${path}中的数值 ${number} 未出现在证据片段中`); + } + } + for (const token of extractUppercaseAnchors(content)) { + if (!appearsInSupport(token, support)) errors.push(`${path}中的实体 ${token} 未出现在证据片段中`); + } + if (checkOrganizations) { + for (const candidates of extractOrganizationAnchorGroups(content)) { + if (!candidates.some((candidate) => appearsInSupport(candidate, support))) { + const label = candidates[0] || ""; + errors.push(label + ? `${path}中的机构名称“${label}”未出现在证据片段中` + : `${path}中的机构名称未出现在证据片段中`); + } + } + } + if (requireEventFamily) { + const eventContent = withoutIgnoredEntityNames(content, ignoredEntityNames); + const eventSupport = support.map((item) => withoutIgnoredEntityNames(item, ignoredEntityNames)); + const requiredFamilies = eventFamilies(eventContent); + const supportedFamilies = new Set(eventSupport.flatMap(eventFamilies)); + for (const family of requiredFamilies) { + if (!supportedFamilies.has(family)) { + const term = eventFamilyTerm(eventContent, family); + errors.push(term + ? `${path}中的事件表述“${term}”未出现在证据片段中` + : `${path}包含证据片段未支持的事件类型`); + } + } + } + return [...new Set(errors)]; +} + +export function evidenceSpanErrors(span = {}, citation = {}, path = "证据片段") { + const quote = compact(span.quote, 500); + const summary = compact(citation.summary || citation.excerpt, 4000); + if (!quote) return [`${path}缺少原文摘录`]; + if (quote.length < 8) return [`${path}的原文摘录少于 8 个字符`]; + if (!summary || !comparable(summary).includes(comparable(quote))) { + return [`${path}不是对应来源摘要中的连续原文`]; + } + return []; +} + +function validIso(value) { + const raw = compact(value, 100); + if (!raw) return ""; + const timestamp = new Date(raw).getTime(); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : ""; +} + +export function deriveEvidenceDataAsOf(evidence = [], generatedAt = new Date().toISOString()) { + const generatedTimestamp = new Date(generatedAt).getTime(); + const upperBound = Number.isFinite(generatedTimestamp) + ? generatedTimestamp + 24 * 60 * 60 * 1000 + : Number.POSITIVE_INFINITY; + const candidates = []; + for (const item of evidence || []) { + candidates.push(validIso(item?.published_at), validIso(item?.source_updated_at)); + if (/^(?:public|联网搜索)$/u.test(String(item?.source_kind || ""))) { + for (const date of extractGroundingDates(item?.summary || item?.excerpt || "")) { + candidates.push(`${date}T00:00:00.000Z`); + } + } + } + return candidates + .filter(Boolean) + .filter((value) => new Date(value).getTime() <= upperBound) + .sort() + .at(-1) || null; +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/evidence/dossierEvidenceCompiler.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/evidence/dossierEvidenceCompiler.js new file mode 100644 index 00000000..220d013a --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/evidence/dossierEvidenceCompiler.js @@ -0,0 +1,774 @@ +import { createHash } from "node:crypto"; + +import { + extractGroundingDates, + extractGroundingEventFamilies, + extractGroundingNumbers, + extractGroundingOrganizations, +} from "./claimGrounding.js"; + +const SECTION_KEYS = Object.freeze([ + "company_overview", + "business_dynamics", + "recent_public_updates", + "risk_attention", + "sales_opportunity", + "recommended_actions", +]); + +const ENTITY_MATCHES = new Set([ + "verified", + "alias_scoped", + "query_bound", + "company_scoped", + "unverified", +]); + +const MAX_ATOM_CHARS = 360; +const MIN_ATOM_CHARS = 8; + +const NAVIGATION_OR_STATUS_PATTERNS = Object.freeze([ + /^(?:首页|当前位置|导航|菜单|产品中心)(?:\s*[>›»/|~-]\s*.*)+$/iu, + /^(?:正在|开始)?搜索(?:中|相关结果)?|请稍候|加载更多|暂无结果|点击查看|查看更多|返回首页/iu, + /(?:人机验证|安全验证|访问验证|验证码页面|页面不存在|内容已下线)/iu, +]); + +const SENSITIVE_CONTENT_PATTERNS = Object.freeze([ + /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/iu, + /\b(?:api[_ -]?key|service[_ -]?role(?:[_ -]?key)?|access[_ -]?token|refresh[_ -]?token|password|cookie|authorization|client[_ -]?secret)\s*[:=]\s*[^\s,。;;]{8,}/iu, + /[A-Za-z]:\\Users\\[^\s"',。;;)]+/iu, + /\bviking:\/\/[^\s"',。;;)]+/iu, +]); + +const PREDICATE_PATTERN = /公司名称|统一社会信用代码|法定代表人|注册资本|成立日期|经营范围|主营|是|为|于|在|由|有|提供|负责|发布|推出|完成|启动|计划|确认|记录|入选|中标|招标|采购|成交|候选|公示|供应|合作|签署|协议|合同|交付|部署|上线|投产|量产|扩产|建设|落地|融资|投资|回购|营收|收入|利润|估值|处罚|诉讼|失信|异常|召回|事故|整改|监管|受到|存在|显示|披露|增长|下降|达到|进入|核验|说明|通过/iu; +const ENGLISH_PREDICATE_PATTERN = /\b(?:is|are|was|were|has|have|will|remains|released|announced|provides|reported)\b/iu; + +const PROTECTED_VALUE_PATTERNS = Object.freeze([ + /20\d{2}年\d{1,2}月\d{1,2}日/gu, + /\b20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}\b/gu, + /(?:人民币|美元)?\s*\d[\d,.]*(?:\.\d+)?\s*(?:%|%|亿元|万元|元|亿|万|MW|MWh|GWh|GW|kW|kWh|套|项|个|条|份|家|台|辆|人|股|吨|亩|平方米|座|次)/giu, + /[0-9A-Z]{18}/gu, + /[\p{Script=Han}A-Za-z0-9()()·]{2,40}(?:股份有限公司|有限责任公司|集团有限公司|有限公司)/gu, +]); + +function digest(value) { + return createHash("sha256").update(String(value || ""), "utf8").digest("hex"); +} + +function stringValue(value, maxLength = 12000) { + return String(value ?? "") + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/gu, " ") + .slice(0, maxLength); +} + +function normalizedText(value, maxLength = 12000) { + return stringValue(value, maxLength * 2) + .normalize("NFKC") + .replace(/\s+/gu, " ") + .trim() + .slice(0, maxLength); +} + +function uniqueSorted(values = []) { + return [...new Set(values.filter(Boolean).map(String))].sort((left, right) => ( + left.localeCompare(right, "zh-CN") + )); +} + +function unixPathIsInsideHttpUrl(input, pathIndex) { + const prefix = input.slice(0, pathIndex); + return /https?:\/\/[^\s,。!?;;()()"'<>]*$/iu.test(prefix); +} + +function containsLocalAbsolutePath(value) { + const input = stringValue(value, 20000); + for (const marker of ["/Users/", "/home/"]) { + let index = input.indexOf(marker); + while (index >= 0) { + if (!unixPathIsInsideHttpUrl(input, index)) return true; + index = input.indexOf(marker, index + marker.length); + } + } + return /[A-Za-z]:\\Users\\[^\s"',。;;)]+/iu.test(input); +} + +function hasSensitiveContent(value) { + const input = stringValue(value, 20000); + return containsLocalAbsolutePath(input) + || SENSITIVE_CONTENT_PATTERNS.some((pattern) => pattern.test(input)); +} + +function safeIdentifier(value) { + const input = normalizedText(value, 240); + if (!input || hasSensitiveContent(input)) return ""; + return /^[A-Za-z0-9_.:-]+$/u.test(input) ? input : ""; +} + +function safeTitle(value) { + const input = normalizedText(value, 240); + if (!input || hasSensitiveContent(input)) return ""; + return input; +} + +function normalizedIso(value) { + const input = normalizedText(value, 100); + if (!input) return null; + const timestamp = new Date(input).getTime(); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null; +} + +function safeUrl(value) { + const input = normalizedText(value, 1200); + if (!/^https?:\/\//iu.test(input)) return null; + try { + const url = new URL(input); + if (/^(?:localhost|127(?:\.\d{1,3}){3}|\[?::1\]?)$/iu.test(url.hostname)) return null; + url.username = ""; + url.password = ""; + url.hash = ""; + for (const key of [...url.searchParams.keys()]) { + if ( + /^(?:utm_.*|spm|from|source)$/iu.test(key) + || /(?:token|key|secret|signature|credential|auth)/iu.test(key) + ) { + url.searchParams.delete(key); + } + } + return url.toString().replace(/\/$/u, ""); + } catch { + return null; + } +} + +function safeHostname(value) { + const url = safeUrl(value); + if (!url) return ""; + try { + return new URL(url).hostname.toLowerCase().replace(/^www\./u, ""); + } catch { + return ""; + } +} + +function normalizedSourceKind(value) { + const input = normalizedText(value, 80).toLowerCase(); + if (input === "professional" || input.includes("专业数据")) return "professional"; + if (input === "public" || input.includes("联网搜索") || input.includes("公开")) return "public"; + if (input === "internal" || input.includes("内部") || input.includes("飞书")) return "internal"; + return "unknown"; +} + +function sourceType(item = {}, sourceKind = "unknown") { + const explicit = normalizedText(item.source_type, 80).toLowerCase(); + if (["datapro", "web", "internal"].includes(explicit)) return explicit; + if (sourceKind === "professional") return "datapro"; + if (sourceKind === "public") return "web"; + if (sourceKind === "internal") return "internal"; + return "unknown"; +} + +function reliability(item = {}, sourceKind = "unknown") { + const quality = normalizedText(item.source_quality, 80).toLowerCase(); + if (quality === "official" || item.official === true) return "primary"; + if (quality === "professional" || sourceKind === "professional") return "professional"; + if (quality === "internal" || sourceKind === "internal") return "internal"; + if (quality === "traceable" || sourceKind === "public") return "public"; + return "limited"; +} + +function sourceTextSelection(item = {}) { + const summary = stringValue(item.summary, 20000); + if (summary.trim()) return { field: "summary", text: summary }; + const excerpt = stringValue(item.excerpt, 20000); + if (excerpt.trim()) return { field: "excerpt", text: excerpt }; + return { field: null, text: "" }; +} + +function sourceHash(item = {}, sourceTextField = "", sourceText = "") { + return digest(JSON.stringify({ + citation_id: normalizedText(item.id || item.evidence_id, 240), + source_key: normalizedText(item.source_key, 500), + source_kind: normalizedSourceKind(item.source_kind || item.source_kind_label), + title: safeTitle(item.label || item.title), + url: safeUrl(item.url), + published_at: normalizedIso(item.published_at), + source_updated_at: normalizedIso(item.source_updated_at), + source_text_field: sourceTextField, + source_text: sourceText, + })); +} + +function sourceIndependenceHash(item = {}, citationId = "") { + const explicit = normalizedText(item.independence_key, 1000).toLowerCase(); + if (explicit) return digest(`explicit:${explicit}`); + const host = safeHostname(item.url); + if (host) return digest(`host:${host}`); + const sourceKey = normalizedText(item.source_key, 1000).toLowerCase(); + if (sourceKey) return digest(`source_key:${sourceKey}`); + return digest(`citation_id:${citationId}`); +} + +function trimRange(sourceText, start, end) { + let nextStart = start; + let nextEnd = end; + while (nextStart < nextEnd && /\s/u.test(sourceText[nextStart])) nextStart += 1; + while (nextEnd > nextStart && /\s/u.test(sourceText[nextEnd - 1])) nextEnd -= 1; + return nextStart < nextEnd ? { start: nextStart, end: nextEnd } : null; +} + +function isListMarkerPeriod(sourceText, lineStart, index) { + return /^\s*\d+\.$/u.test(sourceText.slice(lineStart, index + 1)); +} + +function naturalRanges(sourceText) { + const ranges = []; + let lineStart = 0; + while (lineStart <= sourceText.length) { + const newline = sourceText.indexOf("\n", lineStart); + const rawLineEnd = newline === -1 ? sourceText.length : newline; + const lineEnd = rawLineEnd > lineStart && sourceText[rawLineEnd - 1] === "\r" + ? rawLineEnd - 1 + : rawLineEnd; + let segmentStart = lineStart; + for (let index = lineStart; index < lineEnd; index += 1) { + const character = sourceText[index]; + const alwaysBoundary = /[。!?!?;;]/u.test(character); + const periodBoundary = character === "." + && !isListMarkerPeriod(sourceText, lineStart, index) + && (index + 1 === lineEnd || /\s/u.test(sourceText[index + 1])) + && !(/\d/u.test(sourceText[index - 1] || "") && /\d/u.test(sourceText[index + 1] || "")); + if (!alwaysBoundary && !periodBoundary) continue; + const range = trimRange(sourceText, segmentStart, index + 1); + if (range) ranges.push(range); + segmentStart = index + 1; + } + const remaining = trimRange(sourceText, segmentStart, lineEnd); + if (remaining) ranges.push(remaining); + if (newline === -1) break; + lineStart = newline + 1; + } + return ranges; +} + +function protectedRanges(sourceText, start, end) { + const input = sourceText.slice(start, end); + const ranges = []; + for (const pattern of PROTECTED_VALUE_PATTERNS) { + const expression = new RegExp(pattern.source, pattern.flags); + for (const match of input.matchAll(expression)) { + const matchStart = start + Number(match.index || 0); + ranges.push({ start: matchStart, end: matchStart + match[0].length }); + } + } + return ranges.sort((left, right) => left.start - right.start || left.end - right.end); +} + +function boundaryInsideProtectedRange(boundary, ranges) { + return ranges.find((range) => boundary > range.start && boundary < range.end) || null; +} + +function boundedRanges(sourceText, range) { + if (range.end - range.start <= MAX_ATOM_CHARS) return [range]; + const protectedValues = protectedRanges(sourceText, range.start, range.end); + const ranges = []; + let cursor = range.start; + while (range.end - cursor > MAX_ATOM_CHARS) { + const minimum = cursor + Math.floor(MAX_ATOM_CHARS * 0.55); + const target = cursor + MAX_ATOM_CHARS; + let boundary = -1; + for (let index = target; index >= minimum; index -= 1) { + if (/[,,、::\s]/u.test(sourceText[index - 1] || "")) { + boundary = index; + break; + } + } + if (boundary < 0) boundary = target; + const protectedValue = boundaryInsideProtectedRange(boundary, protectedValues); + if (protectedValue) boundary = protectedValue.end; + if (boundary <= cursor) boundary = Math.min(range.end, cursor + MAX_ATOM_CHARS); + const next = trimRange(sourceText, cursor, boundary); + if (next) ranges.push(next); + cursor = boundary; + while (cursor < range.end && /\s/u.test(sourceText[cursor])) cursor += 1; + } + const remaining = trimRange(sourceText, cursor, range.end); + if (remaining) ranges.push(remaining); + return ranges; +} + +function segmentRanges(sourceText) { + return naturalRanges(sourceText).flatMap((range) => { + const bounded = range.end - range.start > MAX_ATOM_CHARS; + return boundedRanges(sourceText, range).map((item) => ({ ...item, bounded })); + }); +} + +function rejectionReason(quote, { bounded = false } = {}) { + const normalized = normalizedText(quote, MAX_ATOM_CHARS * 2); + if (!normalized) return "empty_content"; + if (hasSensitiveContent(quote)) return "sensitive_content"; + if (NAVIGATION_OR_STATUS_PATTERNS.some((pattern) => pattern.test(normalized))) { + return "navigation_or_search_status"; + } + if (normalized.length < MIN_ATOM_CHARS) return "non_substantive_fragment"; + const hasPredicate = PREDICATE_PATTERN.test(normalized) + || ENGLISH_PREDICATE_PATTERN.test(normalized); + const hasGroundingSignal = extractGroundingDates(normalized).length > 0 + || extractGroundingNumbers(normalized).length > 0 + || extractGroundingEventFamilies(normalized).length > 0; + return hasPredicate || hasGroundingSignal || bounded ? "" : "non_substantive_fragment"; +} + +function entityAliases(entity = {}) { + const values = [ + entity.canonical_name, + ...(Array.isArray(entity.strict_aliases) ? entity.strict_aliases : []), + ...(Array.isArray(entity.contextual_aliases) ? entity.contextual_aliases : []), + ...(Array.isArray(entity.aliases) ? entity.aliases : []), + ]; + return uniqueSorted(values.map((value) => normalizedText(value, 200))) + .sort((left, right) => right.length - left.length || left.localeCompare(right, "zh-CN")); +} + +function extractedCompanyOrganizations(quote) { + const organizations = []; + const suffixExpression = /股份有限公司|有限责任公司|集团有限公司|有限公司/gu; + for (const suffixMatch of quote.matchAll(suffixExpression)) { + const suffixStart = Number(suffixMatch.index || 0); + const contextStart = Math.max(0, suffixStart - 40); + const context = quote.slice(contextStart, suffixStart); + const boundaries = [ + ...context.matchAll(/[,。!?;;、::\s]|关注|涉及|关联|关于|公示|披露|显示|入选|中标|处罚|诉讼|与|和|对|由|及/gu), + ]; + const boundary = boundaries.at(-1); + const prefixSource = boundary + ? context.slice(Number(boundary.index || 0) + boundary[0].length) + : context; + const prefix = prefixSource.match(/[\p{Script=Han}A-Za-z0-9()()·]{2,30}$/u)?.[0] || ""; + if (prefix) organizations.push(`${prefix}${suffixMatch[0]}`); + } + return organizations; +} + +function companyOrganizations(quote, entity = {}) { + const values = [...extractedCompanyOrganizations(quote)]; + const canonicalName = normalizedText(entity.canonical_name, 200); + if (canonicalName && normalizedText(quote, 2000).includes(canonicalName)) { + values.push(canonicalName); + } + return uniqueSorted(values); +} + +function riskSubjectStronglyAnchored(quote, entity = {}) { + const canonicalName = normalizedText(entity.canonical_name, 200); + const eventIndex = quote.search(/处罚|诉讼|失信|异常|召回|事故|整改|监管/iu); + if (!canonicalName || eventIndex < 0) return false; + const organizations = uniqueSorted([ + ...extractGroundingOrganizations(quote), + ...companyOrganizations(quote, entity), + ]); + const preceding = organizations + .map((organization) => ({ + organization, + index: quote.lastIndexOf(organization, eventIndex), + })) + .filter((item) => item.index >= 0) + .sort((left, right) => right.index - left.index); + return preceding[0]?.organization === canonicalName; +} + +function entityMetadata(item = {}, quote = "", entity = {}, eventFamilies = []) { + const normalizedQuote = normalizedText(quote, 2000); + const canonicalName = normalizedText(entity.canonical_name, 200); + const creditCode = normalizedText(entity?.identifiers?.unified_social_credit_code, 80); + const aliases = entityAliases(entity); + const anchors = []; + if (canonicalName && normalizedQuote.includes(canonicalName)) anchors.push(canonicalName); + if (creditCode && normalizedQuote.includes(creditCode)) anchors.push(creditCode); + for (const alias of aliases) { + if ( + alias + && alias !== canonicalName + && normalizedQuote.includes(alias) + && !anchors.includes(alias) + ) { + anchors.push(alias); + } + } + + const provided = ENTITY_MATCHES.has(String(item.entity_match)) + ? String(item.entity_match) + : "unverified"; + const strongAnchor = Boolean( + (canonicalName && normalizedQuote.includes(canonicalName)) + || (creditCode && normalizedQuote.includes(creditCode)) + ); + if (eventFamilies.includes("risk")) { + if (provided === "company_scoped") { + return { entity_match: "company_scoped", entity_anchors: anchors }; + } + return { + entity_match: strongAnchor && riskSubjectStronglyAnchored(quote, entity) + ? "verified" + : "unverified", + entity_anchors: anchors, + }; + } + if (strongAnchor) return { entity_match: "verified", entity_anchors: anchors }; + if (anchors.length) return { entity_match: "alias_scoped", entity_anchors: anchors }; + if (provided === "verified" && normalizedSourceKind(item.source_kind) === "professional") { + return { entity_match: "verified", entity_anchors: [] }; + } + return { entity_match: provided, entity_anchors: [] }; +} + +function sectionCandidates({ + quote, + sourceContext, + sourceKind, + dates, + eventFamilies, + conflictFields, +} = {}) { + const text = normalizedText(quote, 2000); + const context = normalizedText(sourceContext, 500); + const selected = new Set(); + const overview = /公司名称|统一社会信用代码|法定代表人|注册资本|成立日期|注册地址|经营范围|主营业务|主营|企业简介/iu.test(text) + || /企业工商数据库|工商信息|business/iu.test(context); + const business = /经营|业务|项目|产品|产能|供应链|招标|中标|采购|成交|合作|签署|合同|交付|部署|上线|发布|推出|营收|收入|利润|融资|投资|回购/iu.test(text) + || eventFamilies.some((family) => family !== "risk") + || /产品|项目|合作|交付|经营|业务|更新/iu.test(context); + const recent = sourceKind === "public" + && ( + dates.length > 0 + || eventFamilies.length > 0 + || /近日|近期|公告|动态|进展/iu.test(text) + || /公告|动态|更新|进展/iu.test(context) + ); + const risk = eventFamilies.includes("risk") + || eventFamilies.includes("delivery") + || conflictFields.length > 0 + || /企业风险数据库|风险数据|风险记录|risk/iu.test(context) + || /风险|处罚|诉讼|失信|异常|召回|事故|整改|监管|争议/iu.test(text); + + if (overview) selected.add("company_overview"); + if (business) selected.add("business_dynamics"); + if (recent) selected.add("recent_public_updates"); + if (risk) selected.add("risk_attention"); + if (business && !risk) selected.add("sales_opportunity"); + if (business || risk || overview) selected.add("recommended_actions"); + + if (!selected.size && sourceKind === "professional") selected.add("company_overview"); + if (!selected.size && sourceKind === "public") selected.add("recent_public_updates"); + + return SECTION_KEYS.filter((key) => selected.has(key)); +} + +function atomScore({ + entityMatch, + reliabilityLabel, + url, + dates, + numbers, + organizations, + eventFamilies, + conflictFields, +} = {}) { + const entityScores = { + verified: 35, + company_scoped: 24, + alias_scoped: 18, + query_bound: 12, + unverified: -15, + }; + const reliabilityScores = { + primary: 30, + professional: 28, + internal: 20, + public: 18, + limited: 6, + }; + const value = Number(entityScores[entityMatch] || 0) + + Number(reliabilityScores[reliabilityLabel] || 0) + + (url ? 5 : 0) + + Math.min(8, dates.length * 4) + + Math.min(8, numbers.length * 2) + + Math.min(6, organizations.length * 2) + + Math.min(8, eventFamilies.length * 4) + - Math.min(12, conflictFields.length * 6); + return Math.max(0, Math.min(100, value)); +} + +function candidateOrder(left, right) { + return Number(right.score || 0) - Number(left.score || 0) + || String(left.source_hash).localeCompare(String(right.source_hash)) + || Number(left.quote_start || 0) - Number(right.quote_start || 0) + || String(left.id).localeCompare(String(right.id)); +} + +function rejectedOrder(left, right) { + return String(left.source_hash || "").localeCompare(String(right.source_hash || "")) + || Number(left.quote_start ?? -1) - Number(right.quote_start ?? -1) + || String(left.reason || "").localeCompare(String(right.reason || "")) + || String(left.citation_id || "").localeCompare(String(right.citation_id || "")); +} + +function diagnosticOrder(left, right) { + return String(left.code || "").localeCompare(String(right.code || "")) + || String(left.field || "").localeCompare(String(right.field || "")) + || String(left.citation_id || "").localeCompare(String(right.citation_id || "")) + || String(left.atom_id || "").localeCompare(String(right.atom_id || "")); +} + +function compileSource(item = {}, packEntity = {}) { + const citationId = safeIdentifier(item.id || item.evidence_id); + const selectedText = sourceTextSelection(item); + const computedSourceHash = sourceHash(item, selectedText.field || "", selectedText.text); + const rejected = []; + const diagnostics = []; + if (!citationId) { + rejected.push({ + citation_id: null, + source_hash: computedSourceHash, + source_text_field: selectedText.field, + quote_start: null, + quote_end: null, + reason: "unsafe_citation_id", + }); + return { candidates: [], rejected, diagnostics }; + } + if (!selectedText.field) { + rejected.push({ + citation_id: citationId, + source_hash: computedSourceHash, + source_text_field: null, + quote_start: null, + quote_end: null, + reason: "missing_source_text", + }); + return { candidates: [], rejected, diagnostics }; + } + + const sourceKind = normalizedSourceKind(item.source_kind || item.source_kind_label); + const sourceTypeValue = sourceType(item, sourceKind); + const reliabilityLabel = reliability(item, sourceKind); + const title = safeTitle(item.label || item.title); + const url = safeUrl(item.url); + const publishedAt = normalizedIso(item.published_at); + const sourceUpdatedAt = normalizedIso(item.source_updated_at); + const independenceHash = sourceIndependenceHash(item, citationId); + const conflictFields = uniqueSorted( + Array.isArray(item.conflict_fields) ? item.conflict_fields.map((field) => ( + safeIdentifier(field) + )) : [], + ); + const candidates = []; + + for (const range of segmentRanges(selectedText.text)) { + const quote = selectedText.text.slice(range.start, range.end); + const reason = rejectionReason(quote, range); + if (reason) { + rejected.push({ + citation_id: citationId, + source_hash: computedSourceHash, + source_text_field: selectedText.field, + quote_start: range.start, + quote_end: range.end, + reason, + }); + continue; + } + + const dates = uniqueSorted(extractGroundingDates(quote)); + const numbers = uniqueSorted(extractGroundingNumbers(quote)); + const eventFamilies = uniqueSorted(extractGroundingEventFamilies(quote)); + const entityResult = entityMetadata(item, quote, packEntity, eventFamilies); + const organizations = uniqueSorted([ + ...extractGroundingOrganizations(quote), + ...companyOrganizations(quote, packEntity), + ]); + const sections = sectionCandidates({ + quote, + sourceContext: `${title} ${item.purpose || ""} ${item.source_group || ""}`, + sourceKind, + dates, + eventFamilies, + conflictFields, + }); + const score = atomScore({ + entityMatch: entityResult.entity_match, + reliabilityLabel, + url, + dates, + numbers, + organizations, + eventFamilies, + conflictFields, + }); + const atomHash = digest(JSON.stringify({ + citation_id: citationId, + source_hash: computedSourceHash, + independence_hash: independenceHash, + source_text_field: selectedText.field, + quote_start: range.start, + quote_end: range.end, + quote, + })); + const atom = { + id: `E_${atomHash.slice(0, 20)}`, + citation_id: citationId, + source_hash: computedSourceHash, + independence_hash: independenceHash, + source_kind: sourceKind, + source_type: sourceTypeValue, + title, + url, + published_at: publishedAt, + source_updated_at: sourceUpdatedAt, + source_text_field: selectedText.field, + quote, + quote_start: range.start, + quote_end: range.end, + normalized_text: normalizedText(quote, MAX_ATOM_CHARS * 2), + entity_match: entityResult.entity_match, + entity_anchors: uniqueSorted(entityResult.entity_anchors), + section_candidates: sections, + dates, + numbers, + organizations, + event_families: eventFamilies, + conflict_fields: conflictFields, + reliability: reliabilityLabel, + score, + }; + if ( + eventFamilies.includes("risk") + && !["verified", "company_scoped"].includes(entityResult.entity_match) + ) { + diagnostics.push({ + level: "warning", + code: "risk_subject_not_strongly_anchored", + citation_id: citationId, + atom_id: atom.id, + }); + } + candidates.push(atom); + } + + return { candidates, rejected, diagnostics }; +} + +function deduplicateCandidates(candidates = []) { + const accepted = []; + const rejected = []; + const byContent = new Map(); + for (const candidate of [...candidates].sort(candidateOrder)) { + const key = [ + normalizedText(candidate.normalized_text, MAX_ATOM_CHARS * 2).toLowerCase(), + candidate.independence_hash, + ].join("\n"); + const duplicate = byContent.get(key); + if (duplicate) { + rejected.push({ + citation_id: candidate.citation_id, + source_hash: candidate.source_hash, + source_text_field: candidate.source_text_field, + quote_start: candidate.quote_start, + quote_end: candidate.quote_end, + reason: "duplicate_content", + duplicate_of: duplicate.id, + }); + continue; + } + byContent.set(key, candidate); + accepted.push(candidate); + } + return { atoms: accepted.sort(candidateOrder), rejected }; +} + +function buildCoverage(atoms = []) { + return Object.fromEntries(SECTION_KEYS.map((section) => { + const candidates = atoms.filter((atom) => atom.section_candidates.includes(section)); + const strong = candidates.filter((atom) => { + if (section === "company_overview") return atom.entity_match === "verified"; + if (section === "risk_attention") { + return ["verified", "company_scoped"].includes(atom.entity_match); + } + return atom.entity_match !== "unverified"; + }); + if (strong.length) { + return [section, { + status: "supported", + atom_ids: strong.map((atom) => atom.id), + reasons: [], + }]; + } + if (candidates.length) { + return [section, { + status: "partial", + atom_ids: candidates.map((atom) => atom.id), + reasons: ["only_weak_entity_matches"], + }]; + } + return [section, { + status: "missing", + atom_ids: [], + reasons: ["no_relevant_atoms"], + }]; + })); +} + +function uniqueDiagnostics(diagnostics = []) { + const byIdentity = new Map(); + for (const diagnostic of diagnostics) { + const identity = JSON.stringify(diagnostic); + if (!byIdentity.has(identity)) byIdentity.set(identity, diagnostic); + } + return [...byIdentity.values()].sort(diagnosticOrder); +} + +export function compileDossierEvidenceAtoms({ evidencePack = {} } = {}) { + const pack = evidencePack && typeof evidencePack === "object" ? evidencePack : {}; + const entity = pack.entity && typeof pack.entity === "object" ? pack.entity : {}; + const items = Array.isArray(pack.items) ? pack.items : []; + const compiled = items.map((item) => compileSource(item, entity)); + const deduplicated = deduplicateCandidates(compiled.flatMap((entry) => entry.candidates)); + const atoms = deduplicated.atoms; + const coverage = buildCoverage(atoms); + const diagnostics = [ + ...compiled.flatMap((entry) => entry.diagnostics), + ...(Array.isArray(pack.conflicts) ? pack.conflicts : []) + .map((conflict) => safeIdentifier(conflict?.field)) + .filter(Boolean) + .map((field) => ({ + level: "warning", + code: "source_conflict", + field, + })), + ...atoms.flatMap((atom) => atom.conflict_fields.map((field) => ({ + level: "warning", + code: "source_conflict", + field, + citation_id: atom.citation_id, + atom_id: atom.id, + }))), + ...Object.entries(coverage) + .filter(([, value]) => value.status !== "supported") + .map(([section, value]) => ({ + level: "info", + code: "coverage_gap", + section, + status: value.status, + })), + ]; + + return { + atoms, + rejected: [ + ...compiled.flatMap((entry) => entry.rejected), + ...deduplicated.rejected, + ].sort(rejectedOrder), + coverage, + diagnostics: uniqueDiagnostics(diagnostics), + }; +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/evidence/salesEvidence.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/evidence/salesEvidence.js new file mode 100644 index 00000000..9adc78ad --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/evidence/salesEvidence.js @@ -0,0 +1,1247 @@ +import { createHash } from "node:crypto"; +import { deriveEvidenceDataAsOf } from "./claimGrounding.js"; + +const COMPANY_SUFFIXES = [ + "股份有限公司", + "有限责任公司", + "集团有限公司", + "有限公司", + "集团", + "公司", +]; + +const DAY_MS = 24 * 60 * 60 * 1000; +const OFFICIAL_PUBLIC_HOSTS = [ + "gov.cn", + "sse.com.cn", + "szse.cn", + "hkexnews.hk", + "cninfo.com.cn", +]; +const NON_SUBSTANTIVE_PUBLIC_CONTENT_PATTERNS = [ + /for better experience.{0,80}(?:verification|verify)/i, + /(?:complete|pass).{0,40}(?:the )?verification process/i, + /(?:verify you are human|captcha|access denied|robot check|security check)/i, + /(?:请|需要).{0,16}(?:完成|通过).{0,12}(?:人机|安全|访问|滑动)?验证/u, + /(?:人机验证|安全验证|访问验证|滑动验证|验证码页面|页面不存在|内容已下线)/u, +]; +const QA_GAP_HEADING_PATTERN = /^(?:缺口|资料缺口|信息缺口|证据缺口|覆盖缺口)[::]/u; +const QA_GAP_REQUEST_PATTERN = /缺口|缺失|不足|未覆盖|还缺|需要补充|哪些资料没有/u; +const QA_RISK_HEADING_PATTERN = /^(?:风险|主要风险|关注事项)[::]/u; +const QA_ACTION_HEADING_PATTERN = /^(?:跟进行动|行动|建议|下一步)(?:[一二三四五六七八九十]|\d+)?[::]/u; +const CRITICAL_FACT_PATTERNS = [ + { field: "registered_capital", label: "注册资本", pattern: /注册资本(?:为|是|达到|约为|约|[::])?\s*([+-]?\d[\d,.]*(?:\.\d+)?\s*(?:亿|万)?\s*(?:人民币|美元|元))/gi }, + { field: "revenue", label: "营业收入", pattern: /(?:营业收入|营收)(?:为|达到|约为|约|[::])?\s*([+-]?\d[\d,.]*(?:\.\d+)?\s*(?:亿|万)?\s*(?:人民币|美元|元|%|%))/gi }, + { field: "net_profit", label: "净利润", pattern: /(?:净利润|净亏损)(?:为|达到|约为|约|[::])?\s*([+-]?\d[\d,.]*(?:\.\d+)?\s*(?:亿|万)?\s*(?:人民币|美元|元|%|%))/gi }, + { field: "financing", label: "融资金额", pattern: /(?:融资金额|完成融资|获融资)(?:为|达到|约为|约|[::])?\s*([+-]?\d[\d,.]*(?:\.\d+)?\s*(?:亿|万)?\s*(?:人民币|美元|元))/gi }, + { field: "valuation", label: "估值", pattern: /估值(?:为|达到|约为|约|[::])?\s*([+-]?\d[\d,.]*(?:\.\d+)?\s*(?:亿|万)?\s*(?:人民币|美元|元))/gi }, +]; +const DOSSIER_SECTION_TITLES = [ + "企业与业务概览", + "经营与业务动态", + "近期公开动态", + "风险与关注事项", + "销售机会判断", + "建议行动", +]; +const QA_INTENT_RULES = [ + { + id: "risk", + pattern: /风险|处罚|诉讼|失信|异常|隐患|合规|顾虑|阻碍|问题/, + terms: ["风险", "关注事项", "处罚", "诉讼", "失信", "异常", "合规", "顾虑"], + }, + { + id: "timeline", + pattern: /时间|日期|何时|什么时候|节点|计划|周期|进度|最近|最新|先后|历史/, + terms: ["时间", "日期", "节点", "计划", "进度", "近期", "历史"], + }, + { + id: "people", + pattern: /谁|负责人|联系人|决策人|部门|角色|对接人/, + terms: ["负责人", "联系人", "决策人", "部门", "角色", "对接"], + }, + { + id: "requirement", + pattern: /需求|痛点|关注|目标|场景|想要|希望|要求|预算/, + terms: ["需求", "痛点", "关注", "目标", "场景", "希望", "要求", "预算"], + }, + { + id: "action", + pattern: /下一步|怎么推进|如何推进|建议|行动|跟进|切入|机会/, + terms: ["下一步", "建议行动", "推进", "跟进", "切入", "销售机会"], + }, + { + id: "overview", + pattern: /总结|概括|整体|情况|介绍|是什么|(?:企业|公司|客户).{0,4}怎么样/, + terms: ["概览", "总结", "企业与业务概览", "经营与业务动态"], + }, +]; + +function text(value, maxLength = 12000) { + return String(value || "") + .normalize("NFKC") + .replace(/[\u0000-\u001F\u007F]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxLength); +} + +function dossierEvidenceText(value, maxLength = 1600) { + return text(value, maxLength * 2) + .replace(/<[^>]+>/g, " ") + .replace(/(?:查看详情|查看更多|点击查看|立即注册|免费查看|登录后查看)\s*>*/gu, " ") + .replace(/(?:案号|序号|操作)复制/gu, "$1") + .replace(/\bUntitled\b/giu, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxLength); +} + +function digest(value) { + return createHash("sha256").update(String(value || ""), "utf8").digest("hex"); +} + +function qaText(value, maxLength = 20000) { + return String(value || "") + .normalize("NFKC") + .replace(/\r\n?/g, "\n") + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, " ") + .replace(/[ \t]+/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim() + .slice(0, maxLength); +} + +function qaLexemes(value) { + const normalized = qaText(value, 12000).toLowerCase(); + const lexemes = new Set(normalized.match(/[a-z][a-z0-9._-]{1,}|[0-9][0-9.,%+-]*/g) || []); + for (const sequence of normalized.match(/[\p{Script=Han}]{2,}/gu) || []) { + const compact = sequence.slice(0, 80); + for (let size = 2; size <= Math.min(4, compact.length); size += 1) { + for (let index = 0; index <= compact.length - size; index += 1) { + lexemes.add(compact.slice(index, index + size)); + } + } + } + return lexemes; +} + +function qaLexicalSimilarity(queryLexemes, candidateValue) { + if (!queryLexemes.size) return 0; + const candidateLexemes = qaLexemes(candidateValue); + if (!candidateLexemes.size) return 0; + const overlap = [...queryLexemes].filter((term) => candidateLexemes.has(term)).length; + const cosine = overlap / Math.sqrt(queryLexemes.size * candidateLexemes.size); + const queryCoverage = overlap / queryLexemes.size; + return Math.min(1, cosine * 0.65 + queryCoverage * 0.35); +} + +function meaningfulQaSummary(value) { + const visibleText = qaText(value, 2400) + .replace(/<[^>]+>/g, " ") + .replace(/https?:\/\/\S+/gi, " ") + .replace(/[-|#*_`~=::/\\\s]+/g, ""); + return /[\p{L}\p{N}]{2,}/u.test(visibleText); +} + +function qaEnumerationKey(value) { + return qaText(value, 500) + .toLowerCase() + .replace(/<[^>]+>/g, "") + .replace(/[*_`~]/g, "") + .replace(/[^\p{L}\p{N}]+/gu, ""); +} + +function qaEnumerationAliases(label) { + const cleaned = qaText(label, 240) + .replace(/<[^>]+>/g, "") + .replace(/[*_`~]/g, "") + .trim(); + const base = cleaned.split(/[::]/, 1)[0].trim(); + const parts = base.split(/[\/+、]|(?:\s+(?:及|与)\s+)/).map((item) => item.trim()); + return [...new Set([cleaned, base, ...parts].map(qaEnumerationKey))] + .filter((item) => item.length >= 4); +} + +function qaTableRowLabels(value) { + const source = qaText(value, 6000); + const separatorCell = (value) => /^:?-{1,}:?$/.test(String(value || "").trim()); + const cleanCell = (value, maxLength = 900) => ( + qaText(value, maxLength) + .replace(/<[^>]+>/g, "") + .replace(/[*_`~]/g, "") + .trim() + ); + const rowSegments = source.split(/\|\s+\|/).map((item) => item.trim()).filter(Boolean); + const tables = []; + for (let index = 0; index < rowSegments.length; index += 1) { + const separatorCells = rowSegments[index].split("|").map((item) => cleanCell(item, 180)); + if ( + separatorCells.length < 2 + || !separatorCells.every(separatorCell) + ) { + continue; + } + const columnCount = separatorCells.length; + const labels = []; + for (let rowIndex = index + 1; rowIndex < rowSegments.length; rowIndex += 1) { + const cells = rowSegments[rowIndex].split("|").map((item, cellIndex) => cleanCell( + item, + cellIndex ? 900 : 180, + )); + if (cells.length < columnCount) break; + const row = cells.slice(0, columnCount); + if (row.every(separatorCell)) break; + const label = row[0] + .replace(/<[^>]+>/g, "") + .replace(/[*_`~]/g, "") + .trim(); + const description = row.slice(1).join(" "); + const key = qaEnumerationKey(label); + if ( + !key + || /^#{1,6}\s/.test(label) + || /^---/.test(label) + || separatorCell(label) + || separatorCell(description) + ) { + break; + } + if (!labels.some((item) => qaEnumerationKey(item) === key)) labels.push(label); + } + if (labels.length) tables.push(labels); + } + return tables.sort((left, right) => right.length - left.length)[0] || []; +} + +function qaEnumerationSubject(value) { + const source = qaText(value, 1200); + const afterCue = source.match( + /(?:哪些|有哪(?:些)?|列出|列举|逐项(?:说明)?|所有|全部|包括什么|包含什么|多少(?:项|种|个))\s*([^,。?!?;;\n]{2,40})/, + )?.[1]; + const beforeCue = source.match( + /([^,。?!?;;\n]{2,40}?)(?:有哪些|有哪(?:些)?|包括什么|包含什么)/, + )?.[1]; + return qaText(afterCue || beforeCue || source, 80) + .replace(/^(?:这份|该|当前|上述|文档|资料|明确|使用了?)+/g, "") + .replace(/(?:请|并请|需要).*/g, "") + .trim(); +} + +function splitQaChunks(value, maxChars = 1100) { + const input = qaText(value); + if (!input) return []; + const overlapChars = Math.max(80, Math.min(180, Math.floor(maxChars * 0.16))); + const blocks = input + .split(/\n{2,}|(?=^#{1,6}\s)/m) + .map((item) => item.trim()) + .filter(Boolean); + const chunks = []; + let headingContext = ""; + for (const rawBlock of blocks) { + const headingOnly = rawBlock.match(/^(#{1,6}\s+[^\n]+)$/); + if (headingOnly) { + headingContext = headingOnly[1].trim(); + continue; + } + const leadingHeading = rawBlock.match(/^(#{1,6}\s+[^\n]+)\n+([\s\S]+)$/); + const block = leadingHeading ? leadingHeading[2].trim() : rawBlock; + if (leadingHeading) headingContext = leadingHeading[1].trim(); + const contextualize = (chunk) => ( + headingContext && !chunk.startsWith(headingContext) + ? `${headingContext}\n${chunk}` + : chunk + ); + if (block.length <= maxChars) { + chunks.push(contextualize(block)); + continue; + } + const sentences = block.split(/(?<=[。!?!?;;])\s*/).filter(Boolean); + let current = ""; + for (const sentence of sentences.length ? sentences : [block]) { + if (current && current.length + sentence.length + 1 > maxChars) { + chunks.push(contextualize(current.trim())); + current = ""; + } + if (sentence.length > maxChars) { + if (current) chunks.push(contextualize(current.trim())); + current = ""; + const stride = Math.max(1, maxChars - overlapChars); + for (let index = 0; index < sentence.length; index += stride) { + chunks.push(contextualize(sentence.slice(index, index + maxChars).trim())); + if (index + maxChars >= sentence.length) break; + } + } else { + current = `${current}${current ? " " : ""}${sentence}`; + } + } + if (current) chunks.push(contextualize(current.trim())); + } + if (!chunks.length && headingContext) chunks.push(headingContext); + const merged = []; + for (const chunk of chunks.filter(Boolean)) { + const heading = chunk.match(/^(#{1,6}\s+[^\n]+)\n/)?.[1] || ""; + const previous = merged.at(-1) || ""; + if ( + heading + && previous.startsWith(`${heading}\n`) + && previous.length + chunk.length - heading.length <= maxChars + heading.length + 1 + ) { + merged[merged.length - 1] = `${previous}\n${chunk.slice(heading.length).trim()}`; + } else { + merged.push(chunk); + } + } + return merged; +} + +function qaChunkContextWindow(chunks, index, maxChars = 1600) { + const selected = [{ index, text: chunks[index] }].filter((item) => item.text); + let currentLength = selected[0]?.text.length || 0; + for (const neighborIndex of [index - 1, index + 1]) { + const neighbor = chunks[neighborIndex]; + if (!neighbor) continue; + if (currentLength + neighbor.length + 2 > maxChars) continue; + selected.push({ index: neighborIndex, text: neighbor }); + currentLength += neighbor.length + 2; + } + return selected + .sort((left, right) => left.index - right.index) + .map((item) => item.text) + .join("\n\n"); +} + +export function analyzeQaQuestion(question, conversationHistory = []) { + const rawQuestion = qaText(question, 1800); + const recentContext = (conversationHistory || []) + .slice(-2) + .map((message) => qaText(message?.text || message?.content, 500)) + .filter(Boolean) + .join(" "); + const resolvedQuestion = /^(?:那|那么|这个|它|其|上述|刚才)|(?:下一步|然后呢|还有呢)/.test(rawQuestion) + ? qaText(`${recentContext} ${rawQuestion}`, 2200) + : rawQuestion; + const intents = QA_INTENT_RULES.filter((rule) => rule.pattern.test(resolvedQuestion)).map((rule) => rule.id); + const subqueries = [...new Set( + resolvedQuestion + .split(/[??;;]|\s+(?:以及|并且|同时|另外)\s+|(?:还要|还想|还需要)/) + .map((item) => qaText(item, 500)) + .filter((item) => item.length >= 2), + )].slice(0, 3); + return { + original_question: rawQuestion, + resolved_question: resolvedQuestion, + intents: intents.length ? intents : ["fact"], + subqueries: subqueries.length ? subqueries : [resolvedQuestion], + }; +} + +function qaRetrievalContextIdentity(context = {}) { + const uri = qaText(context.uri, 1000) + .replace(/[?#].*$/, "") + .replace(/\/+$/, "") + .toLowerCase(); + if (uri) return `uri:${uri}`; + const materialId = qaText(context.material_id, 240); + if (materialId) return `material:${materialId}`; + return `content:${digest(`${context.title || ""}\n${context.abstract || context.summary || ""}`)}`; +} + +export function fuseQaRetrievalContexts( + queryResults = [], + { + maxContexts = 10, + maxPerMaterial = 2, + rrfK = 60, + } = {}, +) { + const fused = new Map(); + for (const [queryIndex, queryResult] of (queryResults || []).entries()) { + const query = qaText(queryResult?.query, 1800) || `query-${queryIndex + 1}`; + const seenInQuery = new Set(); + for (const [resultIndex, context] of (queryResult?.contexts || []).entries()) { + const identity = qaRetrievalContextIdentity(context); + if (seenInQuery.has(identity)) continue; + seenInQuery.add(identity); + const rank = resultIndex + 1; + const score = Number(context?.score); + const previous = fused.get(identity); + const entry = previous || { + ...context, + fusion_score: 0, + query_hits: 0, + matched_queries: [], + best_rank: rank, + best_provider_score: Number.isFinite(score) ? score : null, + }; + entry.fusion_score += 1 / (Math.max(1, Number(rrfK || 60)) + rank); + entry.query_hits += 1; + entry.matched_queries.push(query); + entry.best_rank = Math.min(entry.best_rank, rank); + if (Number.isFinite(score)) { + entry.best_provider_score = entry.best_provider_score === null + ? score + : Math.max(entry.best_provider_score, score); + } + fused.set(identity, entry); + } + } + const ranked = [...fused.values()] + .map((context) => ({ + ...context, + score: context.best_provider_score ?? context.score ?? null, + fusion_score: Number(context.fusion_score.toFixed(8)), + matched_queries: [...new Set(context.matched_queries)], + })) + .sort((left, right) => ( + Number(right.fusion_score || 0) - Number(left.fusion_score || 0) + || Number(right.best_provider_score ?? -1) - Number(left.best_provider_score ?? -1) + || Number(left.best_rank || 999) - Number(right.best_rank || 999) + || qaRetrievalContextIdentity(left).localeCompare(qaRetrievalContextIdentity(right)) + )); + const limit = Math.max(1, Math.min(20, Number(maxContexts || 10))); + const perMaterialLimit = Math.max(1, Math.min(6, Number(maxPerMaterial || 2))); + const materialCounts = new Map(); + const selected = []; + for (const context of ranked) { + const materialKey = qaText(context.material_id, 240) + || qaRetrievalContextIdentity(context); + const count = materialCounts.get(materialKey) || 0; + if (count >= perMaterialLimit) continue; + selected.push(context); + materialCounts.set(materialKey, count + 1); + if (selected.length >= limit) break; + } + return selected; +} + +function qaEvidenceScore(questionPlan, item) { + const queryLexemes = qaLexemes(questionPlan.resolved_question); + const summaryText = String(item.retrieval_text || item.summary || ""); + const labelText = String(item.label || ""); + const leadText = summaryText.slice(0, 260); + const labelLexemes = qaLexemes(labelText); + const focusLexemes = new Set( + [...queryLexemes].filter((term) => !labelLexemes.has(term)), + ); + const summaryLexical = qaLexicalSimilarity(queryLexemes, summaryText); + const leadLexical = qaLexicalSimilarity(queryLexemes, leadText); + const labelLexical = qaLexicalSimilarity(queryLexemes, labelText); + const focusLexical = qaLexicalSimilarity(focusLexemes, summaryText); + const focusLeadLexical = qaLexicalSimilarity(focusLexemes, leadText); + const lexical = Math.min( + 1, + focusLexical * 0.62 + + focusLeadLexical * 0.14 + + summaryLexical * 0.14 + + leadLexical * 0.05 + + labelLexical * 0.05, + ); + const candidateText = `${labelText} ${summaryText}`; + const intentTerms = QA_INTENT_RULES + .filter((rule) => questionPlan.intents.includes(rule.id)) + .flatMap((rule) => rule.terms); + const intentMatches = intentTerms.filter((term) => candidateText.includes(term)).length; + const intent = intentTerms.length ? intentMatches / intentTerms.length : 0; + const semantic = Number.isFinite(Number(item.semantic_score)) + ? Math.max(0, Math.min(1, Number(item.semantic_score))) + : 0; + const exactSubquery = questionPlan.subqueries.some((query) => ( + query.length >= 4 && candidateText.includes(query) + )) ? 1 : 0; + const contentSignal = Math.max(focusLexical, focusLeadLexical); + const semanticWeight = contentSignal >= 0.02 || intent > 0 || exactSubquery > 0 ? 0.16 : 0.03; + return { + lexical_score: Number(lexical.toFixed(6)), + summary_lexical_score: Number(summaryLexical.toFixed(6)), + lead_lexical_score: Number(leadLexical.toFixed(6)), + label_lexical_score: Number(labelLexical.toFixed(6)), + focus_lexical_score: Number(focusLexical.toFixed(6)), + focus_lead_lexical_score: Number(focusLeadLexical.toFixed(6)), + intent_score: Number(intent.toFixed(6)), + semantic_score: Number(semantic.toFixed(6)), + exact_subquery_match: Boolean(exactSubquery), + retrieval_score: Number(( + lexical * 0.56 + + intent * 0.24 + + semantic * semanticWeight + + exactSubquery * 0.06 + ).toFixed(6)), + }; +} + +function canonicalUrl(value) { + const raw = text(value, 1000); + if (!/^https?:\/\//i.test(raw)) return raw; + try { + const url = new URL(raw); + url.hash = ""; + for (const key of [...url.searchParams.keys()]) { + if (/^(utm_|spm|from|source)/i.test(key)) url.searchParams.delete(key); + } + return url.toString().replace(/\/$/, ""); + } catch { + return raw; + } +} + +function normalizedCompanyName(value) { + return text(value, 160).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ""); +} + +function shortCompanyName(value) { + let name = text(value, 160); + for (const suffix of COMPANY_SUFFIXES) { + if (name.endsWith(suffix) && name.length > suffix.length) { + name = name.slice(0, -suffix.length); + break; + } + } + return normalizedCompanyName(name); +} + +function parentheticalBrandAlias(value) { + const match = text(value, 160).match(/^([^()()]{2,16})\s*[((]\s*(?:中国|China)\s*[))]/iu); + return normalizedCompanyName(match?.[1] || ""); +} + +function validIso(value) { + const raw = text(value, 80); + if (!raw) return null; + const timestamp = new Date(raw).getTime(); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null; +} + +function hostname(value) { + const url = canonicalUrl(value); + if (!url) return ""; + try { + return new URL(url).hostname.toLowerCase().replace(/^www\./, ""); + } catch { + return ""; + } +} + +function isOfficialPublicSource(source, url) { + if (source.official === true || /^(official|government)$/i.test(text(source.authority, 40))) return true; + const host = hostname(url); + return OFFICIAL_PUBLIC_HOSTS.some((suffix) => host === suffix || host.endsWith(`.${suffix}`)); +} + +function sourceQuality(kind, source, url) { + const isTestSource = /^(mock|demo|fixture)$/i.test(text(source.provider_mode, 40)); + if (isTestSource) { + return { source_quality: "limited", source_quality_label: "测试或占位来源", quality_tier: 3, official: false }; + } + if (kind === "professional") { + return { source_quality: "professional", source_quality_label: "专业权威来源", quality_tier: 1, official: true }; + } + if (kind === "internal") { + return { source_quality: "internal", source_quality_label: "内部授权资料", quality_tier: 2, official: false }; + } + if (isOfficialPublicSource(source, url)) { + return { source_quality: "official", source_quality_label: "官方公开来源", quality_tier: 1, official: true }; + } + if (hostname(url)) { + return { source_quality: "traceable", source_quality_label: "可追溯公开来源", quality_tier: 2, official: false }; + } + return { source_quality: "limited", source_quality_label: "来源信息有限", quality_tier: 3, official: false }; +} + +function sourceFreshness(kind, publishedAt, sourceUpdatedAt, generatedAt) { + const referenceDate = kind === "public" + ? publishedAt || sourceUpdatedAt + : sourceUpdatedAt || publishedAt; + if (!referenceDate) { + return { freshness: "unknown", freshness_label: "日期未知", age_days: null }; + } + const referenceTime = new Date(referenceDate).getTime(); + const generatedTime = new Date(generatedAt).getTime(); + const ageDays = Math.max(0, Math.floor((generatedTime - referenceTime) / DAY_MS)); + const currentDays = kind === "public" ? 180 : 365; + const staleDays = kind === "public" ? 365 : 730; + if (ageDays <= currentDays) return { freshness: "current", freshness_label: "近期资料", age_days: ageDays }; + if (ageDays <= staleDays) return { freshness: "aging", freshness_label: "较早资料", age_days: ageDays }; + return { freshness: "stale", freshness_label: "过期资料", age_days: ageDays }; +} + +function normalizeCriticalValue(value) { + return text(value, 80).replace(/[\s,,]/g, "").replace(/%/g, "%").toLowerCase(); +} + +export function extractCriticalClaims(value) { + const input = text(value, 4000); + const claims = []; + for (const definition of CRITICAL_FACT_PATTERNS) { + const expression = new RegExp(definition.pattern.source, definition.pattern.flags); + for (const match of input.matchAll(expression)) { + const normalizedValue = normalizeCriticalValue(match[1]); + if (!normalizedValue) continue; + claims.push({ + field: definition.field, + field_label: definition.label, + value: text(match[1], 80), + normalized_value: normalizedValue, + }); + } + } + return claims.filter((claim, index, values) => values.findIndex((item) => ( + item.field === claim.field && item.normalized_value === claim.normalized_value + )) === index); +} + +function sourceIndependenceKey(kind, source, identity, url) { + if (kind === "public") return hostname(url) || identity; + if (kind === "professional") { + return `${text(source.provider || "datapro", 80)}:${text(source.source_group || source.label || identity, 240)}`; + } + return text(source.uri, 1000) || identity; +} + +function evidenceDate(item) { + return validIso(item.published_at || item.source_updated_at); +} + +function evidenceConflicts(items) { + const byField = new Map(); + for (const item of items.filter((candidate) => candidate.source_kind !== "internal")) { + for (const claim of item.critical_claims || []) { + if (!byField.has(claim.field)) byField.set(claim.field, []); + byField.get(claim.field).push({ + ...claim, + evidence_id: item.id, + source_key: item.source_key, + source_date: evidenceDate(item), + }); + } + } + const conflicts = []; + for (const [field, claims] of byField) { + const distinctValues = [...new Set(claims.map((claim) => claim.normalized_value))]; + if (distinctValues.length < 2) continue; + const competing = claims.some((left, leftIndex) => claims.some((right, rightIndex) => { + if (rightIndex <= leftIndex || left.normalized_value === right.normalized_value) return false; + if (!left.source_date || !right.source_date) return true; + return Math.abs(new Date(left.source_date).getTime() - new Date(right.source_date).getTime()) <= 180 * DAY_MS; + })); + if (!competing) continue; + conflicts.push({ + field, + field_label: claims[0].field_label, + values: distinctValues.map((normalizedValue) => ({ + value: claims.find((claim) => claim.normalized_value === normalizedValue)?.value || normalizedValue, + evidence_ids: claims.filter((claim) => claim.normalized_value === normalizedValue).map((claim) => claim.evidence_id), + })), + }); + } + return conflicts; +} + +function evidenceAnchorsLegalEntity(item, entity) { + if (item?.source_kind !== "professional") return false; + const sourceText = normalizedCompanyName(`${item.label || ""} ${item.summary || ""}`); + const canonicalName = normalizedCompanyName(entity?.canonical_name || ""); + const creditCode = normalizedCompanyName(entity?.identifiers?.unified_social_credit_code || ""); + return Boolean( + (canonicalName && sourceText.includes(canonicalName)) + || (creditCode && sourceText.includes(creditCode)) + ); +} + +function evidencePolicy(items, conflicts, entity = {}) { + const counts = { professional: 0, public: 0, internal: 0 }; + for (const item of items) counts[item.source_kind] = Number(counts[item.source_kind] || 0) + 1; + const warnings = []; + const staleCount = items.filter((item) => item.freshness === "stale").length; + const unknownDateCount = items.filter((item) => item.freshness === "unknown").length; + if (staleCount) warnings.push(`${staleCount} 条来源已过期,不能作为最新动态依据。`); + if (unknownDateCount) warnings.push(`${unknownDateCount} 条来源缺少可核验日期。`); + if (conflicts.length) warnings.push(`${conflicts.length} 个关键数字存在来源冲突,不能直接选取单一值。`); + return { + schema_version: 1, + source_counts: counts, + authoritative_external_count: items.filter((item) => item.source_kind !== "internal" && item.quality_tier === 1).length, + legal_entity_anchor_count: items.filter((item) => evidenceAnchorsLegalEntity(item, entity)).length, + alias_scoped_count: items.filter((item) => item.entity_match === "alias_scoped").length, + traceable_public_count: items.filter((item) => item.source_kind === "public" && item.quality_tier <= 2 && hostname(item.url)).length, + current_public_count: items.filter((item) => item.source_kind === "public" && item.freshness === "current").length, + stale_count: staleCount, + unknown_date_count: unknownDateCount, + conflict_count: conflicts.length, + warnings, + }; +} + +function evidenceRejectionReason(item) { + if (item.entity_match === "unverified") return "entity_not_verified"; + if ( + item.source_kind === "public" + && NON_SUBSTANTIVE_PUBLIC_CONTENT_PATTERNS.some((pattern) => ( + pattern.test(`${item.label || ""} ${item.summary || ""}`) + )) + ) { + return "content_not_substantive"; + } + return ""; +} + +function sourceKindLabel(kind) { + if (kind === "professional") return "专业数据集"; + if (kind === "public") return "联网搜索"; + return "内部资料"; +} + +function evidenceIdentity(kind, source, entity) { + if (kind === "public") return canonicalUrl(source.url) || text(source.label || source.title, 240); + if (kind === "internal") return text(source.uri, 1000) || text(source.source_id || source.title, 240); + return text(source.source_key || source.label, 240) || `${entity.canonical_name}:professional`; +} + +function entityMatch(kind, source, entity) { + if (kind === "internal") return "company_scoped"; + const candidate = normalizedCompanyName(`${source.label || source.title || ""} ${source.summary || source.abstract || ""}`); + if (entity.strict_aliases.some((alias) => alias.length >= 2 && candidate.includes(alias))) return "verified"; + if (entity.contextual_aliases.some((alias) => alias.length >= 2 && candidate.includes(alias))) { + return "alias_scoped"; + } + const query = normalizedCompanyName(source.query || ""); + if (kind === "professional" && entity.aliases.some((alias) => alias.length >= 2 && query.includes(alias))) { + return "query_bound"; + } + return "unverified"; +} + +function normalizeEvidence(kind, source, entity, generatedAt) { + const summary = dossierEvidenceText(source.summary || source.abstract || source.text, 1600); + const identity = evidenceIdentity(kind, source, entity); + if (!summary || !identity) return null; + const publishedAt = validIso(source.published_at || source.publish_time || source.occurred_at); + const sourceUpdatedAt = validIso(source.last_synced_at || source.updated_at); + const match = entityMatch(kind, source, entity); + const url = canonicalUrl(source.url); + const quality = sourceQuality(kind, source, url); + const freshness = sourceFreshness(kind, publishedAt, sourceUpdatedAt, generatedAt); + return { + id: `evidence_${digest(`${kind}\n${identity}`).slice(0, 28)}`, + source_key: identity, + source_kind: kind, + source_kind_label: sourceKindLabel(kind), + label: text(source.label || source.title || identity, 240), + summary, + excerpt: text(source.excerpt || summary, 900), + url, + uri: text(source.uri, 1000), + provider: text(source.provider || (kind === "internal" ? "openviking" : kind === "public" ? "web_search" : "datapro"), 80), + provider_mode: text(source.provider_mode, 40), + raw_ref: text(source.raw_ref, 500), + query: text(source.query, 500), + purpose: text(source.purpose, 160), + site_name: kind === "public" ? text(source.site_name, 160) : "", + published_at: publishedAt, + source_updated_at: sourceUpdatedAt, + observed_at: validIso(source.observed_at) || generatedAt, + entity_match: match, + ...quality, + ...freshness, + independence_key: sourceIndependenceKey(kind, source, identity, url), + critical_claims: extractCriticalClaims(summary), + score: source.score !== null && source.score !== undefined && Number.isFinite(Number(source.score)) + ? Number(source.score) + : null, + }; +} + +function hashableEvidence(item) { + return { + id: item.id, + source_kind: item.source_kind, + source_key: item.source_key, + summary: item.summary, + published_at: item.published_at, + source_updated_at: item.source_updated_at, + entity_match: item.entity_match, + }; +} + +export function resolveCompanyEntity(company = {}) { + const canonicalName = text(company.name, 160); + const strictAliases = [ + normalizedCompanyName(canonicalName), + shortCompanyName(canonicalName), + ].filter((item, index, values) => item && values.indexOf(item) === index); + const contextualAliases = [ + ...(Array.isArray(company.aliases) ? company.aliases.map(normalizedCompanyName) : []), + parentheticalBrandAlias(canonicalName), + ] + .filter((item, index, values) => ( + item + && !strictAliases.includes(item) + && values.indexOf(item) === index + )); + const aliases = [...strictAliases, ...contextualAliases]; + return { + id: text(company.id, 200), + canonical_name: canonicalName, + normalized_name: normalizedCompanyName(canonicalName), + aliases, + strict_aliases: strictAliases, + contextual_aliases: contextualAliases, + identifiers: { + unified_social_credit_code: text(company.unified_social_credit_code || company.credit_code, 80) || null, + }, + }; +} + +export function buildDossierEvidencePack({ company, collected = {}, memoryContexts = [], generatedAt = new Date().toISOString() } = {}) { + const entity = resolveCompanyEntity(company); + if (!entity.id || !entity.canonical_name) throw new Error("company id and name are required for an evidence pack."); + const candidates = [ + ...(collected.professional || []).map((source) => normalizeEvidence("professional", source, entity, generatedAt)), + ...(collected.public_sources || []).map((source) => normalizeEvidence("public", source, entity, generatedAt)), + ...(memoryContexts || []).map((source) => normalizeEvidence("internal", source, entity, generatedAt)), + ].filter(Boolean); + const rejected = candidates + .map((item) => ({ + id: item.id, + label: item.label, + reason: evidenceRejectionReason(item), + })) + .filter((item) => item.reason); + let items = candidates + .filter((item) => !evidenceRejectionReason(item)) + .sort((a, b) => a.source_kind.localeCompare(b.source_kind) || a.id.localeCompare(b.id)); + const conflicts = evidenceConflicts(items); + const conflictFieldsByEvidence = new Map(); + for (const conflict of conflicts) { + for (const value of conflict.values) { + for (const evidenceId of value.evidence_ids) { + if (!conflictFieldsByEvidence.has(evidenceId)) conflictFieldsByEvidence.set(evidenceId, []); + conflictFieldsByEvidence.get(evidenceId).push(conflict.field); + } + } + } + items = items.map((item) => ({ + ...item, + conflict_fields: [...new Set(conflictFieldsByEvidence.get(item.id) || [])], + })); + const evidenceHash = digest(JSON.stringify(items.map(hashableEvidence))); + const dataAsOf = deriveEvidenceDataAsOf(items, generatedAt); + return { + entity, + items, + rejected, + evidence_hash: evidenceHash, + data_as_of: dataAsOf, + collected_at: generatedAt, + conflicts, + policy: evidencePolicy(items, conflicts, entity), + }; +} + +export function validateProductionEvidencePack(pack = {}) { + const policy = pack.policy || evidencePolicy(pack.items || [], pack.conflicts || [], pack.entity || {}); + const errors = []; + if (!policy.legal_entity_anchor_count) { + errors.push("缺少能够用法定名称或统一社会信用代码确认目标主体的专业来源"); + } + return { ok: errors.length === 0, errors, policy }; +} + +export function evidencePackCitations(pack = {}) { + return (pack.items || []).map((item) => ({ + id: item.id, + evidence_id: item.id, + label: item.label, + source_kind: item.source_kind_label, + url: item.url, + uri: item.uri, + summary: item.summary, + excerpt: item.excerpt, + provider: item.provider, + provider_mode: item.provider_mode, + raw_ref: item.raw_ref, + query: item.query, + purpose: item.purpose, + site_name: item.site_name, + published_at: item.published_at, + source_updated_at: item.source_updated_at, + entity_match: item.entity_match, + source_quality: item.source_quality, + source_quality_label: item.source_quality_label, + quality_tier: item.quality_tier, + official: item.official, + freshness: item.freshness, + freshness_label: item.freshness_label, + age_days: item.age_days, + independence_key: item.independence_key, + critical_claims: item.critical_claims, + conflict_fields: item.conflict_fields, + })); +} + +export function makeDossierFingerprint(dossier = {}) { + const canonical = { + title: text(dossier.title, 240), + summary: text(dossier.summary, 1000), + body: (dossier.body || []).map((paragraph) => ({ + text: text(paragraph.text, 1600), + citation_ids: [...new Set((paragraph.citation_ids || []).map(String))].sort(), + })), + citations: (dossier.citations || []).map((citation) => ({ + id: String(citation.id || citation.evidence_id || ""), + summary: text(citation.summary || citation.excerpt, 1600), + })).sort((a, b) => a.id.localeCompare(b.id)), + }; + return digest(JSON.stringify(canonical)); +} + +export function buildQaEvidence({ + dossier = null, + contexts = [], + question = "", + conversationHistory = [], + maxItems = 12, +} = {}) { + const candidates = []; + const questionPlan = analyzeQaQuestion(question, conversationHistory); + if (dossier?.id) { + const versionLabel = text(`${dossier.title || "企业档案"} V${Number(dossier.version_no || 1)}`, 240); + const dossierParagraphs = (dossier.body || []) + .map((paragraph) => text(paragraph?.text, 1800)) + .filter(Boolean); + const chunks = dossierParagraphs.length + ? dossierParagraphs + : splitQaChunks([dossier.summary, dossier.title].filter(Boolean).join("\n"), 1200); + chunks.forEach((chunk, index) => { + const section = DOSSIER_SECTION_TITLES.find((title) => ( + chunk.startsWith(`${title}:`) || chunk.startsWith(`${title}:`) + )) || `章节 ${index + 1}`; + candidates.push({ + id: `evidence_dossier_${digest(`${dossier.id}\n${index}\n${chunk}`).slice(0, 24)}`, + label: text(`${versionLabel} · ${section}`, 240), + source_kind: "企业档案", + summary: text(chunk, 1800), + url: "", + uri: "", + source_quality: "verified_dossier", + source_quality_label: "已核验企业档案", + quality_tier: 1, + freshness: "current", + freshness_label: "当前档案", + independence_key: `dossier:${dossier.id}:${index}`, + critical_claims: extractCriticalClaims(chunk), + semantic_score: null, + chunk_index: index, + }); + }); + } + for (const context of contexts || []) { + const identity = text(context.material_id, 240) + || text(context.uri, 1000) + || text(context.title, 240); + if (!identity) continue; + const content = qaText( + context.content + || context.text + || context.abstract + || context.summary, + ); + const chunks = splitQaChunks(content, 1100); + chunks.forEach((chunk, index) => { + candidates.push({ + id: `evidence_${digest(`internal\n${identity}\n${index}\n${chunk}`).slice(0, 28)}`, + label: text(context.title || identity, 240), + source_kind: text(context.source_kind || "内部资料", 80), + summary: text(qaChunkContextWindow(chunks, index), 1600), + retrieval_text: text(chunk, 1600), + url: "", + uri: text(context.uri, 1000), + source_quality: "internal", + source_quality_label: "内部授权资料", + quality_tier: 2, + freshness: "unknown", + freshness_label: "日期未知", + independence_key: `${identity}:${index}`, + critical_claims: extractCriticalClaims(chunk), + semantic_score: context.score ?? null, + chunk_index: index, + material_id: text(context.material_id, 240), + }); + }); + } + const deduped = [...new Map( + candidates + .filter((item) => item.summary && meaningfulQaSummary(item.summary)) + .map((item) => [digest(`${item.source_kind}\n${item.retrieval_text || item.summary}`), item]), + ).values()].map((item) => ({ + ...item, + ...qaEvidenceScore(questionPlan, item), + })); + const ranked = deduped.sort((left, right) => ( + Number(right.retrieval_score || 0) - Number(left.retrieval_score || 0) + || Number(left.quality_tier || 9) - Number(right.quality_tier || 9) + || left.id.localeCompare(right.id) + )); + const limit = Math.max(2, Math.min(20, Number(maxItems || 12))); + const topScore = Number(ranked[0]?.retrieval_score || 0); + const relevanceFloor = topScore >= 0.08 + ? Math.max(0.025, topScore * 0.3) + : 0; + const eligible = ranked.filter((item) => ( + Number(item.retrieval_score || 0) >= relevanceFloor + )); + const selected = []; + const sourceCounts = new Map(); + while (selected.length < limit) { + const remaining = eligible.filter((candidate) => ( + !selected.some((item) => item.id === candidate.id) + )); + if (!remaining.length) break; + const next = remaining + .map((candidate) => { + const sourceIdentity = candidate.material_id + ? `material:${candidate.material_id}` + : candidate.source_kind === "企业档案" + ? `dossier:${dossier?.id || candidate.label}` + : `source:${candidate.uri || candidate.label}`; + const sourceCount = sourceCounts.get(sourceIdentity) || 0; + return { + candidate, + sourceIdentity, + diversifiedScore: Number(candidate.retrieval_score || 0) - sourceCount * 0.025, + }; + }) + .filter((item) => (sourceCounts.get(item.sourceIdentity) || 0) < 3) + .sort((left, right) => ( + right.diversifiedScore - left.diversifiedScore + || Number(right.candidate.retrieval_score || 0) + - Number(left.candidate.retrieval_score || 0) + || left.candidate.id.localeCompare(right.candidate.id) + ))[0]; + if (!next) break; + selected.push(next.candidate); + sourceCounts.set(next.sourceIdentity, (sourceCounts.get(next.sourceIdentity) || 0) + 1); + } + return selected + .sort((left, right) => ( + Number(right.retrieval_score || 0) - Number(left.retrieval_score || 0) + )) + .map(({ retrieval_text: _retrievalText, ...item }) => item); +} + +export function buildQaEnumerationRequirements(question, evidence = []) { + const normalizedQuestion = qaText(question, 1200); + const asksForEnumeration = /哪些|有哪|列出|列举|逐项|所有|全部|包括什么|包含什么|多少(?:项|种|个)/.test(normalizedQuestion); + if (!asksForEnumeration) return []; + const queryLexemes = qaLexemes(qaEnumerationSubject(normalizedQuestion)); + const candidates = (evidence || []) + .map((item) => { + const summary = String(item.summary || ""); + const labels = qaTableRowLabels(summary); + const tableStart = summary.indexOf("|"); + const tableContext = tableStart >= 0 ? summary.slice(0, tableStart) : ""; + return { + evidence_id: String(item.id || ""), + labels, + topic_score: qaLexicalSimilarity(queryLexemes, `${tableContext} ${labels.join(" ")}`), + retrieval_score: Number(item.retrieval_score || 0), + }; + }) + .filter((item) => ( + item.evidence_id + && item.labels.length >= 2 + && item.labels.length <= 12 + && item.topic_score >= 0.015 + )) + .sort((left, right) => ( + right.topic_score - left.topic_score + || right.retrieval_score - left.retrieval_score + || right.labels.length - left.labels.length + )); + const best = candidates[0]; + return best + ? best.labels.map((label) => ({ label, evidence_id: best.evidence_id })) + : []; +} + +export function assessQaAnswerability(question, evidence = [], conversationHistory = []) { + const plan = analyzeQaQuestion(question, conversationHistory); + const ranked = [...(evidence || [])].sort((left, right) => ( + Number(right.retrieval_score || 0) - Number(left.retrieval_score || 0) + )); + const top = ranked[0] || null; + const topScore = Number(top?.retrieval_score || 0); + const groundedSignal = Boolean( + Number(top?.lexical_score || 0) >= 0.01 + || Number(top?.intent_score || 0) >= 0.05 + || top?.exact_subquery_match, + ); + const supported = ranked.length > 0 && topScore >= 0.07 && groundedSignal; + return { + supported, + score: topScore, + evidence_count: ranked.length, + intents: plan.intents, + reason: supported + ? "retrieval_supported" + : ranked.length + ? "low_relevance" + : "missing_evidence", + }; +} + +function isInternalEvidence(item) { + return /内部资料|OpenViking|历史资料|飞书|会议|文档/.test(text(item?.source_kind, 100)); +} + +function isVerifiedDossierEvidence(item) { + return item?.source_quality === "verified_dossier" + || /企业档案/.test(text(item?.source_kind, 100)); +} + +function independentExternalSources(items) { + const keys = new Set(); + for (const item of items.filter((candidate) => !isInternalEvidence(candidate))) { + keys.add(text(item.independence_key || hostname(item.url) || item.source_key || item.label || item.id, 1000)); + } + return [...keys].filter(Boolean); +} + +export function hasHighRiskAssertion(value) { + const input = text(value, 2000); + if (extractCriticalClaims(input).length) return true; + if (/(?:20\d{2}[-/.\u5e74]\d{1,2}(?:[-/.\u6708]\d{1,2}\u65e5?)?)[^\u3002\uff1b\n]{0,24}(?:\u884c\u653f\u5904\u7f5a|\u53f8\u6cd5\u8bc9\u8bbc|\u5931\u4fe1\u88ab\u6267\u884c|\u9650\u5236\u9ad8\u6d88\u8d39|\u7ecf\u8425\u5f02\u5e38|\u76d1\u7ba1\u5904\u7f5a)/u.test(input)) return true; + return /(?:(?:未发现|未涉及|不存在|存在|涉及|新增|发生|受到|列入|被执行|累计|共计).{0,18}(?:行政处罚|诉讼|失信|执行案件|经营异常|重大风险))|(?:(?:行政处罚|诉讼|失信|被执行|经营异常|重大风险).{0,18}(?:未发现|不存在|存在|涉及|新增|\d))/i.test(input); +} + +function highRiskSupportErrors(paragraph, citations, path) { + if (!hasHighRiskAssertion(paragraph.text)) return []; + if (citations.some(isVerifiedDossierEvidence)) return []; + const external = citations.filter((item) => !isInternalEvidence(item)); + const errors = []; + if (independentExternalSources(external).length < 2) { + errors.push(`${path} 的高风险事实缺少两个独立外部来源`); + } + if (!external.some((item) => Number(item.quality_tier || (/专业数据/.test(item.source_kind) ? 1 : 3)) === 1)) { + errors.push(`${path} 的高风险事实缺少专业或官方来源`); + } + for (const claim of extractCriticalClaims(paragraph.text)) { + const supporters = external.filter((item) => (item.critical_claims || extractCriticalClaims(item.summary)).some((sourceClaim) => ( + sourceClaim.field === claim.field && sourceClaim.normalized_value === claim.normalized_value + ))); + if (independentExternalSources(supporters).length < 2) { + errors.push(`${path} 的${claim.field_label}“${claim.value}”未获得双来源一致支持`); + } + } + return errors; +} + +export function validateDossierModelAnswer(parsed = {}, evidence = []) { + const allowed = new Map((evidence || []).map((item) => [String(item.id), item])); + const errors = []; + const body = (Array.isArray(parsed.body) ? parsed.body : []).map((paragraph, index) => { + const paragraphText = text(paragraph.text, 1400) + .replace(/^([^::]{2,18}):/, "$1:"); + const rawSegments = Array.isArray(paragraph.segments) && paragraph.segments.length + ? paragraph.segments + : [{ text: paragraphText, citation_ids: paragraph.citation_ids || [] }]; + const segments = rawSegments.map((segment, segmentIndex) => { + const requested = [...new Set((segment.citation_ids || []).map(String))]; + const citationIds = requested.filter((id) => allowed.has(id)); + const segmentText = text(segment.text, 800); + const path = `body[${index}].segments[${segmentIndex}]`; + if (requested.length !== citationIds.length) errors.push(`${path} 包含无效引用`); + if (!citationIds.length) errors.push(`${path} 缺少有效引用`); + const citations = citationIds.map((id) => allowed.get(id)); + if (citations.some(isInternalEvidence)) { + errors.push(`${path} 使用内部资料支撑外部事实`); + } + errors.push(...highRiskSupportErrors({ text: segmentText }, citations, path)); + return { text: segmentText, citation_ids: citationIds }; + }).filter((segment) => segment.text); + const citationIds = [...new Set(segments.flatMap((segment) => segment.citation_ids))]; + if (!segments.length) errors.push(`body[${index}] 缺少正文段落`); + return { text: paragraphText, citation_ids: citationIds, segments }; + }).filter((paragraph) => paragraph.text); + if (body.length !== DOSSIER_SECTION_TITLES.length) { + errors.push(`档案正文必须包含 ${DOSSIER_SECTION_TITLES.length} 个有引用的固定章节`); + } + DOSSIER_SECTION_TITLES.forEach((title, index) => { + if (!body[index]?.text.startsWith(`${title}:`)) { + errors.push(`body[${index}] 必须以“${title}:”开头`); + } + }); + return { body, errors }; +} + +export function validateQaModelAnswer(parsed = {}, evidence = [], options = {}) { + const allowed = new Map((evidence || []).map((item) => [String(item.id), item])); + const rawParagraphs = Array.isArray(parsed.paragraphs) + ? parsed.paragraphs + : parsed.answer + ? [{ text: parsed.answer, citation_ids: parsed.citation_ids || parsed.citation_source_ids || [] }] + : []; + const insufficient = Boolean(parsed.insufficient); + const asksForGap = QA_GAP_REQUEST_PATTERN.test(qaText(options.question, 1200)); + const sourceParagraphs = insufficient || asksForGap + ? rawParagraphs + : rawParagraphs.filter((paragraph) => ( + !QA_GAP_HEADING_PATTERN.test(qaText(paragraph?.text, 900)) + )); + const errors = []; + const paragraphs = sourceParagraphs.map((paragraph, index) => { + const requested = [...new Set((paragraph.citation_ids || []).map(String))]; + const citationIds = requested.filter((id) => allowed.has(id)); + if (requested.length !== citationIds.length) errors.push(`paragraphs[${index}] 包含无效引用`); + if (!insufficient && !citationIds.length) errors.push(`paragraphs[${index}] 缺少有效引用`); + const normalized = { + text: text(paragraph.text, 900), + citation_ids: citationIds, + }; + if (!insufficient) { + errors.push(...highRiskSupportErrors(normalized, citationIds.map((id) => allowed.get(id)), `paragraphs[${index}]`)); + const citedEvidence = citationIds.map((id) => allowed.get(id)).filter(Boolean); + const dossierEvidence = citedEvidence.filter((item) => item.source_kind === "企业档案"); + if ( + QA_RISK_HEADING_PATTERN.test(normalized.text) + && dossierEvidence.length + && !dossierEvidence.some((item) => /(?:^|·\s*)风险与关注事项/u.test(String(item.label || ""))) + ) { + errors.push(`paragraphs[${index}] 的风险结论未引用档案中的“风险与关注事项”章节`); + } + if (QA_ACTION_HEADING_PATTERN.test(normalized.text) && dossierEvidence.length) { + const bestOverlap = Math.max(0, ...dossierEvidence.map((item) => ( + qaLexicalSimilarity(qaLexemes(normalized.text), item.summary || "") + ))); + if (bestOverlap < 0.03) { + errors.push(`paragraphs[${index}] 的行动建议与所引用档案章节不匹配`); + } + } + } + return normalized; + }).filter((paragraph) => paragraph.text); + if (!paragraphs.length) errors.push("回答正文缺失"); + const answerText = paragraphs.map((paragraph) => paragraph.text).join("\n\n"); + const enumerationRequirements = Array.isArray(options.enumerationRequirements) + ? options.enumerationRequirements + : []; + const normalizedAnswer = qaEnumerationKey(answerText); + const missingEnumerationItems = insufficient + ? [] + : enumerationRequirements.filter((item) => ( + !qaEnumerationAliases(item?.label).some((alias) => normalizedAnswer.includes(alias)) + )); + if (missingEnumerationItems.length) { + errors.push(`回答遗漏枚举项:${missingEnumerationItems.map((item) => item.label).join("、")}`); + } + const usedIds = [...new Set(paragraphs.flatMap((paragraph) => paragraph.citation_ids))]; + return { + paragraphs, + text: answerText, + citation_ids: usedIds, + citations: usedIds.map((id) => allowed.get(id)), + insufficient, + missing_enumeration_items: missingEnumerationItems, + errors, + }; +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/frontend/staticFrontend.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/frontend/staticFrontend.js new file mode 100644 index 00000000..e79ce219 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/frontend/staticFrontend.js @@ -0,0 +1,79 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +const CONTENT_TYPES = new Map([ + [".css", "text/css; charset=utf-8"], + [".gif", "image/gif"], + [".html", "text/html; charset=utf-8"], + [".ico", "image/x-icon"], + [".jpeg", "image/jpeg"], + [".jpg", "image/jpeg"], + [".js", "text/javascript; charset=utf-8"], + [".json", "application/json; charset=utf-8"], + [".png", "image/png"], + [".svg", "image/svg+xml; charset=utf-8"], + [".webp", "image/webp"], +]); + +function resolveFrontendFile(rootDir, pathname) { + let decodedPath; + try { + decodedPath = decodeURIComponent(pathname); + } catch { + return null; + } + + const relativePath = decodedPath === "/" + ? "index.html" + : decodedPath.replace(/^\/+/, ""); + if (!relativePath || relativePath.includes("\0") || relativePath.includes("\\")) return null; + if (relativePath.split("/").some((segment) => segment === "..")) return null; + + const resolvedRoot = path.resolve(rootDir); + const resolvedFile = path.resolve(resolvedRoot, relativePath); + if (resolvedFile !== resolvedRoot && !resolvedFile.startsWith(`${resolvedRoot}${path.sep}`)) return null; + return resolvedFile; +} + +async function existingFile(filePath) { + try { + const fileStat = await stat(filePath); + if (fileStat.isFile()) return filePath; + if (!fileStat.isDirectory()) return null; + const indexPath = path.join(filePath, "index.html"); + return (await stat(indexPath)).isFile() ? indexPath : null; + } catch { + return null; + } +} + +function cacheControl(filePath) { + const extension = path.extname(filePath).toLowerCase(); + if ([".html", ".js", ".css"].includes(extension)) return "no-store"; + return "public, max-age=3600"; +} + +export function createStaticFrontend({ rootDir }) { + const resolvedRoot = path.resolve(rootDir); + + return async function serveStaticFrontend(req, res, pathname) { + if (!["GET", "HEAD"].includes(req.method || "GET")) return false; + if (pathname === "/api" || pathname.startsWith("/api/")) return false; + + const candidate = resolveFrontendFile(resolvedRoot, pathname); + const filePath = candidate ? await existingFile(candidate) : null; + if (!filePath) return false; + + const body = await readFile(filePath); + const contentType = CONTENT_TYPES.get(path.extname(filePath).toLowerCase()) || "application/octet-stream"; + res.setHeader("Content-Type", contentType); + res.setHeader("Content-Length", body.byteLength); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Referrer-Policy", "same-origin"); + res.setHeader("Cache-Control", cacheControl(filePath)); + res.writeHead(200); + if (req.method === "HEAD") res.end(); + else res.end(body); + return true; + }; +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/limits/paidWorkflowGuard.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/limits/paidWorkflowGuard.js new file mode 100644 index 00000000..bd6ad06e --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/limits/paidWorkflowGuard.js @@ -0,0 +1,228 @@ +import { HttpError } from "../utils/http.js"; +import { makeId } from "../utils/ids.js"; + +const clone = (value) => JSON.parse(JSON.stringify(value)); + +function nonNegativeInteger(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback; +} + +function positiveInteger(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function dateKey(value, timeZone) { + return new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date(value)); +} + +function errorDetails(error) { + const details = error?.details; + if (!details) return {}; + if (typeof details === "object") return details; + try { + return JSON.parse(details); + } catch { + return {}; + } +} + +function limitError(error) { + const message = String(error?.message || ""); + const details = errorDetails(error); + if (message.includes("paid_workflow_concurrency_exceeded")) { + return new HttpError(429, "paid_workflow_concurrency_exceeded", "当前付费任务已达到并发上限,请稍后重试。", { + running: Number(details.running || 0), + limit: Number(details.limit || 0), + retry_after_seconds: Number(details.retry_after_seconds || 30), + }); + } + if (message.includes("paid_workflow_daily_limit_exceeded")) { + return new HttpError(429, "paid_workflow_daily_limit_exceeded", "今日付费任务次数已达到工作区上限。", { + used: Number(details.used || 0), + limit: Number(details.limit || 0), + timezone: String(details.timezone || ""), + }); + } + return null; +} + +export function paidWorkflowLimits(env) { + return Object.freeze({ + max_concurrent: nonNegativeInteger(env.value("PAID_WORKFLOW_MAX_CONCURRENCY", "2"), 2), + daily_limit: nonNegativeInteger(env.value("PAID_WORKFLOW_DAILY_LIMIT", "100"), 100), + timezone: String(env.value("PAID_WORKFLOW_BUDGET_TIMEZONE", "Asia/Shanghai") || "Asia/Shanghai").trim(), + stale_after_seconds: positiveInteger(env.value("PAID_WORKFLOW_STALE_AFTER_SECONDS", "1800"), 1800), + }); +} + +export class PaidWorkflowGuard { + constructor(options = {}) { + this.env = options.env; + this.repository = options.repository || null; + this.failClosed = Boolean(options.failClosed); + this.listLocalJobs = options.listLocalJobs || (() => []); + this.limits = paidWorkflowLimits(this.env); + this.localReservations = new Map(); + this.localQueue = Promise.resolve(); + try { + dateKey(new Date(), this.limits.timezone); + } catch { + throw new Error(`PAID_WORKFLOW_BUDGET_TIMEZONE is invalid: ${this.limits.timezone}`); + } + } + + async reserve(job) { + if (job.is_paid === false) return { job: clone(job), budget: null }; + const reservationId = makeId("usage_reservation"); + const candidate = { ...clone(job), is_paid: true, reservation_id: reservationId }; + + if (typeof this.repository?.reservePaidWorkflow === "function") { + try { + return await this.repository.reservePaidWorkflow(candidate, reservationId, this.limits); + } catch (error) { + const known = limitError(error); + if (known) throw known; + throw new HttpError(503, "usage_guard_unavailable", "付费任务保护暂时不可用,任务未执行。", { + reason: String(error?.code || "reservation_failed"), + }); + } + } + + if (this.failClosed) { + throw new HttpError(503, "usage_guard_unavailable", "生产环境缺少持久化付费任务保护,任务未执行。", { + reason: "repository_reservation_not_supported", + }); + } + return this.withLocalLock(() => this.reserveLocal(candidate)); + } + + async finish(job) { + if (!job?.is_paid || !job?.reservation_id) return clone(job); + if (typeof this.repository?.finishPaidWorkflow === "function") { + try { + return await this.repository.finishPaidWorkflow(job, job.reservation_id); + } catch (error) { + throw new HttpError(503, "usage_guard_unavailable", "付费任务状态未能可靠落库。", { + reason: String(error?.code || "reservation_release_failed"), + }); + } + } + if (this.failClosed) { + throw new HttpError(503, "usage_guard_unavailable", "生产环境缺少持久化付费任务保护。", { + reason: "repository_release_not_supported", + }); + } + const reservation = this.localReservations.get(job.reservation_id); + if (reservation?.status === "running") { + reservation.status = job.status; + reservation.released_at = job.finished_at || new Date().toISOString(); + } + return clone(job); + } + + async snapshot() { + if (typeof this.repository?.getPaidWorkflowUsage === "function") { + try { + const usage = await this.repository.getPaidWorkflowUsage(this.limits.timezone); + return this.publicSnapshot(usage); + } catch (error) { + if (this.failClosed) { + throw new HttpError(503, "usage_guard_unavailable", "无法读取付费任务用量。", { + reason: String(error?.code || "usage_snapshot_failed"), + }); + } + } + } + return this.publicSnapshot(this.localUsage()); + } + + withLocalLock(operation) { + const result = this.localQueue.then(operation, operation); + this.localQueue = result.catch(() => {}); + return result; + } + + reserveLocal(job) { + const now = new Date(); + for (const reservation of this.localReservations.values()) { + if (reservation.status === "running" && new Date(reservation.expires_at) <= now) { + reservation.status = "expired"; + reservation.released_at = now.toISOString(); + } + } + const usage = this.localUsage(now); + if (this.limits.max_concurrent > 0 && usage.running >= this.limits.max_concurrent) { + throw new HttpError(429, "paid_workflow_concurrency_exceeded", "当前付费任务已达到并发上限,请稍后重试。", { + running: usage.running, + limit: this.limits.max_concurrent, + retry_after_seconds: Math.min(this.limits.stale_after_seconds, 60), + }); + } + if (this.limits.daily_limit > 0 && usage.used_today >= this.limits.daily_limit) { + throw new HttpError(429, "paid_workflow_daily_limit_exceeded", "今日付费任务次数已达到工作区上限。", { + used: usage.used_today, + limit: this.limits.daily_limit, + timezone: this.limits.timezone, + }); + } + const reservedAt = now.toISOString(); + this.localReservations.set(job.reservation_id, { + id: job.reservation_id, + job_id: job.id, + job_type: job.job_type, + status: "running", + reserved_at: reservedAt, + expires_at: new Date(now.getTime() + this.limits.stale_after_seconds * 1000).toISOString(), + }); + return { + job: clone(job), + budget: this.publicSnapshot({ + running: usage.running + 1, + used_today: usage.used_today + 1, + by_job_type: { + ...usage.by_job_type, + [job.job_type]: Number(usage.by_job_type[job.job_type] || 0) + 1, + }, + }), + }; + } + + localUsage(now = new Date()) { + const today = dateKey(now, this.limits.timezone); + const usage = { running: 0, used_today: 0, by_job_type: {} }; + for (const reservation of this.localReservations.values()) { + if (reservation.status === "running" && new Date(reservation.expires_at) > now) usage.running += 1; + if (dateKey(reservation.reserved_at, this.limits.timezone) !== today) continue; + usage.used_today += 1; + usage.by_job_type[reservation.job_type] = Number(usage.by_job_type[reservation.job_type] || 0) + 1; + } + if (!this.localReservations.size) { + for (const job of this.listLocalJobs()) { + if (!job?.is_paid || !job.created_at || dateKey(job.created_at, this.limits.timezone) !== today) continue; + usage.used_today += 1; + usage.by_job_type[job.job_type] = Number(usage.by_job_type[job.job_type] || 0) + 1; + if (job.status === "running") usage.running += 1; + } + } + return usage; + } + + publicSnapshot(usage = {}) { + return { + running: Number(usage.running || 0), + max_concurrent: this.limits.max_concurrent, + used_today: Number(usage.used_today || 0), + daily_limit: this.limits.daily_limit, + timezone: this.limits.timezone, + by_job_type: usage.by_job_type && typeof usage.by_job_type === "object" ? usage.by_job_type : {}, + counting_unit: "paid_workflow_attempt", + }; + } +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/limits/providerCircuitBreaker.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/limits/providerCircuitBreaker.js new file mode 100644 index 00000000..749a275e --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/limits/providerCircuitBreaker.js @@ -0,0 +1,85 @@ +const RETRYABLE_CATEGORIES = new Set(["network", "timeout", "rate_limit", "upstream"]); + +function positiveInteger(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function shouldCountFailure(error) { + if (!error) return false; + if (error.code === "provider_circuit_open") return false; + return Boolean(error.retryable) || RETRYABLE_CATEGORIES.has(String(error.category || "").toLowerCase()); +} + +function circuitOpenError(provider, retryAfterSeconds) { + const error = new Error(`${provider} is temporarily unavailable after repeated upstream failures.`); + error.code = "provider_circuit_open"; + error.category = "upstream"; + error.retryable = true; + error.retry_after_seconds = Math.max(1, retryAfterSeconds); + return error; +} + +export class ProviderCircuitBreaker { + constructor(options = {}) { + this.enabled = Boolean(options.enabled); + this.failureThreshold = positiveInteger(options.failureThreshold, 5); + this.cooldownMs = positiveInteger(options.cooldownSeconds, 60) * 1000; + this.now = options.now || (() => Date.now()); + this.states = new Map(); + } + + beforeCall(providerName) { + if (!this.enabled) return { provider: String(providerName || "unknown"), halfOpen: false }; + const provider = String(providerName || "unknown"); + const state = this.states.get(provider); + if (!state?.openUntil) return { provider, halfOpen: false }; + + const remainingMs = state.openUntil - this.now(); + if (remainingMs > 0 || state.probeInFlight) { + throw circuitOpenError(provider, Math.ceil(Math.max(remainingMs, 1000) / 1000)); + } + + state.probeInFlight = true; + return { provider, halfOpen: true }; + } + + recordSuccess(token = {}) { + if (!this.enabled) return; + this.states.delete(String(token.provider || "unknown")); + } + + recordFailure(token = {}, error = null) { + if (!this.enabled) return; + const provider = String(token.provider || "unknown"); + const existing = this.states.get(provider) || { + consecutiveFailures: 0, + openUntil: 0, + probeInFlight: false, + }; + existing.probeInFlight = false; + + if (!shouldCountFailure(error)) { + this.states.delete(provider); + return; + } + + existing.consecutiveFailures += 1; + if (token.halfOpen || existing.consecutiveFailures >= this.failureThreshold) { + existing.openUntil = this.now() + this.cooldownMs; + existing.consecutiveFailures = this.failureThreshold; + } + this.states.set(provider, existing); + } + + snapshot() { + const now = this.now(); + return [...this.states.entries()].map(([provider, state]) => ({ + provider, + consecutive_failures: state.consecutiveFailures, + open: Boolean(state.openUntil && state.openUntil > now), + retry_after_seconds: state.openUntil > now ? Math.ceil((state.openUntil - now) / 1000) : 0, + half_open_probe: Boolean(state.probeInFlight), + })); + } +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/observability/providerRunStore.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/observability/providerRunStore.js new file mode 100644 index 00000000..952c3696 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/observability/providerRunStore.js @@ -0,0 +1,272 @@ +import { makeId } from "../utils/ids.js"; +import { nowIso } from "../utils/time.js"; + +const clone = (value) => JSON.parse(JSON.stringify(value)); + +function redactSecrets(value, maxLength = 500) { + return String(value || "") + .replace(/Bearer\s+[^\s,;]+/gi, "Bearer [REDACTED]") + .replace(/ark-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}-[0-9a-f]{5}/gi, "[REDACTED]") + .replace(/AKLT[A-Za-z0-9]{20,}/g, "[REDACTED]") + .replace(/((?:api|access|secret)[_-]?key)\s*[:=]\s*[^\s,;]+/gi, "$1=[REDACTED]") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxLength); +} + +function safeError(error) { + if (!error) return null; + const validationErrors = Array.isArray(error.details?.validation_errors) + ? error.details.validation_errors + .map((item) => redactSecrets(item)) + .filter(Boolean) + .slice(0, 16) + : []; + return { + code: redactSecrets(error.code || "provider_error", 80), + message: redactSecrets(error.message || "Provider call failed."), + category: redactSecrets(error.category || "unknown", 80), + retryable: Boolean(error.retryable), + validation_errors: validationErrors, + }; +} + +function safeUsage(usage) { + if (!usage || typeof usage !== "object") return null; + const result = {}; + for (const key of ["prompt_tokens", "completion_tokens", "total_tokens", "reasoning_tokens"]) { + const value = Number(usage[key]); + if (Number.isFinite(value)) result[key] = value; + } + return Object.keys(result).length ? result : null; +} + +function durationMs(startedAt, finishedAt) { + return Math.max(0, new Date(finishedAt).getTime() - new Date(startedAt).getTime()); +} + +export class ProviderRunStore { + constructor(options = {}) { + this.maxRuns = Math.max(20, Number(options.maxRuns || 200)); + this.runs = []; + this.repository = options.repository || null; + this.failOnPersistenceError = Boolean(options.failOnPersistenceError); + this.persistenceError = null; + this.circuitBreaker = options.circuitBreaker || null; + } + + async startRun(input = {}) { + const run = { + id: makeId("provider_run"), + operation: redactSecrets(input.operation || "provider_workflow", 120), + status: "running", + app_mode: "production", + entity_type: redactSecrets(input.entity_type || "", 80), + entity_id: redactSecrets(input.entity_id || "", 160), + job_id: redactSecrets(input.job_id || "", 160) || null, + started_at: nowIso(), + finished_at: null, + duration_ms: null, + result_ref: null, + error: null, + steps: [], + }; + this.runs.unshift(run); + this.runs.splice(this.maxRuns); + try { + await this.persistRun(run, { strict: true }); + } catch (error) { + this.runs = this.runs.filter((item) => item.id !== run.id); + throw error; + } + return clone(run); + } + + async startStep(runId, input = {}) { + const run = this.requireRun(runId); + const step = { + id: makeId("provider_step"), + sequence: run.steps.length + 1, + provider: redactSecrets(input.provider || "unknown", 80), + operation: redactSecrets(input.operation || "provider_call", 120), + status: "running", + input_summary: redactSecrets(input.input_summary || ""), + output_summary: "", + request_id: null, + raw_ref: null, + usage: null, + attempts: Math.max(1, Number(input.attempts || 1)), + started_at: nowIso(), + finished_at: null, + latency_ms: null, + error: null, + }; + run.steps.push(step); + await this.persistRun(run, { strict: true }); + return clone(step); + } + + async finishStep(runId, stepId, result = {}) { + const run = this.requireRun(runId); + const step = run.steps.find((item) => item.id === stepId); + if (!step) throw new Error(`Provider step was not found: ${stepId}`); + const finishedAt = nowIso(); + const explicitlySkipped = result.status === "skipped"; + step.status = explicitlySkipped ? "skipped" : result.ok === false ? "failed" : "succeeded"; + step.output_summary = redactSecrets(result.output_summary || result.summary || ""); + step.request_id = redactSecrets(result.request_id || "", 180) || null; + step.raw_ref = redactSecrets(result.raw_ref || "", 240) || null; + step.usage = safeUsage(result.usage); + step.attempts = Math.max(1, Number(result.attempts || step.attempts || 1)); + step.finished_at = finishedAt; + step.latency_ms = Number.isFinite(Number(result.latency_ms)) + ? Math.max(0, Number(result.latency_ms)) + : durationMs(step.started_at, finishedAt); + step.error = safeError(result.error); + await this.persistRun(run, { strict: true }); + return clone(step); + } + + async skipStep(runId, input = {}) { + const step = await this.startStep(runId, input); + return this.finishStep(runId, step.id, { + status: "skipped", + output_summary: input.output_summary || "Provider step was not enabled for this run.", + error: input.error || null, + }); + } + + async executeStep(runId, input, operation) { + const step = await this.startStep(runId, input); + let circuitToken = null; + try { + circuitToken = this.circuitBreaker?.beforeCall(input?.provider); + const result = await operation(); + const succeeded = result?.ok !== false; + if (succeeded) { + this.circuitBreaker?.recordSuccess(circuitToken); + } else { + this.circuitBreaker?.recordFailure(circuitToken, result?.error); + } + await this.finishStep(runId, step.id, { + ...(result || {}), + ok: succeeded, + output_summary: succeeded ? input.output_summary || result?.summary || "" : "", + }); + return result; + } catch (error) { + if (circuitToken) this.circuitBreaker?.recordFailure(circuitToken, error); + try { + await this.finishStep(runId, step.id, { + ok: false, + error: { + code: error.code || "provider_exception", + message: error.message || "Provider call failed.", + category: error.category || "unknown", + retryable: error.retryable, + }, + }); + } catch (persistenceError) { + if (this.failOnPersistenceError) throw persistenceError; + } + throw error; + } + } + + async completeRun(runId, input = {}) { + const run = this.requireRun(runId); + const finishedAt = nowIso(); + const hasFailedStep = run.steps.some((step) => step.status === "failed"); + run.status = hasFailedStep ? "succeeded_with_issues" : "succeeded"; + run.finished_at = finishedAt; + run.duration_ms = durationMs(run.started_at, finishedAt); + run.result_ref = redactSecrets(input.result_ref || "", 240) || null; + await this.persistRun(run, { strict: true }); + return clone(run); + } + + async failRun(runId, error) { + const run = this.requireRun(runId); + const finishedAt = nowIso(); + run.status = "failed"; + run.finished_at = finishedAt; + run.duration_ms = durationMs(run.started_at, finishedAt); + run.error = safeError(error); + await this.persistRun(run, { strict: true }); + return clone(run); + } + + async cancelRun(runId, input = {}) { + const run = this.requireRun(runId); + if (run.status === "cancelled") return clone(run); + if (run.status !== "running") return clone(run); + const finishedAt = nowIso(); + run.status = "cancelled"; + run.finished_at = finishedAt; + run.duration_ms = durationMs(run.started_at, finishedAt); + run.error = null; + for (const step of run.steps) { + if (step.status !== "running") continue; + step.status = "cancelled"; + step.output_summary = redactSecrets(input.summary || "任务已由用户取消。", 500); + step.finished_at = finishedAt; + step.latency_ms = durationMs(step.started_at, finishedAt); + step.error = null; + } + await this.persistRun(run, { strict: true }); + return clone(run); + } + + async list(filters = {}) { + const operation = String(filters.operation || "").trim(); + const entityId = String(filters.entity_id || "").trim(); + const requestedLimit = Number(filters.limit || 20); + const limit = Math.max(1, Math.min(Number.isFinite(requestedLimit) ? requestedLimit : 20, 100)); + const memoryRuns = this.runs + .filter((run) => !operation || run.operation === operation) + .filter((run) => !entityId || run.entity_id === entityId) + .map((run) => clone(run)); + const persistedRuns = await this.readPersisted("listProviderRuns", [{ operation, entity_id: entityId, limit }], []); + return [...new Map([...memoryRuns, ...persistedRuns].map((run) => [run.id, run])).values()] + .sort((a, b) => String(b.started_at || "").localeCompare(String(a.started_at || ""))) + .slice(0, limit); + } + + async get(runId) { + const run = this.runs.find((item) => item.id === runId); + if (run) return clone(run); + return this.readPersisted("getProviderRun", [runId], null); + } + + requireRun(runId) { + const run = this.runs.find((item) => item.id === runId); + if (!run) throw new Error(`Provider run was not found: ${runId}`); + return run; + } + + async persistRun(run, options = {}) { + if (typeof this.repository?.persistProviderRun !== "function") return null; + try { + const result = await this.repository.persistProviderRun(clone(run)); + this.persistenceError = null; + return result; + } catch (error) { + this.persistenceError = safeError(error); + if (options.strict && this.failOnPersistenceError) throw error; + return null; + } + } + + async readPersisted(method, args, fallback) { + if (typeof this.repository?.[method] !== "function") return fallback; + try { + const result = await this.repository[method](...args); + this.persistenceError = null; + return result ?? fallback; + } catch (error) { + this.persistenceError = safeError(error); + if (this.failOnPersistenceError) throw error; + return fallback; + } + } +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/citationValidator.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/citationValidator.js new file mode 100644 index 00000000..5052600b --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/citationValidator.js @@ -0,0 +1,22 @@ +function unique(values) { + return [...new Set((values || []).filter(Boolean))]; +} + +export function collectCitationContext(cards = [], sources = []) { + const cardIds = new Set(cards.map((card) => card.id).filter(Boolean)); + const sourceIds = new Set(sources.map((source) => source.id).filter(Boolean)); + return { cardIds, sourceIds }; +} + +export function filterCitationIds(ids, allowed) { + return unique((ids || []).map((id) => String(id || "").trim()).filter((id) => allowed.has(id))); +} + +export function hasAnyCitation(item) { + return Boolean(item?.citation_card_ids?.length || item?.citation_source_ids?.length); +} + +export function sourceLabelsForIds(sources = [], ids = []) { + const byId = new Map(sources.map((source) => [source.id, source.label || source.url || source.id])); + return ids.map((id) => byId.get(id)).filter(Boolean); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/dataProProvider.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/dataProProvider.js new file mode 100644 index 00000000..28b2da99 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/dataProProvider.js @@ -0,0 +1,352 @@ +import { createEnvReader } from "../config/runtimeEnv.js"; +import { executeProviderCall, providerFailure, providerSuccess } from "./providerResult.js"; + +const DEFAULT_MCP_URL = "https://datapro.hqd.cn-beijing.volces.com/mcp"; +const DEFAULT_TIMEOUT_MS = 45000; + +function enabled(value) { + return ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase()); +} + +function companyContext(object = {}) { + return [ + object.name, + object.industry, + object.location, + object.business_scope, + object.businessScope, + ...(Array.isArray(object.tags) ? object.tags : []), + ].filter(Boolean).join(" "); +} + +function appendUniqueQuery(target, item) { + if (!item?.label || !item?.query) return; + if (target.some((existing) => existing.label === item.label)) return; + target.push(item); +} + +function parseMcpPayload(text) { + if (!text) return {}; + if (text.startsWith("event:")) { + const line = text.split(/\r?\n/).find((item) => item.startsWith("data:")); + return line ? JSON.parse(line.slice(5).trim()) : {}; + } + return JSON.parse(text); +} + +function extractTextContent(result) { + const content = result?.content; + if (!Array.isArray(content)) return ""; + return content + .map((item) => { + if (typeof item?.text === "string") return item.text; + if (item?.type === "json" || item?.json) return JSON.stringify(item.json || item); + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +function firstJsonObject(text) { + const trimmed = String(text || "").trim(); + if (!trimmed) return null; + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start < 0 || end <= start) return null; + try { + return JSON.parse(trimmed.slice(start, end + 1)); + } catch { + return null; + } +} + +function summarizeText(text, maxLength = 4000) { + return String(text || "") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxLength); +} + +function isPrimitiveValue(value) { + return ["string", "number", "boolean"].includes(typeof value); +} + +function cleanValue(value, maxLength = 160) { + if (value === undefined || value === null || value === "") return ""; + if (!isPrimitiveValue(value)) return ""; + return String(value).replace(/\s+/g, " ").trim().slice(0, maxLength); +} + +function summarizeDataItem(item) { + if (!item || typeof item !== "object") return ""; + const preferredKeys = [ + "公司名称", + "企业名称", + "统一社会信用代码", + "注册号", + "法人姓名", + "法定代表人", + "公司组织类型", + "注册资本", + "注册地址", + "成立日期", + "经营状态", + "经营范围", + "业务范围", + "主营业务", + "风险类型", + "案件类型", + "案件名称", + "案号", + "案由", + "涉案金额", + "立案日期", + "开庭日期", + "处罚决定日期", + "处罚事由", + "处罚结果", + "被执行人", + "原告", + "被告", + "标题", + "公告名称", + "发布时间", + "发布日期", + "中标金额", + "招标人", + "中标人", + "项目名称", + ]; + const parts = []; + for (const key of preferredKeys) { + const value = cleanValue( + item[key], + /经营范围|业务范围|主营业务|案由|处罚事由|处罚结果/.test(key) ? 360 : 180, + ); + if (value) parts.push(`${key}:${value}`); + if (parts.length >= 12) break; + } + if (!parts.length) { + for (const [key, value] of Object.entries(item)) { + if (/^(?:id|trace[_-]?id|request[_-]?id|企业ID|关联主键)$/i.test(key)) continue; + const itemText = cleanValue(value, 180); + if (!itemText) continue; + parts.push(`${key}:${itemText}`); + if (parts.length >= 12) break; + } + } + return parts.join(";"); +} + +function summarizeParsedResult(parsed, fallbackText) { + const items = Array.isArray(parsed?.items) ? parsed.items : []; + if (items.length) { + return items + .slice(0, 5) + .map(summarizeDataItem) + .filter(Boolean) + .join("\n") + .slice(0, 4000); + } + const message = cleanValue(parsed?.msg || parsed?.message, 160); + if (message && Number(parsed?.code ?? 0) === 0) return `DataPro 返回成功:${message}`; + return summarizeText(fallbackText); +} + +export class DataProProvider { + constructor(options = {}) { + this.env = options.env || createEnvReader(); + this.fetchImpl = options.fetchImpl || globalThis.fetch; + } + + get apiKey() { + return this.env.value("DATAPRO_API_KEY") || this.env.value("AGENT_PLAN_API_KEY"); + } + + get mcpUrl() { + return this.env.value("DATAPRO_MCP_URL", DEFAULT_MCP_URL); + } + + get runEnabled() { + return enabled(this.env.value("DATAPRO_RUN_ENABLED", "false")); + } + + get maxSources() { + return Math.max(1, Math.min(this.env.number("DATAPRO_MAX_SOURCES", 4), 5)); + } + + get timeoutMs() { + return Math.max(1000, this.env.number("DATAPRO_TIMEOUT_MS", DEFAULT_TIMEOUT_MS)); + } + + get maxRetries() { + return Math.max(0, Math.min(this.env.number("DATAPRO_MAX_RETRIES", 1), 2)); + } + + isConfigured() { + return Boolean(this.apiKey && this.mcpUrl); + } + + isRunEnabled() { + return this.isConfigured() && this.runEnabled; + } + + buildCompanyQuery(object) { + return [ + object.name, + "企业工商信息", + "统一社会信用代码", + "注册资本", + "经营范围", + "知识产权", + "软件著作权", + ].join(" "); + } + + planDossierQueries(object, options = {}) { + const name = cleanValue(object?.name, 200); + if (!name) return []; + const context = companyContext(object); + const maxSources = Math.max( + 1, + Math.min(Number(options.maxSources || this.maxSources) || this.maxSources, 5), + ); + const queries = []; + const businessQuery = { + label: "企业工商数据库", + purpose: "主体、经营与知识产权核验", + query: `${name} 企业工商数据 基本信息 经营状况 经营范围 知识产权 专利`, + }; + + appendUniqueQuery(queries, businessQuery); + + appendUniqueQuery(queries, { + label: "企业风险数据库", + purpose: "风险与关注事项核验", + query: `${name} 企业风险数据 司法诉讼 行政处罚 失信被执行 经营异常 限制高消费`, + }); + + if (/整车|汽车制造|新能源汽车|乘用车|商用车|车企/.test(context)) { + appendUniqueQuery(queries, { + label: "汽车销量数据库", + purpose: "汽车市场与销量变化核验", + query: `${name} 汽车销量数据库 最新月度销量 品牌 车系 厂商 同比 环比`, + }); + } + + if (/股份有限公司|上市|证券|银行|金融|保险|基金|期货|信托/.test(context)) { + appendUniqueQuery(queries, { + label: "金融数据库", + purpose: "上市与财务信息核验", + query: `${name} 金融数据库 证券代码 最新财务指标 营业收入 净利润 市值 公告`, + }); + } + + if (/科研|研究院|高校|生物医药|制药|医疗器械|半导体|人工智能/.test(context)) { + appendUniqueQuery(queries, { + label: "科研学术数据搜索服务", + purpose: "技术与科研能力核验", + query: `${name} 科研学术数据 论文 专利 技术方向 研发成果`, + }); + } + + return queries.slice(0, maxSources); + } + + async callTool(query) { + if (!this.isConfigured()) { + return providerFailure("datapro", { code: "missing_config", message: "AGENT_PLAN_API_KEY or DATAPRO_MCP_URL is not configured." }); + } + + return executeProviderCall( + () => this.callToolOnce(query), + { max_retries: this.maxRetries }, + ); + } + + async callToolOnce(query) { + const startedAt = Date.now(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + let response; + let payload; + try { + response = await this.fetchImpl(this.mcpUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "X-Agent-Plan-Key": this.apiKey, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: `datapro-${Date.now()}`, + method: "tools/call", + params: { + name: "dataPro_search", + arguments: { query }, + }, + }), + signal: controller.signal, + }); + payload = parseMcpPayload(await response.text()); + } catch (error) { + return providerFailure("datapro", { + code: error.name === "AbortError" ? "timeout" : "network_error", + message: error.name === "AbortError" ? "DataPro request timed out." : error.message, + }, { latency_ms: Date.now() - startedAt }); + } finally { + clearTimeout(timeout); + } + + if (!response.ok || payload?.error) { + return providerFailure("datapro", { + code: payload?.error?.code || "provider_error", + message: payload?.error?.message || `HTTP ${response.status}`, + }, { + http_status: response.status, + latency_ms: Date.now() - startedAt, + }); + } + + const result = payload.result || {}; + const text = extractTextContent(result); + const parsed = firstJsonObject(text); + const traceId = parsed?.trace_id || parsed?.traceId || parsed?.data?.trace_id || null; + const parsedCode = parsed?.code ?? parsed?.Code ?? parsed?.data?.code; + const isError = Boolean(result.isError || parsed?.isError || (parsedCode !== undefined && Number(parsedCode) !== 0)); + if (isError) { + return providerFailure("datapro", { + code: parsedCode !== undefined ? String(parsedCode) : "tool_error", + message: parsed?.msg || parsed?.message || summarizeText(text, 240) || "DataPro tool returned an error.", + }, { + request_id: traceId, + raw_ref: traceId ? `datapro:${traceId}` : null, + latency_ms: Date.now() - startedAt, + }); + } + + return providerSuccess("datapro", { + query, + request_id: traceId, + raw_ref: traceId ? `datapro:${traceId}` : null, + latency_ms: Date.now() - startedAt, + text, + parsed, + item_summaries: (Array.isArray(parsed?.items) ? parsed.items : []) + .slice(0, 5) + .map(summarizeDataItem) + .filter(Boolean), + summary: summarizeParsedResult(parsed, text), + }); + } + + async queryCompanyFacts(object) { + const query = this.buildCompanyQuery(object); + return this.callTool(query); + } +} + +export function createDataProProvider(options = {}) { + return new DataProProvider(options); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/modelProvider.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/modelProvider.js new file mode 100644 index 00000000..b275f7c9 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/modelProvider.js @@ -0,0 +1,699 @@ +import { createEnvReader } from "../config/runtimeEnv.js"; +import { collectCitationContext, filterCitationIds, hasAnyCitation, sourceLabelsForIds } from "./citationValidator.js"; +import { + executeProviderCall, + providerFailure, + providerSuccess, +} from "./providerResult.js"; + +const DEFAULT_BASE_URL = "https://ark.cn-beijing.volces.com/api/plan/v3"; +const DEFAULT_MODEL_NAME = "ark-code-latest"; +const DEFAULT_TIMEOUT_MS = 90000; +const MAX_INVALID_JSON_CONTENT_LENGTH = 30000; + +function enabled(value) { + return ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase()); +} + +function stripJsonFence(content) { + const text = String(content || "").trim(); + const unfenced = text.startsWith("```") + ? text + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```$/i, "") + .trim() + : text; + + for (let start = 0; start < unfenced.length; start += 1) { + const opening = unfenced[start]; + if (opening !== "{" && opening !== "[") continue; + const stack = [opening]; + let inString = false; + let escaped = false; + for (let index = start + 1; index < unfenced.length; index += 1) { + const character = unfenced[index]; + if (inString) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === "\"") inString = false; + continue; + } + if (character === "\"") { + inString = true; + continue; + } + if (character === "{" || character === "[") { + stack.push(character); + continue; + } + if (character !== "}" && character !== "]") continue; + const expected = character === "}" ? "{" : "["; + if (stack.at(-1) !== expected) break; + stack.pop(); + if (!stack.length) return unfenced.slice(start, index + 1); + } + } + + if (unfenced.startsWith("{") || unfenced.startsWith("[")) return unfenced; + return unfenced; +} + +function normalizeError(payload) { + const error = payload?.error || payload?.ResponseMetadata?.Error || payload?.Error || null; + if (!error) return null; + return { + code: error.code || error.Code || "provider_error", + message: error.message || error.Message || "Model provider returned an error.", + }; +} + +function conciseSource(source) { + return { + id: source.id, + type: source.type, + label: source.label, + url: source.url, + snippet: source.snippet || "", + summary: source.summary || "", + provider: source.provider, + provider_mode: source.provider_mode, + }; +} + +function baselineForPrompt(baseline) { + return { + id: baseline.id, + dimension: baseline.dimension || baseline.title, + title: baseline.title || baseline.dimension, + value: baseline.value, + source_ids: baseline.source_ids || [], + }; +} + +function asString(value, fallback = "") { + return String(value ?? fallback).trim(); +} + +function stripAndParseJson(content) { + return JSON.parse(stripJsonFence(content)); +} + +function invalidJsonContent(content) { + return String(content || "") + .trim() + .slice(0, MAX_INVALID_JSON_CONTENT_LENGTH); +} + +function extractResponseText(payload) { + if (typeof payload?.output_text === "string") return payload.output_text; + const texts = []; + for (const output of payload?.output || []) { + if (typeof output?.text === "string") texts.push(output.text); + for (const content of output?.content || []) { + if (typeof content?.text === "string") texts.push(content.text); + else if (typeof content?.text?.value === "string") texts.push(content.text.value); + } + } + return texts.join(""); +} + +function normalizeUsage(usage) { + if (!usage || typeof usage !== "object") return null; + const promptTokens = Number(usage.prompt_tokens ?? usage.input_tokens); + const completionTokens = Number(usage.completion_tokens ?? usage.output_tokens); + const explicitTotal = Number(usage.total_tokens); + const totalTokens = Number.isFinite(explicitTotal) + ? explicitTotal + : (Number.isFinite(promptTokens) && Number.isFinite(completionTokens) + ? promptTokens + completionTokens + : NaN); + const reasoningTokens = Number( + usage.reasoning_tokens + ?? usage.output_tokens_details?.reasoning_tokens + ?? usage.completion_tokens_details?.reasoning_tokens, + ); + const normalized = {}; + if (Number.isFinite(promptTokens)) normalized.prompt_tokens = promptTokens; + if (Number.isFinite(completionTokens)) normalized.completion_tokens = completionTokens; + if (Number.isFinite(totalTokens)) normalized.total_tokens = totalTokens; + if (Number.isFinite(reasoningTokens)) normalized.reasoning_tokens = reasoningTokens; + return Object.keys(normalized).length ? normalized : null; +} + +function responseStatusFailure(payload) { + const status = String(payload?.status || "").trim().toLowerCase(); + if (!status || status === "completed") return null; + if (status === "incomplete") { + const reason = String(payload?.incomplete_details?.reason || "unknown").trim(); + return { + code: "incomplete_response", + message: `Model response was incomplete (${reason}).`, + retryable: reason === "max_output_tokens", + }; + } + if (status === "failed") { + return { + code: "response_failed", + message: String(payload?.error?.message || "Model response failed."), + retryable: false, + }; + } + return { + code: "unexpected_response_status", + message: `Model response ended with unexpected status: ${status}.`, + retryable: false, + }; +} + +function matchingFunctionCalls(payload, functionName) { + return (Array.isArray(payload?.output) ? payload.output : []) + .filter((item) => ["function_call", "function_tool_call"].includes(String(item?.type || ""))) + .filter((item) => String(item?.name || "") === functionName); +} + +export class ModelProvider { + constructor(options = {}) { + this.env = options.env || createEnvReader(); + this.fetchImpl = options.fetchImpl || globalThis.fetch; + this.sleep = options.sleep; + } + + get apiKey() { + return this.env.value("MODEL_API_KEY") + || this.env.value("AGENT_PLAN_API_KEY") + || this.env.value("ARK_API_KEY") + || this.env.value("VOLCENGINE_ARK_API_KEY"); + } + + get baseUrl() { + return this.env.value("MODEL_BASE_URL", DEFAULT_BASE_URL).replace(/\/$/, ""); + } + + get modelName() { + return this.env.value("MODEL_NAME", DEFAULT_MODEL_NAME); + } + + get runEnabled() { + return enabled(this.env.value("MODEL_RUN_ENABLED", "false")); + } + + get maxCards() { + return Math.max(1, Math.min(this.env.number("MODEL_MAX_CARDS", 2), 5)); + } + + get maxTokens() { + return Math.max(200, Math.min(this.env.number("MODEL_MAX_TOKENS", 700), 2000)); + } + + get timeoutMs() { + return Math.max(5000, Math.min(this.env.number("MODEL_TIMEOUT_MS", DEFAULT_TIMEOUT_MS), 300000)); + } + + get maxRetries() { + return Math.max(0, Math.min(this.env.number("MODEL_MAX_RETRIES", 1), 2)); + } + + isConfigured() { + return Boolean(this.apiKey && this.baseUrl && this.modelName); + } + + isRunEnabled() { + return this.isConfigured() && this.runEnabled; + } + + async generateChangeCards(input) { + if (!this.isConfigured()) { + return providerFailure("model", { code: "missing_config", message: "AGENT_PLAN_API_KEY, MODEL_BASE_URL or MODEL_NAME is not configured." }); + } + + const allowedSourceIds = new Set((input.sources || []).map((source) => source.id).filter(Boolean)); + if (!allowedSourceIds.size) { + return providerFailure("model", { code: "missing_sources", message: "At least one source is required for model generation." }); + } + + const result = await this.callJson({ + operation: "change_cards", + maxTokens: this.maxTokens, + system: [ + "你是竞争变化卡生成器。只输出 JSON,不要输出 Markdown。", + "只能根据用户提供的 baseline 和 sources 判断,不能补充外部事实。", + "如果证据足以和 baseline 对比,输出候选变化卡。", + "如果证据相关但不足以确定变化,也输出低置信度候选卡,并在 after 中写明需要人工核验。", + "只有 sources 明显与对象无关时,才返回空 cards,并写 note。", + "每张 card 必须引用至少一个给定 source id。", + ].join("\n"), + payload: { + task: "基于真实来源生成候选变化卡", + output_schema: { + cards: [ + { + dimension: "变化维度,例如 价格页 / 官网新闻 / 文档站 / 企业主体 / 知识产权", + title: "一句话标题", + before: "历史基线或未知状态", + after: "基于 sources 可支持的候选变化描述", + confidence: "高/中/低", + source_ids: ["必须来自 sources[].id"], + }, + ], + note: "证据不足或补充说明", + }, + rules: [ + `最多输出 ${this.maxCards} 张 card`, + "不要使用未提供的 source_id", + "不要把搜索结果标题直接当作确定事实,无法确认时写成候选变化或待核验", + "如果 sources 与 baseline 无法比较,但来源与对象相关,可以输出低置信度候选卡", + ], + object: { + id: input.object.id, + name: input.object.name, + object_type: input.object.object_type, + summary: input.object.summary, + }, + baseline: (input.object.baseline || []).map(baselineForPrompt), + sources: (input.sources || []).map(conciseSource), + }, + }); + if (!result.ok) return result; + + const validation = this.validateCards(result.parsed, allowedSourceIds); + return providerSuccess("model", { + request_id: result.request_id, + model: this.modelName, + latency_ms: result.latency_ms, + raw_ref: result.raw_ref, + cards: validation.cards, + note: asString(result.parsed.note), + validation_errors: validation.errors, + usage: result.usage, + }); + } + + async callJson({ system, payload, maxTokens, operation = "model" }) { + if (!this.isConfigured()) { + return providerFailure("model", { code: "missing_config", message: "AGENT_PLAN_API_KEY, MODEL_BASE_URL or MODEL_NAME is not configured." }); + } + + const body = { + model: this.modelName, + instructions: system, + input: JSON.stringify(payload), + max_output_tokens: maxTokens || this.maxTokens, + thinking: { type: "disabled" }, + text: { format: { type: "json_object" } }, + }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + const startedAt = Date.now(); + let response; + let providerPayload; + try { + response = await this.fetchImpl(`${this.baseUrl}/responses`, { + method: "POST", + headers: { + "Authorization": `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + const text = await response.text(); + providerPayload = text ? JSON.parse(text) : {}; + } catch (error) { + return providerFailure("model", { + code: error.name === "AbortError" ? "timeout" : "network_error", + message: error.name === "AbortError" ? `${operation} request timed out.` : error.message, + }, { latency_ms: Date.now() - startedAt }); + } finally { + clearTimeout(timeout); + } + + const providerError = normalizeError(providerPayload); + const requestId = providerPayload?.id || providerPayload?.ResponseMetadata?.RequestId || null; + if (!response.ok || providerError) { + return providerFailure("model", providerError || { code: "http_error", message: `HTTP ${response.status}` }, { + http_status: response.status, + request_id: requestId, + latency_ms: Date.now() - startedAt, + }); + } + + const content = extractResponseText(providerPayload); + try { + return providerSuccess("model", { + request_id: requestId, + model: this.modelName, + latency_ms: Date.now() - startedAt, + raw_ref: requestId ? `model:${requestId}` : null, + parsed: stripAndParseJson(content), + usage: normalizeUsage(providerPayload?.usage), + }); + } catch (error) { + return providerFailure("model", { code: "invalid_json", message: `Model returned invalid JSON: ${error.message}` }, { + request_id: requestId, + raw_ref: requestId ? `model:${requestId}` : null, + latency_ms: Date.now() - startedAt, + invalid_content: invalidJsonContent(content), + }); + } + } + + async callRequiredFunction(request = {}) { + return executeProviderCall( + () => this.callRequiredFunctionOnce(request), + { + max_retries: this.maxRetries, + base_delay_ms: 1200, + sleep: this.sleep, + }, + ); + } + + async callRequiredFunctionOnce({ + system, + payload, + functionName, + functionDescription, + parameters, + maxTokens, + operation = "model_function", + }) { + if (!this.isConfigured()) { + return providerFailure("model", { + code: "missing_config", + message: "AGENT_PLAN_API_KEY, MODEL_BASE_URL or MODEL_NAME is not configured.", + }); + } + + const body = { + model: this.modelName, + instructions: system, + input: JSON.stringify(payload), + max_output_tokens: maxTokens || this.maxTokens, + thinking: { type: "disabled" }, + store: false, + tools: [{ + type: "function", + name: functionName, + description: functionDescription, + strict: true, + parameters, + }], + tool_choice: "required", + }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + const startedAt = Date.now(); + let response; + let providerPayload; + try { + response = await this.fetchImpl(`${this.baseUrl}/responses`, { + method: "POST", + headers: { + "Authorization": `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + const text = await response.text(); + providerPayload = text ? JSON.parse(text) : {}; + } catch (error) { + return providerFailure("model", { + code: error.name === "AbortError" ? "timeout" : "network_error", + message: error.name === "AbortError" ? `${operation} request timed out.` : error.message, + }, { latency_ms: Date.now() - startedAt }); + } finally { + clearTimeout(timeout); + } + + const providerError = normalizeError(providerPayload); + const requestId = providerPayload?.id || providerPayload?.ResponseMetadata?.RequestId || null; + if (!response.ok || providerError) { + return providerFailure("model", providerError || { + code: "http_error", + message: `HTTP ${response.status}`, + }, { + http_status: response.status, + request_id: requestId, + latency_ms: Date.now() - startedAt, + }); + } + + const statusFailure = responseStatusFailure(providerPayload); + if (statusFailure) { + return providerFailure("model", statusFailure, { + request_id: requestId, + raw_ref: requestId ? `model:${requestId}` : null, + latency_ms: Date.now() - startedAt, + usage: normalizeUsage(providerPayload?.usage), + }); + } + + const calls = matchingFunctionCalls(providerPayload, functionName); + if (!calls.length) { + const anyFunctionCall = (Array.isArray(providerPayload?.output) ? providerPayload.output : []) + .some((item) => ["function_call", "function_tool_call"].includes(String(item?.type || ""))); + return providerFailure("model", { + code: anyFunctionCall ? "unexpected_function_call" : "missing_function_call", + message: anyFunctionCall + ? `Model called a function other than ${functionName}.` + : `Model did not call required function ${functionName}.`, + }, { + request_id: requestId, + raw_ref: requestId ? `model:${requestId}` : null, + latency_ms: Date.now() - startedAt, + usage: normalizeUsage(providerPayload?.usage), + }); + } + if (calls.length !== 1) { + return providerFailure("model", { + code: "unexpected_function_call", + message: `Model called required function ${functionName} ${calls.length} times.`, + }, { + request_id: requestId, + raw_ref: requestId ? `model:${requestId}` : null, + latency_ms: Date.now() - startedAt, + usage: normalizeUsage(providerPayload?.usage), + }); + } + + try { + return providerSuccess("model", { + request_id: requestId, + model: providerPayload?.model || this.modelName, + latency_ms: Date.now() - startedAt, + raw_ref: requestId ? `model:${requestId}` : null, + parsed: JSON.parse(String(calls[0].arguments || "")), + function_call_id: calls[0].call_id || calls[0].id || null, + usage: normalizeUsage(providerPayload?.usage), + }); + } catch (error) { + return providerFailure("model", { + code: "invalid_function_arguments", + message: `Model returned invalid function arguments: ${error.message}`, + }, { + request_id: requestId, + raw_ref: requestId ? `model:${requestId}` : null, + latency_ms: Date.now() - startedAt, + usage: normalizeUsage(providerPayload?.usage), + }); + } + } + + async generateReport({ scope, object, cards, sources, visualAsset = null }) { + const result = await this.callJson({ + operation: "report", + maxTokens: Math.max(this.maxTokens, 1000), + system: [ + "你是竞争变化报告生成器。只输出 JSON,不要输出 Markdown。", + "你只能基于 confirmed_cards、sources 和 visual_asset 生成报告。", + "每个实质性结论都必须引用 citation_card_ids 或 citation_source_ids。", + "证据不足时写入 risks 或 uncertainty,不能补编事实。", + ].join("\n"), + payload: { + task: "生成竞争变化追踪报告", + output_schema: { + summary: "一句话到三句话摘要", + sections: [ + { + title: "章节标题", + items: [ + { + text: "结论或说明", + citation_card_ids: ["必须来自 confirmed_cards[].id"], + citation_source_ids: ["必须来自 sources[].id"], + }, + ], + }, + ], + risks: [ + { + text: "风险或不确定性", + citation_card_ids: [], + citation_source_ids: [], + }, + ], + next_steps: ["后续建议"], + }, + scope, + object, + confirmed_cards: cards, + sources, + visual_asset: visualAsset ? { + id: visualAsset.id, + type: visualAsset.type, + title: visualAsset.title, + provider: visualAsset.provider, + provider_mode: visualAsset.provider_mode, + } : null, + }, + }); + if (!result.ok) return result; + const validation = this.validateReport(result.parsed, cards, sources); + return { + ...result, + content_json: validation.content_json, + validation_errors: validation.errors, + }; + } + + validateReport(parsed, cards, sources) { + const { cardIds, sourceIds } = collectCitationContext(cards, sources); + const errors = []; + const sections = []; + for (const [sectionIndex, section] of (Array.isArray(parsed?.sections) ? parsed.sections : []).entries()) { + const items = []; + for (const [itemIndex, item] of (Array.isArray(section?.items) ? section.items : []).entries()) { + const normalized = { + text: asString(item.text).slice(0, 420), + citation_card_ids: filterCitationIds(item.citation_card_ids, cardIds), + citation_source_ids: filterCitationIds(item.citation_source_ids, sourceIds), + }; + if (!normalized.text) { + errors.push(`sections[${sectionIndex}].items[${itemIndex}].text 缺失`); + continue; + } + if (!hasAnyCitation(normalized)) { + errors.push(`sections[${sectionIndex}].items[${itemIndex}] 缺少有效引用`); + continue; + } + items.push(normalized); + } + if (items.length) { + sections.push({ + title: asString(section.title, "报告章节").slice(0, 48), + items, + }); + } + } + + const risks = (Array.isArray(parsed?.risks) ? parsed.risks : []) + .map((risk) => ({ + text: asString(risk.text || risk).slice(0, 240), + citation_card_ids: filterCitationIds(risk.citation_card_ids, cardIds), + citation_source_ids: filterCitationIds(risk.citation_source_ids, sourceIds), + })) + .filter((risk) => risk.text); + + return { + errors, + content_json: { + summary: asString(parsed?.summary, "基于已确认变化生成报告。").slice(0, 600), + sections, + risks, + next_steps: (Array.isArray(parsed?.next_steps) ? parsed.next_steps : []).map((item) => asString(item).slice(0, 180)).filter(Boolean).slice(0, 5), + }, + }; + } + + async generateQaAnswer({ scope, question, cards, sources, assets = [], excerpts = [] }) { + const result = await this.callJson({ + operation: "qa", + maxTokens: Math.max(this.maxTokens, 700), + system: [ + "你是资料问答助手。只输出 JSON,不要输出 Markdown。", + "你只能基于 confirmed_cards、sources、reports 和 excerpts 回答。", + "如果资料不足,answer 里明确说当前资料不足。", + "回答必须引用有效 citation_card_ids 或 citation_source_ids,资料不足回答也要引用相关资料或留空并说明原因。", + ].join("\n"), + payload: { + task: "基于当前范围已确认资料回答问题", + output_schema: { + answer: "回答文本", + citation_card_ids: ["必须来自 confirmed_cards[].id"], + citation_source_ids: ["必须来自 sources[].id"], + insufficient: false, + }, + question, + scope, + confirmed_cards: cards, + sources, + reports: assets.filter((asset) => asset.type === "report").map((asset) => ({ + id: asset.id, + title: asset.title, + summary: asset.content_json?.summary || "", + })), + excerpts, + }, + }); + if (!result.ok) return result; + const validation = this.validateQaAnswer(result.parsed, cards, sources); + return { + ...result, + answer: validation.answer, + validation_errors: validation.errors, + }; + } + + validateQaAnswer(parsed, cards, sources) { + const { cardIds, sourceIds } = collectCitationContext(cards, sources); + const answer = { + text: asString(parsed?.answer).slice(0, 900), + citation_card_ids: filterCitationIds(parsed?.citation_card_ids, cardIds), + citation_source_ids: filterCitationIds(parsed?.citation_source_ids, sourceIds), + insufficient: Boolean(parsed?.insufficient), + }; + answer.citations = sourceLabelsForIds(sources, answer.citation_source_ids); + const errors = []; + if (!answer.text) errors.push("answer 缺失"); + if (!answer.insufficient && !hasAnyCitation(answer)) errors.push("answer 缺少有效引用"); + return { answer, errors }; + } + + validateCards(parsed, allowedSourceIds) { + const cards = Array.isArray(parsed?.cards) ? parsed.cards : Array.isArray(parsed) ? parsed : []; + const errors = []; + const normalized = []; + for (const [index, card] of cards.slice(0, this.maxCards).entries()) { + const sourceIds = Array.isArray(card?.source_ids) + ? card.source_ids.map((id) => String(id).trim()).filter((id) => allowedSourceIds.has(id)) + : []; + if (!sourceIds.length) { + errors.push(`cards[${index}].source_ids 缺失或不在允许来源内`); + continue; + } + const title = asString(card.title); + const after = asString(card.after); + if (!title || !after) { + errors.push(`cards[${index}].title/after 缺失`); + continue; + } + const confidence = ["高", "中", "低"].includes(asString(card.confidence)) ? asString(card.confidence) : "中"; + normalized.push({ + dimension: asString(card.dimension, "公开来源"), + title: title.slice(0, 80), + before: asString(card.before, "历史基线未记录该候选变化。").slice(0, 240), + after: after.slice(0, 320), + confidence, + source_ids: [...new Set(sourceIds)], + }); + } + return { cards: normalized, errors }; + } +} + +export function createModelProvider(options = {}) { + return new ModelProvider(options); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/openVikingProvider.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/openVikingProvider.js new file mode 100644 index 00000000..6409dd13 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/openVikingProvider.js @@ -0,0 +1,755 @@ +import { execFile } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { promisify } from "node:util"; +import { createEnvReader } from "../config/runtimeEnv.js"; +import { providerFailure, providerSuccess } from "./providerResult.js"; + +const execFileAsync = promisify(execFile); + +function truthy(value) { + return ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase()); +} + +function truncate(text, maxLength = 12000) { + const value = String(text || ""); + return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value; +} + +function parseJsonOutput(stdout) { + const output = String(stdout || "").trim(); + if (!output) return null; + try { + return JSON.parse(output); + } catch { + const objectStart = output.indexOf("{"); + const arrayStart = output.indexOf("["); + const candidates = [objectStart, arrayStart].filter((index) => index >= 0); + if (!candidates.length) return null; + const start = Math.min(...candidates); + try { + return JSON.parse(output.slice(start)); + } catch { + return null; + } + } +} + +function defaultCliPath() { + const homeCli = process.env.HOME ? join(process.env.HOME, "bin", "ov") : ""; + if (homeCli && existsSync(homeCli)) return homeCli; + return "ov"; +} + +function defaultCliConfigPath() { + return process.env.HOME ? join(process.env.HOME, ".openviking", "ovcli.conf") : ""; +} + +function commandExists(command) { + const value = String(command || "").trim(); + if (!value) return false; + if (value.includes("/")) return existsSync(value); + return String(process.env.PATH || "") + .split(delimiter) + .filter(Boolean) + .some((directory) => existsSync(join(directory, value))); +} + +function readCliConfig(configPath) { + if (!configPath || !existsSync(configPath)) return {}; + try { + const parsed = JSON.parse(readFileSync(configPath, "utf8")); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +function sessionIdFromResult(result, fallback = "") { + const candidates = [ + result?.session_id, + result?.id, + result?.result?.session_id, + result?.result?.id, + ]; + return String(candidates.find((value) => value) || fallback || "").trim(); +} + +function sessionMessageText(message) { + const parts = Array.isArray(message?.parts) ? message.parts : []; + const partText = parts + .filter((part) => part?.type === "text" || typeof part?.text === "string") + .map((part) => String(part?.text || "")) + .join("\n") + .trim(); + return partText || String(message?.content || message?.text || "").trim(); +} + +function normalizeSessionContext(result) { + const context = result?.result && typeof result.result === "object" ? result.result : result || {}; + const messages = (Array.isArray(context?.messages) ? context.messages : []) + .map((message, index) => ({ + id: String(message?.id || `openviking-message-${index + 1}`), + role: ["assistant", "user"].includes(message?.role) ? message.role : "user", + text: sessionMessageText(message), + created_at: message?.created_at || message?.timestamp || null, + })) + .filter((message) => message.text); + return { + ...context, + latest_archive_overview: String( + context?.latest_archive_overview + || context?.archive_overview + || context?.overview + || "", + ).trim(), + messages, + }; +} + +function textResourceContent(result) { + const value = result?.result ?? result; + if (typeof value === "string") return value; + return String(value?.content || value?.text || value?.raw_content || "").trim(); +} + +function isSessionNotFound(result) { + const code = String(result?.error?.code || "").toLowerCase(); + const message = `${result?.error?.message || ""} ${result?.stderr || ""} ${result?.stdout || ""}`.toLowerCase(); + return Number(result?.http_status || 0) === 404 + || ["404", "not_found", "session_not_found"].includes(code) + || /not found|does not exist|不存在|未找到/.test(message); +} + +function uriSegment(value, fallback = "default") { + const normalized = String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized || fallback; +} + +export class OpenVikingProvider { + constructor(options = {}) { + this.env = options.env || createEnvReader(); + this.execFile = options.execFile || execFileAsync; + this.fetchImpl = options.fetchImpl || globalThis.fetch; + this.cliPath = this.env.value("OPENVIKING_CLI") || defaultCliPath(); + this.cliConfigPath = this.env.value("OPENVIKING_CLI_CONFIG") || defaultCliConfigPath(); + this.cliConfig = options.cliConfig || readCliConfig(this.cliConfigPath); + this.agentId = this.env.value("OPENVIKING_AGENT_ID") || this.cliConfig.agent_id || "default"; + this.timeoutMs = Math.min(300000, Math.max(5000, this.env.number("OPENVIKING_TIMEOUT_MS", 120000))); + this.findLimit = this.env.number("OPENVIKING_FIND_LIMIT", 3); + this.qaKeepRecentMessages = Math.max(0, Math.min( + 40, + this.env.number("OPENVIKING_QA_KEEP_RECENT_MESSAGES", 6), + )); + this.memoryUri = this.env.value("OPENVIKING_MEMORY_URI", ""); + this.salesRootUri = String(this.env.value("OPENVIKING_SALES_ROOT_URI", "viking://resources/sales-workbench") || "viking://resources/sales-workbench").replace(/\/$/, ""); + } + + get apiKey() { + return this.env.value("OPENVIKING_API_KEY") + || this.env.value("OPENVIKING_BEARER_TOKEN") + || this.cliConfig.api_key + || ""; + } + + get baseUrl() { + const raw = this.env.value("OPENVIKING_BASE_URL") + || this.env.value("OPENVIKING_URL") + || this.cliConfig.url + || ""; + return String(raw || "").replace(/\/mcp\/?$/, "").replace(/\/api\/v1\/?$/, "").replace(/\/$/, ""); + } + + isConfigured() { + return Boolean((this.baseUrl && this.apiKey) || commandExists(this.cliPath)); + } + + isRunEnabled() { + return truthy(this.env.value("OPENVIKING_RUN_ENABLED", "false")); + } + + salesWorkspaceUri({ workspaceId } = {}) { + return `${this.salesRootUri}/${uriSegment(workspaceId, "local-workspace")}`; + } + + salesCompanyUri({ workspaceId, companyId } = {}) { + return `${this.salesWorkspaceUri({ workspaceId })}/companies/${uriSegment(companyId, "unknown-company")}`; + } + + salesMaterialUri({ workspaceId, companyId, sourceId } = {}) { + return `${this.salesCompanyUri({ workspaceId, companyId })}/materials/${uriSegment(sourceId, "unknown-source")}.md`; + } + + salesDossierUri({ workspaceId, companyId, dossierId } = {}) { + return `${this.salesCompanyUri({ workspaceId, companyId })}/dossiers/${uriSegment(dossierId, "unknown-dossier")}.md`; + } + + salesSessionId({ workspaceId, companyId } = {}) { + return `sales-${uriSegment(workspaceId, "local-workspace")}-${uriSegment(companyId, "unknown-company")}`; + } + + async runCli(args) { + const startedAt = Date.now(); + const cliArgs = this.agentId && !args.includes("--agent-id") + ? ["--agent-id", this.agentId, ...args] + : args; + try { + const { stdout, stderr } = await this.execFile(this.cliPath, cliArgs, { + timeout: this.timeoutMs, + maxBuffer: 1024 * 1024, + env: { + ...process.env, + NO_COLOR: "1", + PYTHONIOENCODING: "utf-8", + }, + }); + return providerSuccess("openviking", { + stdout: truncate(stdout), + stderr: truncate(stderr, 2000), + latency_ms: Date.now() - startedAt, + }); + } catch (error) { + const timedOut = error.code === "ETIMEDOUT" || (error.killed && error.signal === "SIGTERM"); + return providerFailure("openviking", { + code: error.code === "ENOENT" ? "missing_cli" : timedOut ? "timeout" : "cli_error", + message: timedOut ? "OpenViking CLI request timed out." : truncate(error.message, 2000), + }, { + stdout: truncate(error.stdout, 2000), + stderr: truncate(error.stderr, 2000), + latency_ms: Date.now() - startedAt, + }); + } + } + + async callRest(path, body = {}, options = {}) { + if (!this.baseUrl || !this.apiKey) { + return providerFailure("openviking", { code: "missing_http_config", message: "OPENVIKING_BASE_URL and an OpenViking API Key are not configured." }); + } + const startedAt = Date.now(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + let response; + let payload; + try { + const method = String(options.method || "POST").toUpperCase(); + response = await this.fetchImpl(`${this.baseUrl}/api/v1${path}`, { + method, + headers: { + "Authorization": `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + "X-OpenViking-Agent": this.agentId, + }, + body: ["GET", "HEAD", "DELETE"].includes(method) ? undefined : JSON.stringify(body), + signal: controller.signal, + }); + const text = await response.text(); + payload = text ? JSON.parse(text) : {}; + } catch (error) { + return providerFailure("openviking", { + code: error.name === "AbortError" ? "timeout" : "network_error", + message: error.name === "AbortError" ? "OpenViking HTTP request timed out." : error.message, + }, { + latency_ms: Date.now() - startedAt, + }); + } finally { + clearTimeout(timeout); + } + + if (!response.ok || payload?.status === "error") { + return providerFailure("openviking", { + code: payload?.error?.code || "provider_error", + message: payload?.error?.message || `HTTP ${response.status}`, + }, { + http_status: response.status, + latency_ms: Date.now() - startedAt, + }); + } + + return providerSuccess("openviking", { + result: payload?.result ?? payload, + raw_ref: `openviking:http:${path}`, + latency_ms: Date.now() - startedAt, + }); + } + + async health() { + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking CLI is not configured." }); + } + const result = await this.runCli(["health", "-o", "json"]); + if (!result.ok) return result; + return providerSuccess("openviking", { + result: parseJsonOutput(result.stdout), + raw_ref: "openviking:health", + latency_ms: result.latency_ms, + }); + } + + buildConfirmedCardMemory({ scope, object, card, sources }) { + const sourceLines = (sources || []) + .slice(0, 5) + .map((source) => `- ${source.label || source.id}${source.url ? ` (${source.url})` : ""}`) + .join("\n"); + return [ + "竞争变化卡已被用户确认,需要作为长期记忆保存。", + `范围:${scope?.name || card.scope_id}`, + `对象:${object?.name || card.object_id}`, + `维度:${card.dimension}`, + `标题:${card.title}`, + `确认后的变化:${card.after}`, + `置信度:${card.confidence}`, + sourceLines ? `证据来源:\n${sourceLines}` : "", + `内部追踪:scope=${card.scope_id}; object=${card.object_id}; card=${card.id}; run=${card.run_id}`, + ].filter(Boolean).join("\n"); + } + + async rememberConfirmedCard(payload) { + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking CLI is not configured." }); + } + if (!this.isRunEnabled()) { + return providerFailure("openviking", { code: "disabled", message: "OPENVIKING_RUN_ENABLED is false." }, { skipped: true }); + } + + const content = this.buildConfirmedCardMemory(payload); + const message = JSON.stringify({ role: "user", content }); + const result = await this.runCli(["add-memory", message, "-o", "json"]); + if (!result.ok) return result; + + const parsed = parseJsonOutput(result.stdout); + return providerSuccess("openviking", { + raw_ref: `openviking:add-memory:${payload.card.id}`, + result: parsed, + summary: parsed?.result?.message || parsed?.message || "OpenViking memory write completed.", + latency_ms: result.latency_ms, + }); + } + + async storeMemory(messages) { + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking is not configured." }); + } + if (!this.isRunEnabled()) { + return providerFailure("openviking", { code: "disabled", message: "OPENVIKING_RUN_ENABLED is false." }, { skipped: true }); + } + const normalized = Array.isArray(messages) ? messages : [{ role: "user", content: String(messages || "") }]; + const result = await this.runCli(["add-memory", JSON.stringify(normalized), "-o", "json"]); + if (!result.ok) return result; + const parsed = parseJsonOutput(result.stdout); + return providerSuccess("openviking", { + raw_ref: `openviking:add-memory:${Date.now()}`, + result: parsed, + summary: parsed?.result?.message || parsed?.message || "OpenViking memory write completed.", + latency_ms: result.latency_ms, + }); + } + + async addResource(path, options = {}) { + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking is not configured." }); + } + if (!this.isRunEnabled()) { + return providerFailure("openviking", { code: "disabled", message: "OPENVIKING_RUN_ENABLED is false." }, { skipped: true }); + } + const args = ["add-resource", String(path), "-o", "json"]; + if (options.to) args.push("--to", String(options.to)); + if (options.parent) args.push("--parent", String(options.parent)); + if (options.reason) args.push("--reason", String(options.reason)); + if (options.instruction) args.push("--instruction", String(options.instruction)); + if (options.wait) args.push("--wait"); + const result = await this.runCli(args); + if (!result.ok) return result; + return providerSuccess("openviking", { + raw_ref: `openviking:add-resource:${path}`, + result: parseJsonOutput(result.stdout), + latency_ms: result.latency_ms, + }); + } + + async upsertTextResource({ uri, content, mode = "replace" } = {}) { + const targetUri = String(uri || "").trim(); + const text = String(content || "").trim(); + if (!targetUri || !text) { + return providerFailure("openviking", { code: "bad_request", message: "uri and content are required." }); + } + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking is not configured." }); + } + if (!this.isRunEnabled()) { + return providerFailure("openviking", { code: "disabled", message: "OPENVIKING_RUN_ENABLED is false." }, { skipped: true }); + } + const writeMode = mode === "create" ? "create" : "replace"; + const result = await this.runCli([ + "write", + targetUri, + "--content", + text, + "--mode", + writeMode, + "-o", + "json", + ]); + if (!result.ok) return result; + const parsed = parseJsonOutput(result.stdout); + const semanticStatus = String(parsed?.result?.semantic_status || "").trim(); + const vectorStatus = String(parsed?.result?.vector_status || "").trim(); + const processingStatus = [semanticStatus, vectorStatus].includes("queued") ? "queued" : "ready"; + return providerSuccess("openviking", { + uri: targetUri, + raw_ref: targetUri, + result: parsed, + processing_status: processingStatus, + summary: processingStatus === "queued" + ? "OpenViking resource accepted and queued for indexing." + : writeMode === "create" ? "OpenViking resource created." : "OpenViking resource updated.", + latency_ms: result.latency_ms, + }); + } + + async readTextResource(uri) { + const targetUri = String(uri || "").trim(); + if (!targetUri) { + return providerFailure("openviking", { code: "bad_request", message: "uri is required." }); + } + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking is not configured." }); + } + + if (this.baseUrl && this.apiKey) { + const result = await this.callRest( + `/content/read?uri=${encodeURIComponent(targetUri)}&raw=true`, + {}, + { method: "GET" }, + ); + if (!result.ok) return result; + return providerSuccess("openviking", { + uri: targetUri, + content: textResourceContent(result.result), + result: result.result, + raw_ref: targetUri, + latency_ms: result.latency_ms, + }); + } + + const result = await this.runCli(["read", targetUri, "-o", "json"]); + if (!result.ok) return result; + const parsed = parseJsonOutput(result.stdout); + return providerSuccess("openviking", { + uri: targetUri, + content: textResourceContent(parsed), + result: parsed?.result ?? parsed, + raw_ref: targetUri, + latency_ms: result.latency_ms, + }); + } + + async removeResource(uri) { + const targetUri = String(uri || "").trim(); + if (!targetUri) { + return providerFailure("openviking", { code: "bad_request", message: "uri is required." }); + } + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking is not configured." }); + } + if (!this.isRunEnabled()) { + return providerFailure("openviking", { code: "disabled", message: "OPENVIKING_RUN_ENABLED is false." }, { skipped: true }); + } + const result = await this.runCli(["rm", targetUri, "-o", "json"]); + if (!result.ok) return result; + return providerSuccess("openviking", { + uri: targetUri, + raw_ref: targetUri, + result: parseJsonOutput(result.stdout), + summary: "OpenViking resource removed.", + latency_ms: result.latency_ms, + }); + } + + async getSession(sessionId) { + const targetSessionId = String(sessionId || "").trim(); + if (!targetSessionId) { + return providerFailure("openviking", { code: "bad_request", message: "sessionId is required." }); + } + if (this.baseUrl && this.apiKey) { + const result = await this.callRest(`/sessions/${encodeURIComponent(targetSessionId)}`, {}, { method: "GET" }); + if (!result.ok) return result; + return providerSuccess("openviking", { + session_id: sessionIdFromResult(result.result, targetSessionId), + result: result.result, + raw_ref: `openviking:session:${targetSessionId}`, + latency_ms: result.latency_ms, + }); + } + + const result = await this.runCli(["session", "get", targetSessionId, "-o", "json"]); + if (!result.ok) return result; + const parsed = parseJsonOutput(result.stdout); + return providerSuccess("openviking", { + session_id: sessionIdFromResult(parsed, targetSessionId), + result: parsed?.result ?? parsed, + raw_ref: `openviking:session:${targetSessionId}`, + latency_ms: result.latency_ms, + }); + } + + async getSessionContext(sessionId, options = {}) { + const targetSessionId = String(sessionId || "").trim(); + if (!targetSessionId) { + return providerFailure("openviking", { code: "bad_request", message: "sessionId is required." }); + } + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking is not configured." }); + } + + if (this.baseUrl && this.apiKey) { + const tokenBudget = Math.max(0, Number(options.tokenBudget || 0)); + const query = tokenBudget ? `?token_budget=${Math.floor(tokenBudget)}` : ""; + const result = await this.callRest( + `/sessions/${encodeURIComponent(targetSessionId)}/context${query}`, + {}, + { method: "GET" }, + ); + if (!result.ok) return result; + const context = normalizeSessionContext(result.result); + return providerSuccess("openviking", { + session_id: targetSessionId, + context, + messages: context.messages, + latest_archive_overview: context.latest_archive_overview, + result: result.result, + raw_ref: `openviking:session:${targetSessionId}:context`, + latency_ms: result.latency_ms, + }); + } + + const result = await this.runCli([ + "session", + "get-session-context", + targetSessionId, + "-o", + "json", + ]); + if (!result.ok) return result; + const parsed = parseJsonOutput(result.stdout); + const context = normalizeSessionContext(parsed); + return providerSuccess("openviking", { + session_id: targetSessionId, + context, + messages: context.messages, + latest_archive_overview: context.latest_archive_overview, + result: parsed?.result ?? parsed, + raw_ref: `openviking:session:${targetSessionId}:context`, + latency_ms: result.latency_ms, + }); + } + + async createSession(preferredSessionId = "") { + const requestedSessionId = String(preferredSessionId || "").trim(); + if (this.baseUrl && this.apiKey) { + const body = requestedSessionId ? { session_id: requestedSessionId } : {}; + const result = await this.callRest("/sessions", body); + if (!result.ok) return result; + const sessionId = sessionIdFromResult(result.result, requestedSessionId); + if (!sessionId) { + return providerFailure("openviking", { + code: "invalid_response", + message: "OpenViking did not return a session_id.", + }); + } + return providerSuccess("openviking", { + session_id: sessionId, + created: true, + result: result.result, + raw_ref: `openviking:session:${sessionId}`, + latency_ms: result.latency_ms, + }); + } + + const result = await this.runCli(["session", "new", "-o", "json"]); + if (!result.ok) return result; + const parsed = parseJsonOutput(result.stdout); + const sessionId = sessionIdFromResult(parsed); + if (!sessionId) { + return providerFailure("openviking", { + code: "invalid_response", + message: "OpenViking CLI did not return a session_id.", + }); + } + return providerSuccess("openviking", { + session_id: sessionId, + created: true, + result: parsed?.result ?? parsed, + raw_ref: `openviking:session:${sessionId}`, + latency_ms: result.latency_ms, + }); + } + + async ensureSession(sessionId) { + const preferredSessionId = String(sessionId || "").trim(); + if (!preferredSessionId) return this.createSession(); + const existing = await this.getSession(preferredSessionId); + if (existing.ok) return { ...existing, created: false }; + if (!isSessionNotFound(existing)) return existing; + return this.createSession(preferredSessionId); + } + + async addSessionMessages(sessionId, messages) { + const normalized = (Array.isArray(messages) ? messages : []) + .map((message) => ({ + role: ["assistant", "user"].includes(message.role) ? message.role : "user", + content: String(message.content || message.text || "").trim(), + })) + .filter((message) => message.content); + if (!normalized.length) { + return providerFailure("openviking", { code: "bad_request", message: "messages are required." }); + } + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking is not configured." }); + } + if (!this.isRunEnabled()) { + return providerFailure("openviking", { code: "disabled", message: "OPENVIKING_RUN_ENABLED is false." }, { skipped: true }); + } + + const ensured = await this.ensureSession(sessionId); + if (!ensured.ok) return ensured; + const actualSessionId = ensured.session_id; + const results = []; + + if (this.baseUrl && this.apiKey) { + for (const message of normalized) { + const result = await this.callRest( + `/sessions/${encodeURIComponent(actualSessionId)}/messages`, + { + role: message.role, + parts: [{ type: "text", text: message.content }], + }, + ); + if (!result.ok) return result; + results.push(result.result); + } + } else { + for (const message of normalized) { + const result = await this.runCli([ + "session", + "add-message", + actualSessionId, + "--role", + message.role, + "--content", + message.content, + "-o", + "json", + ]); + if (!result.ok) return result; + results.push(parseJsonOutput(result.stdout)); + } + } + + return providerSuccess("openviking", { + session_id: actualSessionId, + created: Boolean(ensured.created), + raw_ref: `openviking:session:${actualSessionId}:messages`, + result: results, + }); + } + + async recordSessionUsed(sessionId, contexts = []) { + const uris = (contexts || []).map((item) => String(item || "").trim()).filter(Boolean); + if (!uris.length) return providerSuccess("openviking", { skipped: true, summary: "No OpenViking contexts used." }); + if (!this.isRunEnabled()) { + return providerFailure("openviking", { code: "disabled", message: "OPENVIKING_RUN_ENABLED is false." }, { skipped: true }); + } + const result = await this.callRest(`/sessions/${encodeURIComponent(sessionId)}/used`, { contexts: uris }); + return { + ...result, + raw_ref: result.ok ? `openviking:session:${sessionId}:used` : result.raw_ref, + }; + } + + async commitSession(sessionId, options = {}) { + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking is not configured." }); + } + if (!this.isRunEnabled()) { + return providerFailure("openviking", { code: "disabled", message: "OPENVIKING_RUN_ENABLED is false." }, { skipped: true }); + } + + const keepRecentCount = Math.max(0, Math.min( + 40, + Number.isFinite(Number(options.keepRecentCount)) + ? Math.floor(Number(options.keepRecentCount)) + : this.qaKeepRecentMessages, + )); + const rest = await this.callRest(`/sessions/${encodeURIComponent(sessionId)}/commit`, { + telemetry: false, + keep_recent_count: keepRecentCount, + }); + if (rest.ok || rest.error?.code !== "missing_http_config") { + return { + ...rest, + raw_ref: rest.ok ? `openviking:session:${sessionId}:commit` : rest.raw_ref, + }; + } + + const result = await this.runCli(["session", "commit", String(sessionId), "-o", "json"]); + if (!result.ok) return result; + return providerSuccess("openviking", { + raw_ref: `openviking:session:${sessionId}:commit`, + result: parseJsonOutput(result.stdout), + latency_ms: result.latency_ms, + }); + } + + async deleteSession(sessionId) { + const targetSessionId = String(sessionId || "").trim(); + if (!targetSessionId) { + return providerFailure("openviking", { code: "bad_request", message: "sessionId is required." }); + } + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking is not configured." }); + } + if (!this.isRunEnabled()) { + return providerFailure("openviking", { code: "disabled", message: "OPENVIKING_RUN_ENABLED is false." }, { skipped: true }); + } + if (!this.baseUrl || !this.apiKey) { + return providerFailure("openviking", { + code: "missing_http_config", + message: "Deleting an OpenViking session requires HTTP configuration or ~/.openviking/ovcli.conf.", + }); + } + const result = await this.callRest(`/sessions/${encodeURIComponent(targetSessionId)}`, {}, { method: "DELETE" }); + if (!result.ok) return result; + return providerSuccess("openviking", { + session_id: targetSessionId, + raw_ref: `openviking:session:${targetSessionId}:deleted`, + result: result.result, + latency_ms: result.latency_ms, + }); + } + + async findMemories(query, options = {}) { + if (!this.isConfigured()) { + return providerFailure("openviking", { code: "missing_config", message: "OpenViking CLI is not configured." }); + } + const args = ["find", String(query || ""), "--node-limit", String(options.limit || this.findLimit), "-o", "json"]; + const uri = options.uri || this.memoryUri; + if (uri) args.splice(2, 0, "--uri", uri); + const result = await this.runCli(args); + if (!result.ok) return result; + const parsed = parseJsonOutput(result.stdout); + + return providerSuccess("openviking", { + result: parsed?.result ?? parsed, + raw_ref: "openviking:find", + latency_ms: result.latency_ms, + }); + } +} + +export function createOpenVikingProvider(options = {}) { + return new OpenVikingProvider(options); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/providerResult.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/providerResult.js new file mode 100644 index 00000000..313e871e --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/providerResult.js @@ -0,0 +1,98 @@ +const AUTH_CODES = new Set(["401", "403", "unauthorized", "forbidden", "invalid_api_key", "authentication_error"]); +const VALIDATION_CODES = new Set([ + "4003", + "bad_request", + "invalid_query", + "missing_sources", + "invalid_json", + "invalid_function_arguments", + "missing_function_call", + "unexpected_function_call", + "validation_error", +]); +const CONFIG_CODES = new Set(["missing_config", "missing_http_config", "missing_cli", "disabled", "provider_disabled"]); +const NETWORK_CODES = new Set(["network_error", "econnreset", "econnrefused", "enotfound"]); + +function normalizedCode(value) { + return String(value || "provider_error").trim().toLowerCase(); +} + +export function classifyProviderError(input = {}) { + const code = normalizedCode(input.code); + const httpStatus = Number(input.http_status || input.httpStatus || 0); + const message = String(input.message || "").toLowerCase(); + + if (CONFIG_CODES.has(code)) return { category: "configuration", retryable: false }; + if (AUTH_CODES.has(code) || httpStatus === 401 || httpStatus === 403 || /auth|api.?key|鉴权/.test(message)) { + return { category: "authentication", retryable: false }; + } + if (VALIDATION_CODES.has(code) || (httpStatus >= 400 && httpStatus < 422)) { + return { category: "validation", retryable: false }; + } + if (code === "timeout" || /timed? out|超时/.test(message)) return { category: "timeout", retryable: true }; + if (NETWORK_CODES.has(code)) return { category: "network", retryable: true }; + if (code === "429" || httpStatus === 429 || /rate.?limit|too many requests|限流/.test(message)) { + return { category: "rate_limit", retryable: true }; + } + if ( + httpStatus >= 500 + || /temporar|unavailable|service busy|internal (?:server )?error|暂时不可用|内部错误/.test(message) + ) { + return { category: "upstream", retryable: true }; + } + return { category: "unknown", retryable: false }; +} + +export function providerFailure(provider, error = {}, metadata = {}) { + const httpStatus = Number(metadata.http_status || error.http_status || 0) || undefined; + const classified = classifyProviderError({ + code: error.code, + message: error.message, + http_status: httpStatus, + }); + return { + ok: false, + provider, + provider_mode: "real", + ...metadata, + ...(httpStatus ? { http_status: httpStatus } : {}), + error: { + code: String(error.code || "provider_error"), + message: String(error.message || "Provider call failed."), + category: error.category || classified.category, + retryable: error.retryable ?? classified.retryable, + }, + }; +} + +export function providerSuccess(provider, data = {}) { + return { + ok: true, + provider, + provider_mode: "real", + ...data, + }; +} + +function defaultSleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +export async function executeProviderCall(operation, options = {}) { + const maxRetries = Math.max(0, Math.min(Number(options.max_retries || 0), 3)); + const baseDelayMs = Math.max(0, Number(options.base_delay_ms || 150)); + const sleep = options.sleep || defaultSleep; + let attempts = 0; + let result; + + while (attempts <= maxRetries) { + attempts += 1; + result = await operation(attempts); + if (result?.ok || !result?.error?.retryable || attempts > maxRetries) { + return { ...(result || {}), attempts }; + } + await sleep(baseDelayMs * attempts); + } + + return { ...(result || {}), attempts }; +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/supabaseDataProvider.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/supabaseDataProvider.js new file mode 100644 index 00000000..6cf7de4a --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/supabaseDataProvider.js @@ -0,0 +1,146 @@ +import { createEnvReader } from "../config/runtimeEnv.js"; + +function truthy(value) { + return ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase()); +} + +function normalizedBaseUrl(value) { + const base = String(value || "").trim().replace(/\/$/, ""); + if (!base) return ""; + return base.endsWith("/rest/v1") ? base : `${base}/rest/v1`; +} + +function apiError(response, body) { + const error = new Error(body?.message || body?.hint || `Supabase Data API returned HTTP ${response.status}.`); + error.code = body?.code || `http_${response.status}`; + error.details = body?.details || null; + error.http_status = response.status; + return error; +} + +export class SupabaseDataProvider { + constructor(options = {}) { + this.env = options.env || createEnvReader(); + this.fetch = options.fetchImpl || fetch; + this.baseUrl = normalizedBaseUrl(this.env.value("SUPABASE_API_URL")); + this.serviceRoleKey = this.env.value("SUPABASE_SERVICE_ROLE_KEY"); + this.timeoutMs = this.env.number("SUPABASE_DATA_API_TIMEOUT_MS", 15000); + this.runEnabled = truthy(this.env.value("SUPABASE_RUN_ENABLED", "false")); + } + + isConfigured() { + return Boolean(this.baseUrl && this.serviceRoleKey); + } + + isRunEnabled() { + return this.runEnabled; + } + + async request(path, options = {}) { + if (!this.isConfigured()) throw new Error("Supabase Data API is not configured."); + const url = new URL(`${this.baseUrl}/${String(path).replace(/^\//, "")}`); + for (const [name, value] of Object.entries(options.query || {})) { + if (value !== undefined && value !== null && value !== "") url.searchParams.set(name, String(value)); + } + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await this.fetch(url, { + method: options.method || "GET", + headers: { + Accept: "application/json", + apikey: this.serviceRoleKey, + Authorization: `Bearer ${this.serviceRoleKey}`, + ...(options.body !== undefined ? { "Content-Type": "application/json" } : {}), + ...(options.prefer ? { Prefer: options.prefer } : {}), + ...(options.headers || {}), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller.signal, + }); + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = { message: text.slice(0, 1000) }; + } + } + if (!response.ok) throw apiError(response, body); + return body; + } catch (error) { + if (error.name === "AbortError") { + const timeoutError = new Error(`Supabase Data API timed out after ${this.timeoutMs}ms.`); + timeoutError.code = "timeout"; + throw timeoutError; + } + throw error; + } finally { + clearTimeout(timeout); + } + } + + select(table, options = {}) { + return this.request(table, { + query: { + select: options.select || "*", + ...(options.filters || {}), + order: options.order || undefined, + limit: options.limit || undefined, + offset: options.offset || undefined, + }, + }); + } + + insert(table, rows, options = {}) { + return this.request(table, { + method: "POST", + body: rows, + prefer: options.returning === false ? "return=minimal" : "return=representation", + }); + } + + upsert(table, rows, options = {}) { + return this.request(table, { + method: "POST", + query: { on_conflict: options.onConflict || "id" }, + body: rows, + prefer: `resolution=merge-duplicates,${options.returning === false ? "return=minimal" : "return=representation"}`, + }); + } + + update(table, values, filters = {}, options = {}) { + return this.request(table, { + method: "PATCH", + query: filters, + body: values, + prefer: options.returning === false ? "return=minimal" : "return=representation", + }); + } + + delete(table, filters = {}, options = {}) { + return this.request(table, { + method: "DELETE", + query: filters, + prefer: options.returning === false ? "return=minimal" : "return=representation", + }); + } + + rpc(functionName, body) { + return this.request(`rpc/${functionName}`, { + method: "POST", + body, + prefer: "return=representation", + }); + } + + async probe() { + const rows = await this.select("app_workspaces", { select: "id", limit: 1 }); + return { ok: true, row_count: Array.isArray(rows) ? rows.length : 0 }; + } +} + +export function createSupabaseDataProvider(options = {}) { + return new SupabaseDataProvider(options); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/supabaseProvider.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/supabaseProvider.js new file mode 100644 index 00000000..a229b8d6 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/supabaseProvider.js @@ -0,0 +1,204 @@ +import { execFile, execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { createEnvReader } from "../config/runtimeEnv.js"; +import { providerFailure, providerSuccess } from "./providerResult.js"; + +const execFileAsync = promisify(execFile); + +function truthy(value) { + return ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase()); +} + +function truncate(text, maxLength = 12000) { + const value = String(text || ""); + return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value; +} + +function parseJsonOutput(stdout) { + const output = String(stdout || "").trim(); + if (!output) return null; + try { + return JSON.parse(output); + } catch { + return null; + } +} + +function resultRows(parsed) { + if (Array.isArray(parsed)) return parsed; + if (Array.isArray(parsed?.rows)) return parsed.rows; + return parsed; +} + +function isReadOnlySql(query) { + const normalized = String(query || "") + .replace(/^\s*(?:--[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*/g, "") + .trim() + .toLowerCase(); + return /^(select|with|show|explain)\b/.test(normalized); +} + +export class SupabaseProvider { + constructor(options = {}) { + this.env = options.env || createEnvReader(); + this.execFile = options.execFile || execFileAsync; + this.command = this.env.value("SUPABASE_CLI_BIN", "byted-supabase-cli"); + this.timeoutMs = this.env.number("SUPABASE_TIMEOUT_MS", 30000); + this.workspaceId = this.env.value("SUPABASE_WORKSPACE_ID") || this.env.value("DEFAULT_WORKSPACE_ID"); + this.branchId = this.env.value("SUPABASE_BRANCH_ID"); + this.readOnly = truthy(this.env.value("SUPABASE_READ_ONLY", "true")); + } + + isConfigured() { + return Boolean( + this.workspaceId + && this.env.value("VOLCENGINE_ACCESS_KEY") + && this.env.value("VOLCENGINE_SECRET_KEY") + && this.command + ); + } + + isRunEnabled() { + return truthy(this.env.value("SUPABASE_RUN_ENABLED", "false")); + } + + async executeSql(query) { + if (!this.isConfigured()) { + return providerFailure("supabase", { code: "missing_config", message: "Supabase control-plane SQL is not configured." }); + } + if (this.readOnly && !isReadOnlySql(query)) { + return providerFailure("supabase", { code: "read_only", message: "Supabase writes are disabled by SUPABASE_READ_ONLY." }); + } + + const tempDir = await mkdtemp(join(tmpdir(), "ccc-supabase-")); + const queryFile = join(tempDir, "query.sql"); + await writeFile(queryFile, query, "utf8"); + const startedAt = Date.now(); + try { + const args = [ + "db", + "query", + "--file", + queryFile, + "--workspace-id", + this.workspaceId, + ]; + if (this.branchId) args.push("--branch-id", this.branchId); + const { stdout, stderr } = await this.execFile(this.command, args, { + timeout: this.timeoutMs, + maxBuffer: 1024 * 1024, + env: { + ...process.env, + VOLCENGINE_ACCESS_KEY: this.env.value("VOLCENGINE_ACCESS_KEY"), + VOLCENGINE_SECRET_KEY: this.env.value("VOLCENGINE_SECRET_KEY"), + VOLCENGINE_REGION: this.env.value("VOLCENGINE_REGION", "cn-beijing"), + }, + }); + const parsed = parseJsonOutput(stdout); + const providerError = parsed && !Array.isArray(parsed) && parsed.error; + if (providerError) { + return providerFailure("supabase", { code: "provider_error", message: truncate(providerError, 2000) }, { + stdout: truncate(stdout, 2000), + stderr: truncate(stderr, 2000), + latency_ms: Date.now() - startedAt, + }); + } + return providerSuccess("supabase", { + rows: resultRows(parsed), + stdout: truncate(stdout, 2000), + stderr: truncate(stderr, 2000), + latency_ms: Date.now() - startedAt, + }); + } catch (error) { + return providerFailure("supabase", { + code: error.code === "ENOENT" ? "missing_cli" : "cli_error", + message: truncate(error.message, 2000), + }, { + stdout: truncate(error.stdout, 2000), + stderr: truncate(error.stderr, 2000), + latency_ms: Date.now() - startedAt, + }); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + } + + executeSqlSync(query) { + if (!this.isConfigured()) { + return providerFailure("supabase", { code: "missing_config", message: "Supabase control-plane SQL is not configured." }); + } + if (this.readOnly && !isReadOnlySql(query)) { + return providerFailure("supabase", { code: "read_only", message: "Supabase writes are disabled by SUPABASE_READ_ONLY." }); + } + + const tempDir = mkdtempSync(join(tmpdir(), "ccc-supabase-")); + const queryFile = join(tempDir, "query.sql"); + writeFileSync(queryFile, query, "utf8"); + const startedAt = Date.now(); + try { + const args = [ + "db", + "query", + "--file", + queryFile, + "--workspace-id", + this.workspaceId, + ]; + if (this.branchId) args.push("--branch-id", this.branchId); + const stdout = execFileSync(this.command, args, { + timeout: this.timeoutMs, + maxBuffer: 1024 * 1024, + encoding: "utf8", + env: { + ...process.env, + VOLCENGINE_ACCESS_KEY: this.env.value("VOLCENGINE_ACCESS_KEY"), + VOLCENGINE_SECRET_KEY: this.env.value("VOLCENGINE_SECRET_KEY"), + VOLCENGINE_REGION: this.env.value("VOLCENGINE_REGION", "cn-beijing"), + }, + }); + const parsed = parseJsonOutput(stdout); + const providerError = parsed && !Array.isArray(parsed) && parsed.error; + if (providerError) { + return providerFailure("supabase", { code: "provider_error", message: truncate(providerError, 2000) }, { + stdout: truncate(stdout, 2000), + latency_ms: Date.now() - startedAt, + }); + } + return providerSuccess("supabase", { + rows: resultRows(parsed), + stdout: truncate(stdout, 2000), + latency_ms: Date.now() - startedAt, + }); + } catch (error) { + return providerFailure("supabase", { + code: error.code === "ENOENT" ? "missing_cli" : "cli_error", + message: truncate(error.message, 2000), + }, { + stdout: truncate(error.stdout, 2000), + stderr: truncate(error.stderr, 2000), + latency_ms: Date.now() - startedAt, + }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + } + + async probe() { + const result = await this.executeSql("select 1 as supabase_probe;"); + if (!result.ok) return result; + return providerSuccess("supabase", { + rows: result.rows, + raw_ref: "supabase:execute-sql:probe", + latency_ms: result.latency_ms, + }); + } + +} + +export function createSupabaseProvider(options = {}) { + return new SupabaseProvider(options); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/webSearchProvider.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/webSearchProvider.js new file mode 100644 index 00000000..5507dfe7 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/webSearchProvider.js @@ -0,0 +1,221 @@ +import { createEnvReader } from "../config/runtimeEnv.js"; +import { executeProviderCall, providerFailure, providerSuccess } from "./providerResult.js"; + +const DEFAULT_BASE_URL = "https://open.feedcoopapi.com/search_api/web_search"; +const DEFAULT_TRAFFIC_TAG = "skill_web_search_common"; +const DEFAULT_TIMEOUT_MS = 20000; + +function clampCount(value, maxCount) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return maxCount; + return Math.max(1, Math.min(Math.trunc(parsed), maxCount)); +} + +function normalizeError(payload) { + const error = payload?.ResponseMetadata?.Error || payload?.Error || null; + if (!error) return null; + return { + code: error.Code || payload?.Code || "provider_error", + code_n: error.CodeN || payload?.CodeN || null, + message: error.Message || payload?.Message || "Provider returned an error.", + }; +} + +function cleanResultText(value, maxLength = 2000) { + return String(value || "") + .normalize("NFKC") + .replace(/[\u0000-\u001F\u007F]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxLength); +} + +function normalizeTitle(value) { + const title = cleanResultText(value, 500); + const structured = title.match( + /(?:^|---\s*)title\s*[::]\s*(.*?)(?=\s+(?:source|datetime|publish(?:ed)?_?time|url|summary)\s*[::]|$)/i, + )?.[1]; + return cleanResultText(structured || title, 300) + .replace(/^["'“”‘’]+|["'“”‘’]+$/g, "") + .trim(); +} + +function validPublishDate(value) { + if (value === null || value === undefined || value === "") return null; + const numeric = Number(value); + const input = Number.isFinite(numeric) + ? numeric < 10_000_000_000 ? numeric * 1000 : numeric + : value; + const date = new Date(input); + if (!Number.isFinite(date.getTime())) return null; + const year = date.getUTCFullYear(); + if (year < 2000 || year > new Date().getUTCFullYear() + 1) return null; + return date.toISOString(); +} + +function normalizePublishTime(value, metadataText = "") { + const direct = validPublishDate(value); + if (direct) return direct; + const embedded = cleanResultText(metadataText, 800).match( + /(?:datetime|publish(?:ed)?_?time|发布日期|发布时间)\s*[::]\s*(\d{4}[-/]\d{1,2}[-/]\d{1,2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?)/i, + )?.[1]; + return validPublishDate(embedded?.replaceAll("/", "-")); +} + +function normalizeResult(result) { + return { + id: result.Id || null, + sort_id: result.SortId ?? null, + title: normalizeTitle(result.Title), + site_name: cleanResultText(result.SiteName, 160), + url: cleanResultText(result.Url, 1000), + snippet: cleanResultText(result.Snippet, 2000), + summary: cleanResultText(result.Summary, 4000), + publish_time: normalizePublishTime(result.PublishTime, result.Title), + logo_url: result.LogoUrl || null, + rank_score: result.RankScore ?? null, + auth_description: result.AuthInfoDes || null, + auth_level: result.AuthInfoLevel ?? null, + content_formats: result.ContentFormats || null, + }; +} + +export class WebSearchProvider { + constructor(options = {}) { + this.env = options.env || createEnvReader(); + this.fetchImpl = options.fetchImpl || globalThis.fetch; + this.sleep = options.sleep; + } + + get apiKey() { + return this.env.value("WEB_SEARCH_API_KEY") + || this.env.value("AGENT_PLAN_API_KEY") + || this.env.value("ASK_ECHO_SEARCH_INFINITY_API_KEY"); + } + + get baseUrl() { + return this.env.value("WEB_SEARCH_BASE_URL", DEFAULT_BASE_URL); + } + + get maxCount() { + return Math.max(1, Math.min(this.env.number("WEB_SEARCH_MAX_COUNT", 3), 50)); + } + + get trafficTag() { + return this.env.value("WEB_SEARCH_TRAFFIC_TAG", DEFAULT_TRAFFIC_TAG); + } + + get runEnabled() { + return ["1", "true", "yes", "on"].includes(String(this.env.value("WEB_SEARCH_RUN_ENABLED", "false")).toLowerCase()); + } + + get timeoutMs() { + return Math.max(1000, this.env.number("WEB_SEARCH_TIMEOUT_MS", DEFAULT_TIMEOUT_MS)); + } + + get maxRetries() { + return Math.max(0, Math.min(this.env.number("WEB_SEARCH_MAX_RETRIES", 1), 2)); + } + + isConfigured() { + return Boolean(this.apiKey); + } + + isRunEnabled() { + return this.isConfigured() && this.runEnabled; + } + + async search(input) { + const query = String(input.query || input.Query || "").trim(); + if (!query) { + return providerFailure("web_search", { code: "bad_request", message: "query is required." }); + } + if (query.length > 100) { + return providerFailure("web_search", { code: "bad_request", message: "query must be 100 characters or fewer." }); + } + if (!this.isConfigured()) { + return providerFailure("web_search", { code: "missing_config", message: "AGENT_PLAN_API_KEY is not configured." }); + } + + const searchType = input.search_type || input.SearchType || "web"; + const count = clampCount(input.count ?? input.Count, this.maxCount); + const body = { + Query: query, + SearchType: searchType, + Count: count, + NeedSummary: input.need_summary ?? input.NeedSummary ?? true, + }; + const timeRange = input.time_range ?? input.TimeRange; + const authLevel = input.auth_level ?? input.AuthLevel; + const queryRewrite = input.query_rewrite ?? input.QueryRewrite; + if (timeRange) body.TimeRange = timeRange; + if (authLevel !== undefined && authLevel !== null && authLevel !== "") { + body.Filter = { AuthInfoLevel: Number(authLevel) }; + } + if (queryRewrite) body.QueryControl = { QueryRewrite: true }; + + return executeProviderCall( + () => this.searchOnce({ body, query, searchType }), + { + max_retries: this.maxRetries, + base_delay_ms: 2500, + sleep: this.sleep, + }, + ); + } + + async searchOnce({ body, query, searchType }) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + let response; + let payload; + const startedAt = Date.now(); + try { + response = await this.fetchImpl(this.baseUrl, { + method: "POST", + headers: { + "Authorization": `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + "X-Traffic-Tag": this.trafficTag, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + payload = await response.json(); + } catch (error) { + return providerFailure("web_search", { + code: error.name === "AbortError" ? "timeout" : "network_error", + message: error.name === "AbortError" ? "web search request timed out." : error.message, + }, { latency_ms: Date.now() - startedAt }); + } finally { + clearTimeout(timeout); + } + + const providerError = normalizeError(payload); + if (!response.ok || providerError) { + return providerFailure("web_search", providerError || { code: "http_error", message: `HTTP ${response.status}` }, { + http_status: response.status, + request_id: payload?.ResponseMetadata?.RequestId || payload?.Result?.LogId || null, + latency_ms: Date.now() - startedAt, + }); + } + + const result = payload.Result || {}; + const webResults = Array.isArray(result.WebResults) ? result.WebResults : []; + const requestId = payload.ResponseMetadata?.RequestId || result.LogId || null; + return providerSuccess("web_search", { + request_id: requestId, + log_id: result.LogId || requestId, + query, + search_type: result.SearchContext?.SearchType || searchType, + result_count: result.ResultCount ?? webResults.length, + latency_ms: Date.now() - startedAt, + raw_ref: requestId ? `web_search:${requestId}` : null, + results: webResults.map(normalizeResult), + }); + } +} + +export function createWebSearchProvider(options = {}) { + return new WebSearchProvider(options); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/repositories/supabaseDataRepository.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/repositories/supabaseDataRepository.js new file mode 100644 index 00000000..acc303f5 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/repositories/supabaseDataRepository.js @@ -0,0 +1,806 @@ +import { createSupabaseDataProvider } from "../providers/supabaseDataProvider.js"; +import { makeId } from "../utils/ids.js"; +import { nowIso } from "../utils/time.js"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +const clone = (value) => JSON.parse(JSON.stringify(value)); + +function payload(row) { + const value = row?.payload_json; + if (!value) return {}; + if (typeof value === "string") { + try { + return JSON.parse(value); + } catch { + return {}; + } + } + return value; +} + +function groupBy(rows, key) { + const groups = new Map(); + for (const row of rows || []) { + const value = row[key] || ""; + if (!groups.has(value)) groups.set(value, []); + groups.get(value).push(row); + } + return groups; +} + +function updateBody(row) { + const body = { ...row }; + delete body.id; + delete body.workspace_id; + delete body.created_at; + return body; +} + +function boundedLimit(value, fallback = 20) { + const parsed = Number(value || fallback); + return Math.max(1, Math.min(Number.isFinite(parsed) ? parsed : fallback, 100)); +} + +function salesMaterialMetadata(material = {}) { + return { + id: material.id, + company_id: material.company_id, + title: material.title || "", + source_type: material.source_type || "", + source_url: material.source_url || "", + source_id: material.source_id || null, + source_external_id: material.source_external_id || "", + source_version: material.source_version || "", + content_hash: material.content_hash || null, + occurred_at: material.occurred_at || null, + last_synced_at: material.last_synced_at || null, + openviking_uri: material.openviking_uri || material.openviking_ref || "", + openviking_status: material.openviking_status || (material.openviking_uri ? "indexed" : "pending"), + created_at: material.created_at || null, + updated_at: material.updated_at || null, + }; +} + +export class SupabaseDataRepository { + constructor(options = {}) { + this.provider = options.supabaseDataProvider || createSupabaseDataProvider({ env: options.env }); + this.workspaceId = String(options.workspaceId || this.provider.env?.value?.("APP_WORKSPACE_ID") || "").trim(); + if (!UUID_PATTERN.test(this.workspaceId)) { + throw new Error("APP_WORKSPACE_ID must be a valid UUID."); + } + this.readyPromise = null; + } + + async ensureSalesReady() { + if (this.readyPromise) return this.readyPromise; + this.readyPromise = (async () => { + if (!this.provider.isConfigured()) throw new Error("Supabase Data API is not configured."); + const [migrations, workspaces] = await Promise.all([ + this.provider.select("schema_migrations", { + select: "version", + filters: { version: "eq.202607300001" }, + limit: 1, + }), + this.provider.select("app_workspaces", { + select: "id", + filters: { id: `eq.${this.workspaceId}` }, + limit: 1, + }), + ]); + if (!migrations.length) throw new Error("Supabase security boundary migration is not applied."); + if (!workspaces.length) throw new Error(`Application workspace is not initialized: ${this.workspaceId}`); + return true; + })().catch((error) => { + this.readyPromise = null; + throw error; + }); + return this.readyPromise; + } + + async upsertScoped(table, id, row) { + await this.ensureSalesReady(); + const existing = await this.provider.update(table, updateBody(row), { + workspace_id: `eq.${this.workspaceId}`, + id: `eq.${id}`, + }); + if (Array.isArray(existing) && existing.length) return existing[0]; + const inserted = await this.provider.insert(table, row); + return Array.isArray(inserted) ? inserted[0] : inserted; + } + + async getSalesState(seed = {}) { + await this.ensureSalesReady(); + const workspaceFilter = { workspace_id: `eq.${this.workspaceId}` }; + const activeFilter = { ...workspaceFilter, deleted_at: "is.null" }; + const [ + goalRows, + companyRows, + targetRows, + progressRows, + dossierRows, + citationRows, + materialRows, + refRows, + syncSourceRows, + syncCheckpointRows, + jobRows, + ] = await Promise.all([ + this.provider.select("sales_goals", { filters: activeFilter, order: "created_at.asc" }), + this.provider.select("sales_companies", { filters: activeFilter, order: "created_at.asc" }), + this.provider.select("sales_target_enterprises", { filters: activeFilter, order: "created_at.asc" }), + this.provider.select("sales_progress_snapshots", { filters: workspaceFilter, order: "created_at.desc" }), + this.provider.select("sales_dossier_records", { filters: activeFilter, order: "created_at.desc" }), + this.provider.select("sales_dossier_citations", { filters: workspaceFilter, order: "created_at.asc" }), + this.provider.select("sales_materials", { filters: activeFilter, order: "updated_at.desc" }), + this.provider.select("sales_openviking_refs", { filters: workspaceFilter, order: "created_at.asc" }), + this.provider.select("sync_sources", { filters: workspaceFilter, order: "updated_at.desc" }), + this.provider.select("sync_checkpoints", { filters: workspaceFilter, order: "updated_at.desc" }), + this.provider.select("jobs", { filters: workspaceFilter, order: "created_at.desc" }), + ]); + + const progressByCompany = new Map(); + for (const row of progressRows) { + if (!progressByCompany.has(row.company_id)) { + progressByCompany.set(row.company_id, { + label: row.label, + summary: row.summary, + evidence: row.evidence, + updated_at: row.created_at, + }); + } + } + + const companies = {}; + for (const row of companyRows) { + const saved = payload(row); + companies[row.id] = { + ...saved, + id: row.id, + name: row.name, + initial: row.initial, + industry: row.industry, + location: row.location, + tags: Array.isArray(row.tags) ? row.tags : saved.tags || [], + progress: progressByCompany.get(row.id) || saved.progress || null, + dossier_ids: [], + material_ids: [], + qa_session_id: saved.qa_session_id || `sales-${row.id}`, + created_at: row.created_at, + updated_at: row.updated_at, + }; + } + + const targetCompanyIdsByGoal = groupBy(targetRows, "goal_id"); + const seedGoalOrder = new Map((seed?.goals || []).map((goal, index) => [goal.id, index])); + const goals = goalRows.map((row) => { + const saved = payload(row); + return { + ...saved, + id: row.id, + name: row.name, + description: row.description, + keywords: Array.isArray(row.keywords) ? row.keywords : saved.keywords || [], + company_ids: (targetCompanyIdsByGoal.get(row.id) || []).map((target) => target.company_id).filter((id) => companies[id]), + candidate_ids: saved.candidate_ids || [], + created_at: row.created_at, + updated_at: row.updated_at, + }; + }).sort((a, b) => { + const aOrder = seedGoalOrder.has(a.id) ? seedGoalOrder.get(a.id) : Number.MAX_SAFE_INTEGER; + const bOrder = seedGoalOrder.has(b.id) ? seedGoalOrder.get(b.id) : Number.MAX_SAFE_INTEGER; + if (aOrder !== bOrder) return aOrder - bOrder; + return String(b.created_at || "").localeCompare(String(a.created_at || "")); + }); + + const citationsByDossier = groupBy(citationRows, "dossier_id"); + const dossiers = {}; + for (const row of dossierRows) { + const saved = payload(row); + const citations = (citationsByDossier.get(row.id) || []).map((citationRow) => ({ + ...payload(citationRow), + id: citationRow.citation_no, + label: citationRow.label, + source_kind: citationRow.source_kind, + url: citationRow.url || "", + })).sort((a, b) => Number(a.id) - Number(b.id)); + dossiers[row.id] = { + ...saved, + id: row.id, + company_id: row.company_id, + title: row.title, + summary: row.summary, + memory_summary: row.memory_summary, + provider_run_id: row.provider_run_id || saved.provider_run_id || null, + version_no: Number(row.version_no || saved.version_no || 1), + previous_dossier_id: row.previous_dossier_id || saved.previous_dossier_id || null, + evidence_hash: row.evidence_hash || saved.evidence_hash || null, + dossier_fingerprint: row.dossier_fingerprint || saved.dossier_fingerprint || null, + change_status: row.change_status || saved.change_status || "initial", + data_as_of: row.data_as_of || saved.data_as_of || row.created_at, + generated_at: row.generated_at || saved.generated_at || row.created_at, + evidence_pack: Array.isArray(row.evidence_pack_json) + ? row.evidence_pack_json + : saved.evidence_pack || [], + created_at: row.created_at, + body: saved.body || [], + citations: citations.length ? citations : saved.citations || [], + }; + if (companies[row.company_id]) companies[row.company_id].dossier_ids.push(row.id); + } + + const materials = {}; + for (const row of materialRows) { + const saved = payload(row); + materials[row.id] = { + id: row.id, + company_id: row.company_id, + title: row.title, + source_type: row.source_type || saved.source_type || "", + source_url: row.source_url || saved.source_url || "", + source_id: row.source_id || saved.source_id || null, + source_external_id: saved.source_external_id || "", + source_version: row.source_version || saved.source_version || "", + content_hash: row.content_hash || saved.content_hash || null, + summary: "", + text: "", + source_items: [], + occurred_at: row.occurred_at || saved.occurred_at || null, + last_synced_at: row.last_synced_at || saved.last_synced_at || null, + updated_at: row.updated_at, + created_at: row.created_at, + openviking_uri: row.openviking_uri || saved.openviking_uri || "", + openviking_status: row.openviking_status || saved.openviking_status || (row.openviking_uri ? "indexed" : "pending"), + }; + if (companies[row.company_id] && !companies[row.company_id].material_ids.includes(row.id)) { + companies[row.company_id].material_ids.push(row.id); + } + } + + for (const row of refRows.filter((item) => item.related_type === "material")) { + const saved = payload(row); + const id = row.related_id || saved.id || row.id; + const seedMaterial = seed?.materials?.[id] || {}; + const existing = materials[id] || {}; + const memoryImported = row.ref_kind === "memory_import"; + materials[id] = { + ...existing, + id, + company_id: row.company_id, + title: saved.title || existing.title || seedMaterial.title || row.summary, + source_type: saved.source_type || existing.source_type || seedMaterial.source_type || "", + source_url: saved.source_url || existing.source_url || seedMaterial.source_url || "", + source_id: saved.source_id || existing.source_id || seedMaterial.source_id || null, + source_external_id: saved.source_external_id || existing.source_external_id || "", + source_version: saved.source_version || existing.source_version || "", + content_hash: saved.content_hash || existing.content_hash || null, + summary: "", + text: "", + source_items: [], + updated_at: saved.updated_at || existing.updated_at || seedMaterial.updated_at || row.created_at, + openviking_uri: memoryImported ? row.uri : existing.openviking_uri || row.uri, + openviking_status: memoryImported ? "indexed" : existing.openviking_status || (row.uri ? "indexed" : "pending"), + }; + if (companies[row.company_id] && !companies[row.company_id].material_ids.includes(id)) { + companies[row.company_id].material_ids.push(id); + } + } + + const qa_messages = {}; + + const sync_sources = Object.fromEntries(syncSourceRows.map((row) => [row.id, { + ...payload(row), + id: row.id, + source_type: row.source_type, + external_id: row.external_id, + display_name: row.display_name || "", + status: row.status, + config: row.config_json || {}, + last_synced_at: row.last_synced_at || null, + created_at: row.created_at, + updated_at: row.updated_at, + }])); + const sync_checkpoints = Object.fromEntries(syncCheckpointRows.map((row) => [row.id, { + ...payload(row), + id: row.id, + source_id: row.source_id, + checkpoint_key: row.checkpoint_key, + checkpoint_value: row.checkpoint_value || "", + content_hash: row.content_hash || null, + last_success_at: row.last_success_at || null, + error: row.error_json || null, + created_at: row.created_at, + updated_at: row.updated_at, + }])); + + const jobs = Object.fromEntries(jobRows.map((row) => [row.id, this.jobView(row)])); + + return { goals, companies, dossiers, materials, qa_messages, sync_sources, sync_checkpoints, jobs }; + } + + async persistSalesGoal(goal) { + const row = { + id: goal.id, + workspace_id: this.workspaceId, + name: goal.name, + description: goal.description || "", + keywords: goal.keywords || [], + deleted_at: null, + created_at: goal.created_at || nowIso(), + updated_at: goal.updated_at || nowIso(), + payload_json: goal, + }; + return this.upsertScoped("sales_goals", goal.id, row); + } + + async persistSalesCompany(company) { + const row = { + id: company.id, + workspace_id: this.workspaceId, + name: company.name, + initial: company.initial || "", + industry: company.industry || "", + location: company.location || "", + tags: company.tags || [], + deleted_at: null, + created_at: company.created_at || nowIso(), + updated_at: company.updated_at || nowIso(), + payload_json: company, + }; + const saved = await this.upsertScoped("sales_companies", company.id, row); + if (company.progress) await this.persistSalesProgress(company.id, company.progress); + return saved; + } + + async persistSalesProgress(companyId, progress) { + const createdAt = progress.updated_at || nowIso(); + const id = `${companyId}:${createdAt}`; + return this.upsertScoped("sales_progress_snapshots", id, { + id, + workspace_id: this.workspaceId, + company_id: companyId, + label: progress.label || "", + summary: progress.summary || "", + evidence: progress.evidence || "", + created_at: createdAt, + payload_json: progress, + }); + } + + async persistSalesTargetEnterprise(goalId, company) { + await this.persistSalesCompany(company); + const now = nowIso(); + const filters = { + workspace_id: `eq.${this.workspaceId}`, + goal_id: `eq.${goalId}`, + company_id: `eq.${company.id}`, + }; + const status = company.progress?.label || "新商机"; + const payload_json = { goal_id: goalId, company_id: company.id, status }; + const existing = await this.provider.update("sales_target_enterprises", { + status, + deleted_at: null, + updated_at: now, + payload_json, + }, filters); + if (Array.isArray(existing) && existing.length) return existing[0]; + const inserted = await this.provider.insert("sales_target_enterprises", { + id: `${goalId}:${company.id}`, + workspace_id: this.workspaceId, + goal_id: goalId, + company_id: company.id, + status, + created_at: now, + updated_at: now, + payload_json, + }); + return Array.isArray(inserted) ? inserted[0] : inserted; + } + + async persistSalesSearchResults(goalId, query, companies) { + await this.ensureSalesReady(); + const rows = (companies || []).map((company) => ({ + id: makeId("sales_search"), + workspace_id: this.workspaceId, + goal_id: goalId, + company_id: company.id || null, + query, + reason: company.reason || "", + created_at: nowIso(), + payload_json: company, + })); + if (!rows.length) return []; + return this.provider.insert("sales_company_search_results", rows); + } + + async persistSalesDossier(dossier) { + await this.ensureSalesReady(); + await this.provider.rpc("persist_sales_dossier", { + p_workspace_id: this.workspaceId, + p_dossier: dossier, + }); + return clone(dossier); + } + + async persistSalesMaterial(material) { + const metadata = salesMaterialMetadata(material); + const row = { + id: material.id, + workspace_id: this.workspaceId, + company_id: material.company_id, + title: material.title, + source_type: material.source_type || "", + source_url: material.source_url || "", + source_id: material.source_id || null, + source_version: material.source_version || "", + content_hash: material.content_hash || null, + summary: "", + occurred_at: material.occurred_at || null, + openviking_uri: material.openviking_uri || material.openviking_ref || "", + openviking_status: material.openviking_status || (material.openviking_uri ? "indexed" : "pending"), + last_synced_at: material.last_synced_at || null, + deleted_at: null, + created_at: material.created_at || material.updated_at || nowIso(), + updated_at: material.updated_at || nowIso(), + payload_json: metadata, + }; + return this.upsertScoped("sales_materials", material.id, row); + } + + async softDeleteSalesMaterial(materialId, deletedAt = nowIso()) { + await this.ensureSalesReady(); + return this.provider.update("sales_materials", { + deleted_at: deletedAt, + updated_at: deletedAt, + }, { + workspace_id: `eq.${this.workspaceId}`, + id: `eq.${materialId}`, + }); + } + + async persistSyncSource(source) { + const row = { + id: source.id, + workspace_id: this.workspaceId, + source_type: source.source_type, + external_id: source.external_id, + display_name: source.display_name || "", + status: source.status || "active", + config_json: source.config || source.config_json || {}, + last_synced_at: source.last_synced_at || null, + created_at: source.created_at || nowIso(), + updated_at: source.updated_at || nowIso(), + }; + return this.upsertScoped("sync_sources", source.id, row); + } + + async persistSyncCheckpoint(checkpoint) { + const id = checkpoint.id || `${checkpoint.source_id}:${checkpoint.checkpoint_key || "latest"}`; + const row = { + id, + workspace_id: this.workspaceId, + source_id: checkpoint.source_id, + checkpoint_key: checkpoint.checkpoint_key || "latest", + checkpoint_value: checkpoint.checkpoint_value || "", + content_hash: checkpoint.content_hash || null, + last_success_at: checkpoint.last_success_at || null, + error_json: checkpoint.error || checkpoint.error_json || null, + created_at: checkpoint.created_at || nowIso(), + updated_at: checkpoint.updated_at || nowIso(), + }; + return this.upsertScoped("sync_checkpoints", id, row); + } + + async persistSalesOpenVikingRef(record) { + const id = record.id || (record.related_id + ? `${record.company_id || "global"}:${record.related_type || "ref"}:${record.related_id}:${record.ref_kind || "ref"}` + : makeId("sales_ov")); + const row = { + id, + workspace_id: this.workspaceId, + company_id: record.company_id || null, + related_type: record.related_type, + related_id: record.related_id || null, + ref_kind: record.ref_kind, + uri: record.uri || "", + summary: record.summary || "", + created_at: record.created_at || nowIso(), + payload_json: record.payload_json || record, + }; + return this.upsertScoped("sales_openviking_refs", id, row); + } + + async persistProviderRun(run) { + await this.ensureSalesReady(); + await this.provider.rpc("persist_provider_run", { + p_workspace_id: this.workspaceId, + p_run: run, + }); + return clone(run); + } + + async persistJob(job) { + const row = { + id: job.id, + workspace_id: this.workspaceId, + job_type: job.job_type, + status: job.status || "queued", + entity_type: job.entity_type || null, + entity_id: job.entity_id || null, + idempotency_key: job.idempotency_key || null, + attempt_count: Number(job.attempt_count || 0), + max_attempts: Number(job.max_attempts || 3), + scheduled_at: job.scheduled_at || null, + started_at: job.started_at || null, + finished_at: job.finished_at || null, + error_json: job.error || job.error_json || null, + payload_json: job, + is_paid: Boolean(job.is_paid), + stage: job.stage || job.status || "queued", + progress: Math.max(0, Math.min(Number(job.progress || 0), 100)), + worker_id: job.worker_id || null, + lease_expires_at: job.lease_expires_at || null, + heartbeat_at: job.heartbeat_at || null, + cancel_requested_at: job.cancel_requested_at || null, + checkpoint_json: job.checkpoint || job.checkpoint_json || {}, + progress_detail_json: job.progress_detail || job.progress_detail_json || {}, + created_by: job.created_by || null, + created_at: job.created_at || nowIso(), + updated_at: job.updated_at || nowIso(), + }; + await this.upsertScoped("jobs", job.id, row); + return clone(job); + } + + async enqueueJob(job) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("enqueue_sales_job", { + p_workspace_id: this.workspaceId, + p_job: job, + }); + return this.jobView(result); + } + + async claimNextJob(workerId, jobTypes, leaseSeconds) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("claim_sales_job", { + p_workspace_id: this.workspaceId, + p_worker_id: workerId, + p_job_types: Array.isArray(jobTypes) ? jobTypes : [], + p_lease_seconds: Number(leaseSeconds || 600), + }); + return result ? this.jobView(result) : null; + } + + async heartbeatJob(jobId, workerId, stage, progress, leaseSeconds) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("heartbeat_sales_job", { + p_workspace_id: this.workspaceId, + p_job_id: jobId, + p_worker_id: workerId, + p_stage: stage, + p_progress: Number(progress || 1), + p_lease_seconds: Number(leaseSeconds || 600), + }); + return this.jobView(result); + } + + async saveJobCheckpoint(jobId, workerId, checkpointPatch = {}, options = {}) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("checkpoint_sales_job", { + p_workspace_id: this.workspaceId, + p_job_id: jobId, + p_worker_id: workerId, + p_stage: options.stage || "running", + p_progress: Number(options.progress || 1), + p_progress_detail: options.detail || {}, + p_checkpoint_patch: checkpointPatch || {}, + p_lease_seconds: Number(options.lease_seconds || 600), + }); + return this.jobView(result); + } + + async releaseJobClaim(jobId, workerId, error, options = {}) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("release_sales_job_claim", { + p_workspace_id: this.workspaceId, + p_job_id: jobId, + p_worker_id: workerId, + p_error: error || null, + p_retry: Boolean(options.retry), + p_delay_seconds: Number(options.delay_seconds || 0), + }); + return result ? this.jobView(result) : null; + } + + async requestJobCancellation(jobId) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("request_cancel_sales_job", { + p_workspace_id: this.workspaceId, + p_job_id: jobId, + }); + return this.jobView(result); + } + + async acknowledgeJobCancellation(jobId, workerId) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("acknowledge_cancel_sales_job", { + p_workspace_id: this.workspaceId, + p_job_id: jobId, + p_worker_id: workerId, + }); + return this.jobView(result); + } + + async retryQueuedJob(jobId) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("retry_sales_job", { + p_workspace_id: this.workspaceId, + p_job_id: jobId, + }); + return this.jobView(result); + } + + async reservePaidWorkflow(job, reservationId, limits) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("reserve_paid_workflow", { + p_workspace_id: this.workspaceId, + p_job: job, + p_reservation_id: reservationId, + p_max_concurrent: limits.max_concurrent, + p_daily_limit: limits.daily_limit, + p_budget_timezone: limits.timezone, + p_stale_after_seconds: limits.stale_after_seconds, + }); + return { + job: result?.job || job, + budget: result?.budget || null, + }; + } + + async finishPaidWorkflow(job, reservationId) { + await this.ensureSalesReady(); + const result = await this.provider.rpc("finish_paid_workflow", { + p_workspace_id: this.workspaceId, + p_job: job, + p_reservation_id: reservationId, + }); + return result || clone(job); + } + + async getPaidWorkflowUsage(timezone) { + await this.ensureSalesReady(); + return this.provider.rpc("get_paid_workflow_usage", { + p_workspace_id: this.workspaceId, + p_budget_timezone: timezone, + }); + } + + async listJobs(filters = {}) { + await this.ensureSalesReady(); + const queryFilters = { workspace_id: `eq.${this.workspaceId}` }; + if (filters.job_type) queryFilters.job_type = `eq.${filters.job_type}`; + if (filters.status) queryFilters.status = `eq.${filters.status}`; + if (filters.entity_id) queryFilters.entity_id = `eq.${filters.entity_id}`; + const rows = await this.provider.select("jobs", { + filters: queryFilters, + order: "created_at.desc", + limit: boundedLimit(filters.limit), + }); + return rows.map((row) => this.jobView(row)); + } + + async getJob(jobId) { + await this.ensureSalesReady(); + const rows = await this.provider.select("jobs", { + filters: { workspace_id: `eq.${this.workspaceId}`, id: `eq.${jobId}` }, + limit: 1, + }); + return rows.length ? this.jobView(rows[0]) : null; + } + + jobView(row) { + const saved = payload(row); + return { + ...saved, + id: row.id, + job_type: row.job_type, + status: row.status, + entity_type: row.entity_type || "", + entity_id: row.entity_id || "", + idempotency_key: row.idempotency_key || null, + attempt_count: Number(row.attempt_count || 0), + max_attempts: Number(row.max_attempts || 3), + scheduled_at: row.scheduled_at || null, + started_at: row.started_at || null, + finished_at: row.finished_at || null, + error: row.error_json || saved.error || null, + is_paid: Boolean(row.is_paid || saved.is_paid), + stage: row.stage || saved.stage || row.status, + progress: Number(row.progress ?? saved.progress ?? (row.status === "succeeded" ? 100 : 0)), + worker_id: row.worker_id || saved.worker_id || null, + lease_expires_at: row.lease_expires_at || saved.lease_expires_at || null, + heartbeat_at: row.heartbeat_at || saved.heartbeat_at || null, + cancel_requested_at: row.cancel_requested_at || saved.cancel_requested_at || null, + checkpoint: row.checkpoint_json || saved.checkpoint || {}, + progress_detail: row.progress_detail_json || saved.progress_detail || {}, + created_by: row.created_by || saved.created_by || null, + created_at: row.created_at, + updated_at: row.updated_at, + }; + } + + async listProviderRuns(filters = {}) { + await this.ensureSalesReady(); + const limit = boundedLimit(filters.limit); + const queryFilters = { workspace_id: `eq.${this.workspaceId}` }; + if (filters.operation) queryFilters.operation = `eq.${filters.operation}`; + if (filters.entity_id) queryFilters.entity_id = `eq.${filters.entity_id}`; + const runs = await this.provider.select("provider_runs", { + filters: queryFilters, + order: "started_at.desc", + limit, + }); + if (!runs.length) return []; + const runIds = runs.map((run) => run.id); + const steps = await this.provider.select("provider_run_steps", { + filters: { + workspace_id: `eq.${this.workspaceId}`, + provider_run_id: `in.(${runIds.join(",")})`, + }, + order: "sequence.asc", + }); + const stepsByRun = groupBy(steps, "provider_run_id"); + return runs.map((row) => this.providerRunView(row, stepsByRun.get(row.id) || [])); + } + + async getProviderRun(runId) { + await this.ensureSalesReady(); + const rows = await this.provider.select("provider_runs", { + filters: { workspace_id: `eq.${this.workspaceId}`, id: `eq.${runId}` }, + limit: 1, + }); + if (!rows.length) return null; + const steps = await this.provider.select("provider_run_steps", { + filters: { workspace_id: `eq.${this.workspaceId}`, provider_run_id: `eq.${runId}` }, + order: "sequence.asc", + }); + return this.providerRunView(rows[0], steps); + } + + providerRunView(row, stepRows = []) { + const saved = payload(row); + return { + ...saved, + id: row.id, + operation: row.operation, + status: row.status, + app_mode: row.app_mode, + entity_type: row.entity_type || "", + entity_id: row.entity_id || "", + job_id: row.job_id || saved.job_id || null, + started_at: row.started_at, + finished_at: row.finished_at, + duration_ms: row.duration_ms, + result_ref: row.result_ref, + error: row.error_json || saved.error || null, + steps: stepRows.map((step) => ({ + id: step.id, + sequence: step.sequence, + provider: step.provider, + operation: step.operation, + status: step.status, + input_summary: step.input_summary || "", + output_summary: step.output_summary || "", + request_id: step.request_id, + raw_ref: step.raw_ref, + usage: step.usage_json, + attempts: step.attempts, + started_at: step.started_at, + finished_at: step.finished_at, + latency_ms: step.latency_ms, + error: step.error_json, + })), + }; + } +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/routes/index.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/routes/index.js new file mode 100644 index 00000000..a9d23924 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/routes/index.js @@ -0,0 +1,473 @@ +import { + HttpError, + accepted, + created, + fail, + isOriginAllowed, + ok, + parseAllowedOrigins, + readJson, + withCors, + withSecurityHeaders, +} from "../utils/http.js"; +import { makeRequestId } from "../utils/ids.js"; +import { enforceRateLimit } from "../security/rateLimiter.js"; + +function route(method, pattern, names, handler, access = method === "GET" ? "viewer" : "member", audit = null) { + return { method, pattern, names, handler, access, audit }; +} + +function paramsFrom(match, names) { + return Object.fromEntries(names.map((name, index) => [name, decodeURIComponent(match[index + 1])])); +} + +function listMeta(data, providerMode = "real") { + return { + count: Array.isArray(data) ? data.length : undefined, + provider_mode: providerMode, + }; +} + +function isSalesBusinessPath(pathname) { + return /^\/api\/(sales-goals|target-enterprises|dossiers|provider-runs|jobs)(?:\/|$)/.test(pathname); +} + +function isProviderProbePath(pathname) { + return /^\/api\/providers\/[^/]+\/probe$/.test(pathname); +} + +function isPaidOperation(method, pathname) { + if (method !== "POST") return false; + return isProviderProbePath(pathname) + || /\/company-search$/.test(pathname) + || /\/dossiers$/.test(pathname) + || /\/qa(?:\/commit-memory)?$/.test(pathname) + || /\/materials\/(?:import|sync-openviking|feishu-import)$/.test(pathname); +} + +function requestClientKey(req, trustProxy = false) { + if (trustProxy) { + const forwarded = String(req.headers?.["x-forwarded-for"] || "").split(",")[0].trim(); + if (forwarded) return forwarded.slice(0, 120); + } + return String(req.socket?.remoteAddress || "unknown").slice(0, 120); +} + +export function createRouter(providerService, options = {}) { + const salesService = options.salesService || null; + const feishuImportTaskService = options.feishuImportTaskService || null; + const adminStatusService = options.adminStatusService || null; + const staticFrontend = options.staticFrontend || null; + const authService = options.authService || null; + const rateLimiters = options.rateLimiters || null; + const env = options.env || null; + const allowedOrigins = parseAllowedOrigins(env?.value?.("ALLOWED_ORIGINS", "") || ""); + const maxBodyBytes = Math.max(1024, env?.number?.("API_MAX_BODY_BYTES", 1024 * 1024) || 1024 * 1024); + const trustProxy = ["1", "true", "yes", "on"].includes(String(env?.value?.("TRUST_PROXY", "false") || "").toLowerCase()); + const runtimePolicy = options.runtimePolicy || { + ready: true, + fail_closed: true, + blockers: [], + }; + const providerMode = "real"; + const freshSalesData = async (read, options = {}) => { + await salesService?.refreshPersistedState?.(options); + return read(); + }; + const routes = [ + route("GET", /^\/api\/health$/, [], async () => ({ + data: { + status: runtimePolicy.ready ? "ok" : "degraded", + service: "sales-intelligence-workbench-api", + version: "0.10.0", + provider_mode: providerMode, + runtime_ready: runtimePolicy.ready, + }, + meta: { provider_mode: providerMode }, + }), "public"), + route("GET", /^\/api\/auth\/status$/, [], async ({ req, res }) => ({ + data: authService + ? await authService.sessionStatus(req, res) + : { enabled: false, authenticated: true, bootstrap_required: false, user: null }, + meta: { provider_mode: "supabase_auth" }, + }), "public"), + route("POST", /^\/api\/auth\/bootstrap$/, [], async ({ body, res }) => ({ + data: await authService.bootstrap(body, res), + status: 201, + meta: { provider_mode: "supabase_auth" }, + }), "public"), + route("POST", /^\/api\/auth\/login$/, [], async ({ body, res }) => ({ + data: await authService.login(body, res), + meta: { provider_mode: "supabase_auth" }, + }), "public"), + route("POST", /^\/api\/auth\/cli-login$/, [], async ({ body }) => ({ + data: await authService.cliLogin(body), + meta: { provider_mode: "supabase_auth" }, + }), "public"), + route("POST", /^\/api\/auth\/cli-refresh$/, [], async ({ body }) => ({ + data: await authService.cliRefresh(body), + meta: { provider_mode: "supabase_auth" }, + }), "public"), + route("POST", /^\/api\/auth\/refresh$/, [], async ({ req, res }) => ({ + data: await authService.refresh(req, res), + meta: { provider_mode: "supabase_auth" }, + }), "public"), + route("POST", /^\/api\/auth\/logout$/, [], async ({ req, res }) => ({ + data: await authService.logout(req, res), + meta: { provider_mode: "supabase_auth" }, + }), "public"), + route("GET", /^\/api\/providers\/status$/, [], async () => ({ + data: providerService.getProviderStatus(), + meta: { provider_mode: "real" }, + }), "admin"), + route("GET", /^\/api\/admin\/status$/, [], async () => ({ + data: adminStatusService + ? await adminStatusService.getStatus() + : { read_only: true, unavailable: true }, + meta: { provider_mode: providerMode }, + }), "admin"), + route("GET", /^\/api\/admin\/audit-events$/, [], async ({ auth, query }) => { + const data = await authService.listAuditEvents(auth, { + action: query.get("action") || "", + entity_type: query.get("entity_type") || "", + entity_id: query.get("entity_id") || "", + limit: query.get("limit") || 50, + }); + return { + data, + meta: { ...listMeta(data, "local") }, + }; + }, "admin"), + route("GET", /^\/api\/admin\/workspace-export$/, [], async () => { + await salesService?.assertRuntimeReady?.(); + return { + data: await freshSalesData(() => salesService.exportWorkspaceData(), { force: true }), + meta: { provider_mode: "local" }, + }; + }, "owner", ({ auth }) => ({ + action: "workspace.exported", + entity_type: "workspace", + entity_id: auth?.principal?.workspace_id || "", + })), + route("POST", /^\/api\/providers\/web-search\/probe$/, [], async ({ body }) => ({ + data: await providerService.probeWebSearch(body), + meta: { provider_mode: "real" }, + }), "admin", () => ({ action: "provider.probed", entity_type: "provider", entity_id: "web_search" })), + route("POST", /^\/api\/providers\/datapro\/probe$/, [], async ({ body }) => ({ + data: await providerService.probeDataPro(body), + meta: { provider_mode: "real" }, + }), "admin", () => ({ action: "provider.probed", entity_type: "provider", entity_id: "datapro" })), + route("POST", /^\/api\/providers\/model\/probe$/, [], async () => ({ + data: await providerService.probeModel(), + meta: { provider_mode: "real" }, + }), "admin", () => ({ action: "provider.probed", entity_type: "provider", entity_id: "model" })), + route("POST", /^\/api\/providers\/openviking\/probe$/, [], async ({ body }) => ({ + data: await providerService.probeOpenViking(body), + meta: { provider_mode: "real" }, + }), "admin", () => ({ action: "provider.probed", entity_type: "provider", entity_id: "openviking" })), + route("POST", /^\/api\/providers\/supabase\/probe$/, [], async () => ({ + data: await providerService.probeSupabase(), + meta: { provider_mode: "real" }, + }), "admin", () => ({ action: "provider.probed", entity_type: "provider", entity_id: "supabase" })), + + route("GET", /^\/api\/provider-runs$/, [], async ({ query }) => { + const data = await salesService.listProviderRuns({ + operation: query.get("operation") || "", + entity_id: query.get("entity_id") || "", + limit: query.get("limit") || 20, + }); + return { data, meta: { ...listMeta(data, providerMode) } }; + }, "admin"), + route("GET", /^\/api\/provider-runs\/([^/]+)$/, ["provider_run_id"], async ({ params }) => ({ + data: await salesService.getProviderRun(params.provider_run_id), + meta: { provider_mode: providerMode }, + }), "admin"), + route("GET", /^\/api\/jobs$/, [], async ({ query }) => { + const data = await salesService.listPublicJobs({ + job_type: query.get("job_type") || "", + status: query.get("status") || "", + entity_id: query.get("entity_id") || "", + limit: query.get("limit") || 20, + }); + return { data, meta: { ...listMeta(data, providerMode) } }; + }), + route("GET", /^\/api\/jobs\/([^/]+)$/, ["job_id"], async ({ params }) => ({ + data: await salesService.getPublicJob(params.job_id), + meta: { provider_mode: providerMode }, + })), + route("POST", /^\/api\/jobs\/([^/]+)\/cancel$/, ["job_id"], async ({ params }) => ({ + data: salesService.publicJob(await salesService.cancelJob(params.job_id)), + meta: { provider_mode: providerMode }, + }), "member", ({ params }) => ({ + action: "job.cancelled", + entity_type: "job", + entity_id: params.job_id, + })), + route("POST", /^\/api\/jobs\/([^/]+)\/retry$/, ["job_id"], async ({ params }) => ({ + data: await salesService.retryJob(params.job_id), + meta: { provider_mode: providerMode }, + }), "member", ({ params }) => ({ + action: "job.retried", + entity_type: "job", + entity_id: params.job_id, + })), + route("GET", /^\/api\/admin\/usage-budget$/, [], async () => ({ + data: await salesService.getPaidWorkflowUsage(), + meta: {}, + }), "admin"), + + route("GET", /^\/api\/sales-goals$/, [], async () => ({ + data: await freshSalesData(() => salesService.listGoals()), + meta: { provider_mode: "mixed" }, + })), + route("POST", /^\/api\/sales-goals$/, [], async ({ body }) => ({ + data: await salesService.createGoal(body), + status: 201, + meta: { provider_mode: "mixed" }, + }), "member", ({ result }) => ({ + action: "sales_goal.created", + entity_type: "sales_goal", + entity_id: result?.data?.id || "", + })), + route("GET", /^\/api\/sales-goals\/([^/]+)\/target-enterprises$/, ["goal_id"], async ({ params }) => ({ + data: await freshSalesData(() => salesService.listTargetEnterprises(params.goal_id)), + meta: { provider_mode: "mixed" }, + })), + route("POST", /^\/api\/sales-goals\/([^/]+)\/company-search$/, ["goal_id"], async ({ params, body }) => ({ + data: await salesService.searchCompanies(params.goal_id, body), + meta: { provider_mode: "mixed" }, + }), "member", ({ params }) => ({ + action: "company_search.executed", + entity_type: "sales_goal", + entity_id: params.goal_id, + })), + route("POST", /^\/api\/sales-goals\/([^/]+)\/target-enterprises$/, ["goal_id"], async ({ params, body }) => ({ + data: await salesService.addTargetEnterprise(params.goal_id, body), + status: 201, + meta: { provider_mode: "mixed" }, + }), "member", ({ params, result }) => ({ + action: "target_enterprise.added", + entity_type: "target_enterprise", + entity_id: result?.data?.id || params.goal_id, + })), + route("GET", /^\/api\/target-enterprises\/([^/]+)$/, ["enterprise_id"], async ({ params, query }) => ({ + data: await freshSalesData(() => salesService.enterpriseDetail(params.enterprise_id, { + goal_id: query.get("goal_id") || "", + })), + meta: { provider_mode: "mixed" }, + })), + route("GET", /^\/api\/target-enterprises\/([^/]+)\/progress$/, ["enterprise_id"], async ({ params }) => ({ + data: await freshSalesData(() => salesService.progressView(params.enterprise_id)), + meta: { provider_mode: "mixed" }, + })), + route("GET", /^\/api\/target-enterprises\/([^/]+)\/dossiers$/, ["enterprise_id"], async ({ params }) => ({ + data: await freshSalesData(() => salesService.listDossiers(params.enterprise_id)), + meta: { provider_mode: "mixed" }, + })), + route("POST", /^\/api\/target-enterprises\/([^/]+)\/dossiers$/, ["enterprise_id"], async ({ params, body, auth }) => { + if (salesService.asyncJobsEnabled) { + return { + data: await salesService.enqueueDossier(params.enterprise_id, body, { + created_by: auth?.principal?.id || null, + }), + status: 202, + meta: { provider_mode: "mixed", execution_mode: "asynchronous" }, + }; + } + return { + data: await salesService.createDossier(params.enterprise_id, body), + status: 201, + meta: { provider_mode: "mixed", execution_mode: "synchronous" }, + }; + }, "member", ({ params }) => ({ + action: "dossier.generation_requested", + entity_type: "target_enterprise", + entity_id: params.enterprise_id, + })), + route("GET", /^\/api\/dossiers\/([^/]+)$/, ["dossier_id"], async ({ params }) => ({ + data: await freshSalesData(() => salesService.dossierDetail(params.dossier_id), { minIntervalMs: 5_000 }), + meta: { provider_mode: "mixed" }, + })), + route("GET", /^\/api\/target-enterprises\/([^/]+)\/materials$/, ["enterprise_id"], async ({ params }) => ({ + data: await freshSalesData(() => salesService.listMaterials(params.enterprise_id)), + meta: { provider_mode: "mixed" }, + })), + route("GET", /^\/api\/target-enterprises\/([^/]+)\/materials\/sources$/, ["enterprise_id"], async ({ params }) => ({ + data: await freshSalesData(() => salesService.listMaterialSyncSources(params.enterprise_id)), + meta: { provider_mode: "mixed" }, + })), + route("GET", /^\/api\/target-enterprises\/([^/]+)\/materials\/sync-state$/, ["enterprise_id"], async ({ params, query }) => ({ + data: await freshSalesData(() => salesService.getMaterialSyncState(params.enterprise_id, { + source_id: query.get("source_id") || "", + title: query.get("display_name") || query.get("external_id") || "资料同步源", + source: { + type: query.get("source_type") || "manual", + external_id: query.get("external_id") || "", + checkpoint_key: query.get("checkpoint_key") || "latest", + }, + })), + meta: { provider_mode: "mixed" }, + })), + route("GET", /^\/api\/feishu-import\/status$/, [], async () => ({ + data: feishuImportTaskService?.status?.() || { + available: false, + supported_sources: [], + }, + meta: { provider_mode: "local_cli" }, + }), "viewer"), + route("POST", /^\/api\/target-enterprises\/([^/]+)\/materials\/feishu-import$/, ["enterprise_id"], async ({ params, body }) => ({ + data: await feishuImportTaskService.start(params.enterprise_id, body), + status: 202, + meta: { provider_mode: "local_cli", execution_mode: "asynchronous" }, + }), "member", ({ params, result }) => ({ + action: "feishu_material.import_requested", + entity_type: "target_enterprise", + entity_id: params.enterprise_id, + metadata: { task_id: result?.data?.id || null }, + })), + route("GET", /^\/api\/target-enterprises\/([^/]+)\/materials\/feishu-import\/([^/]+)$/, ["enterprise_id", "task_id"], async ({ params }) => ({ + data: feishuImportTaskService.get(params.enterprise_id, params.task_id), + meta: { provider_mode: "local_cli" }, + }), "member"), + route("POST", /^\/api\/target-enterprises\/([^/]+)\/materials\/import$/, ["enterprise_id"], async ({ params, body }) => ({ + data: await salesService.importMaterial(params.enterprise_id, body), + status: 201, + meta: { provider_mode: "mixed" }, + }), "member", ({ params, result }) => ({ + action: "material.imported", + entity_type: "sales_material", + entity_id: result?.data?.material?.id || params.enterprise_id, + })), + route("POST", /^\/api\/target-enterprises\/([^/]+)\/materials\/source-action$/, ["enterprise_id"], async ({ params, body }) => ({ + data: await salesService.updateMaterialSyncSource(params.enterprise_id, body), + meta: { provider_mode: "mixed" }, + }), "member", ({ params, result }) => ({ + action: "material_source.updated", + entity_type: "material_source", + entity_id: result?.data?.source?.id || params.enterprise_id, + })), + route("POST", /^\/api\/target-enterprises\/([^/]+)\/materials\/sync-openviking$/, ["enterprise_id"], async ({ params, body, auth }) => { + if (salesService.asyncJobsEnabled) { + const data = await salesService.enqueueMaterialsToOpenViking(params.enterprise_id, { + idempotency_key: body.idempotency_key || null, + created_by: auth?.principal?.id || null, + }); + return { + data, + status: data.id ? 202 : 200, + meta: { provider_mode: "mixed", execution_mode: data.id ? "asynchronous" : "skipped" }, + }; + } + return { + data: await salesService.syncMaterialsToOpenViking(params.enterprise_id), + meta: { provider_mode: "mixed", execution_mode: "synchronous" }, + }; + }, "member", ({ params }) => ({ + action: "openviking.sync_requested", + entity_type: "target_enterprise", + entity_id: params.enterprise_id, + })), + route("GET", /^\/api\/target-enterprises\/([^/]+)\/qa$/, ["enterprise_id"], async ({ params }) => ({ + data: await freshSalesData(() => salesService.getQa(params.enterprise_id)), + meta: { provider_mode: "mixed" }, + })), + route("POST", /^\/api\/target-enterprises\/([^/]+)\/qa$/, ["enterprise_id"], async ({ params, body }) => ({ + data: await salesService.askQuestion(params.enterprise_id, body), + meta: { provider_mode: "mixed" }, + }), "member", ({ params }) => ({ + action: "qa.answered", + entity_type: "target_enterprise", + entity_id: params.enterprise_id, + })), + route("POST", /^\/api\/target-enterprises\/([^/]+)\/qa\/commit-memory$/, ["enterprise_id"], async ({ params }) => ({ + data: await salesService.commitQaMemory(params.enterprise_id), + meta: { provider_mode: "mixed" }, + }), "member", ({ params }) => ({ + action: "qa.memory_committed", + entity_type: "target_enterprise", + entity_id: params.enterprise_id, + })), + ]; + + return async function handle(req, res) { + const requestId = makeRequestId(); + try { + const url = new URL(req.url, "http://localhost"); + const isApi = url.pathname.startsWith("/api"); + withSecurityHeaders(res, { api: isApi }); + if (isApi && !isOriginAllowed(req, allowedOrigins)) { + throw new HttpError(403, "origin_not_allowed", "当前请求来源不在允许列表中。"); + } + withCors(req, res, allowedOrigins); + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + if (staticFrontend && !url.pathname.startsWith("/api")) { + const served = await staticFrontend(req, res, url.pathname); + if (served) return; + } + const found = routes.find((item) => item.method === req.method && item.pattern.test(url.pathname)); + if (!found) throw new HttpError(404, "not_found", "API route was not found.", { method: req.method, path: url.pathname }); + + const clientKey = requestClientKey(req, trustProxy); + if (rateLimiters?.general) enforceRateLimit(res, rateLimiters.general, clientKey); + if (/^\/api\/auth\/(?:bootstrap|login|cli-login|cli-refresh)$/.test(url.pathname) && rateLimiters?.auth) { + enforceRateLimit(res, rateLimiters.auth, clientKey, "auth_rate_limit_exceeded"); + } + let auth = null; + if (found.access !== "public") { + if (!authService) throw new HttpError(503, "auth_not_configured", "身份认证尚未完成配置。"); + auth = await authService.authenticateRequest(req, res); + authService.requireRole(auth, found.access); + } + if ( + runtimePolicy.fail_closed + && !runtimePolicy.ready + && (isSalesBusinessPath(url.pathname) || isPaidOperation(req.method, url.pathname)) + ) { + throw new HttpError(503, "runtime_not_ready", "Runtime configuration is not ready."); + } + if (isSalesBusinessPath(url.pathname)) { + await salesService?.assertRuntimeReady?.(); + } + if (req.method !== "GET" && req.method !== "HEAD" && found.access !== "public") { + authService?.assertCsrf(req, auth); + if (rateLimiters?.write) enforceRateLimit(res, rateLimiters.write, auth?.principal?.id || clientKey); + } + if (isPaidOperation(req.method, url.pathname) && rateLimiters?.paid) { + enforceRateLimit(res, rateLimiters.paid, auth?.principal?.id || clientKey, "paid_operation_rate_limit_exceeded"); + } + + const match = url.pathname.match(found.pattern); + const params = paramsFrom(match, found.names); + const body = req.method === "POST" ? await readJson(req, { maxBytes: maxBodyBytes }) : {}; + const result = await found.handler({ params, body, query: url.searchParams, request_id: requestId, req, res, auth }); + if (found.audit && auth?.principal && authService?.recordAudit) { + const descriptor = typeof found.audit === "function" + ? found.audit({ params, body, result, auth }) + : found.audit; + if (descriptor?.action) { + await authService.recordAudit(auth, { + ...descriptor, + request_id: requestId, + after: { + status: result.status || 200, + ...(descriptor.after || {}), + }, + }); + } + } + const meta = { + request_id: requestId, + ...(result.meta || {}), + }; + if (result.status === 201) created(res, result.data, meta); + else if (result.status === 202) accepted(res, result.data, meta); + else ok(res, result.data, meta); + } catch (error) { + fail(res, error, requestId); + } + }; +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/security/authService.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/security/authService.js new file mode 100644 index 00000000..450eae4b --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/security/authService.js @@ -0,0 +1,632 @@ +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; +import { HttpError } from "../utils/http.js"; + +const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); +const ROLE_LEVEL = Object.freeze({ viewer: 0, member: 1, admin: 2, owner: 3 }); +const AUDIT_FILTER_PATTERN = /^[a-z0-9_.:-]+$/i; +const AUDIT_SECRET_KEY_PATTERN = /(?:authorization|cookie|password|secret|token|api[_-]?key|raw[_-]?ref|openviking[_-]?(?:uri|ref))/i; + +function enabled(value) { + return TRUE_VALUES.has(String(value || "").trim().toLowerCase()); +} + +function authBaseUrl(value) { + return String(value || "").trim().replace(/\/$/, "").replace(/\/rest\/v1$/, ""); +} + +function normalizeEmail(value) { + return String(value || "").trim().toLowerCase(); +} + +function normalizeUsername(value) { + return String(value || "").trim().replace(/\s+/g, " "); +} + +function validateEmail(value) { + const email = normalizeEmail(value); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw new HttpError(400, "invalid_email", "请输入有效的邮箱地址。"); + } + return email; +} + +function validateUsername(value) { + const username = normalizeUsername(value); + if ( + username.length < 2 + || username.length > 40 + || /[@\u0000-\u001f\u007f]/u.test(username) + ) { + throw new HttpError(400, "invalid_username", "用户名需要为 2 至 40 个字符,不能包含 @ 或控制字符。"); + } + return username; +} + +function validatePassword(value) { + const password = String(value || ""); + if (password.length < 10 || password.length > 256) { + throw new HttpError(400, "weak_password", "密码长度需要为 10 至 256 个字符。"); + } + return password; +} + +function internalOwnerEmail(workspaceId) { + const suffix = createHash("sha256").update(String(workspaceId || "")).digest("hex").slice(0, 24); + return `owner-${suffix}@sales-workbench.invalid`; +} + +function parseCookies(header = "") { + const cookies = {}; + for (const item of String(header || "").split(";")) { + const separator = item.indexOf("="); + if (separator < 1) continue; + const name = item.slice(0, separator).trim(); + const value = item.slice(separator + 1).trim(); + try { + cookies[name] = decodeURIComponent(value); + } catch { + cookies[name] = value; + } + } + return cookies; +} + +function serializeCookie(name, value, options = {}) { + const parts = [`${name}=${encodeURIComponent(value)}`, `Path=${options.path || "/"}`]; + if (options.maxAge !== undefined) parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAge))}`); + if (options.httpOnly) parts.push("HttpOnly"); + if (options.secure) parts.push("Secure"); + parts.push(`SameSite=${options.sameSite || "Strict"}`); + return parts.join("; "); +} + +function safeEqual(left, right) { + const a = Buffer.from(String(left || "")); + const b = Buffer.from(String(right || "")); + return a.length > 0 && a.length === b.length && timingSafeEqual(a, b); +} + +function tokenHash(token) { + return createHash("sha256").update(String(token || "")).digest("hex"); +} + +function sanitizeAuditValue(value, depth = 0) { + if (value === null || value === undefined) return null; + if (depth > 4) return "[depth-limited]"; + if (typeof value === "string") return value.slice(0, 1000); + if (typeof value === "number" || typeof value === "boolean") return value; + if (Array.isArray(value)) return value.slice(0, 50).map((item) => sanitizeAuditValue(item, depth + 1)); + if (typeof value !== "object") return String(value).slice(0, 1000); + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => !AUDIT_SECRET_KEY_PATTERN.test(String(key))) + .slice(0, 50) + .map(([key, item]) => [String(key).slice(0, 120), sanitizeAuditValue(item, depth + 1)]), + ); +} + +function auditFilter(value, name, maxLength) { + const text = String(value || "").trim(); + if (!text) return ""; + if (text.length > maxLength || !AUDIT_FILTER_PATTERN.test(text)) { + throw new HttpError(400, "invalid_audit_filter", `审计筛选条件 ${name} 无效。`); + } + return text; +} + +function safeAuthError(status, body, context = "session") { + const code = String(body?.error_code || body?.code || body?.error || `auth_http_${status}`); + const message = String(body?.msg || body?.message || ""); + const expiredJwt = status === 403 && /(?:bad_jwt|invalid jwt|jwt.{0,40}expired|token.{0,20}expired)/i.test(`${code} ${message}`); + if (status === 400 || status === 401 || expiredJwt) { + return new HttpError(401, "invalid_credentials", "用户名或密码不正确,或登录会话已经过期。"); + } + if (status === 422 || /already|registered|exists/i.test(message)) { + return new HttpError(409, "account_exists", "管理员账号已经创建,请直接登录。"); + } + return new HttpError(502, "auth_provider_error", "身份服务暂时不可用,请稍后重试。", { provider_code: code }); +} + +function validateLoginCredentials(body) { + const identifier = String(body?.username || body?.account || body?.email || "").trim(); + if (!identifier) throw new HttpError(400, "username_required", "请输入用户名。"); + const password = validatePassword(body?.password); + return { identifier, password }; +} + +function publicUser(principal) { + if (!principal) return null; + return { + id: principal.id, + username: principal.username, + display_name: principal.display_name, + }; +} + +export class AuthService { + constructor(options = {}) { + this.env = options.env; + this.fetch = options.fetchImpl || fetch; + this.dataProvider = options.dataProvider; + this.baseUrl = authBaseUrl(this.env?.value?.("SUPABASE_API_URL", "")); + this.serviceRoleKey = this.env?.value?.("SUPABASE_SERVICE_ROLE_KEY", "") || ""; + this.workspaceId = this.env?.value?.("APP_WORKSPACE_ID", "") || ""; + this.authEnabled = enabled(this.env?.value?.("HTTP_AUTH_ENABLED", "false")); + this.bootstrapEnabled = enabled(this.env?.value?.("AUTH_BOOTSTRAP_ENABLED", "true")); + this.cookieSecure = enabled(this.env?.value?.("AUTH_COOKIE_SECURE", "false")); + this.timeoutMs = this.env?.number?.("AUTH_PROVIDER_TIMEOUT_MS", 12000) || 12000; + this.cacheTtlMs = this.env?.number?.("AUTH_SESSION_CACHE_TTL_MS", 15000) || 15000; + this.refreshMaxAge = this.env?.number?.("AUTH_REFRESH_COOKIE_MAX_AGE", 31536000) || 31536000; + this.cache = new Map(); + this.bootstrapPromise = null; + this.cookieNames = Object.freeze({ + access: "siw_access", + refresh: "siw_refresh", + csrf: "siw_csrf", + }); + } + + isEnabled() { + return this.authEnabled; + } + + isConfigured() { + return Boolean(this.baseUrl && this.serviceRoleKey && this.workspaceId && this.dataProvider?.isConfigured?.()); + } + + assertConfigured() { + if (!this.isConfigured()) { + throw new HttpError(503, "auth_not_configured", "身份认证尚未完成配置。"); + } + } + + async authRequest(path, options = {}) { + this.assertConfigured(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await this.fetch(`${this.baseUrl}/auth/v1/${String(path).replace(/^\//, "")}`, { + method: options.method || "GET", + headers: { + Accept: "application/json", + apikey: this.serviceRoleKey, + Authorization: `Bearer ${options.accessToken || this.serviceRoleKey}`, + ...(options.body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller.signal, + }); + const text = await response.text(); + let body = {}; + try { + body = text ? JSON.parse(text) : {}; + } catch { + body = {}; + } + if (!response.ok) throw safeAuthError(response.status, body, options.context); + return body; + } catch (error) { + if (error?.name === "AbortError") { + throw new HttpError(504, "auth_timeout", "身份服务响应超时,请稍后重试。"); + } + if (error instanceof HttpError) throw error; + throw new HttpError(502, "auth_unreachable", "无法连接身份服务,请稍后重试。"); + } finally { + clearTimeout(timeout); + } + } + + async isBootstrapRequired() { + if (!this.authEnabled || !this.bootstrapEnabled || !this.isConfigured()) return false; + const bindings = await this.dataProvider.select("app_workspace_members", { + select: "user_id", + filters: { workspace_id: `eq.${this.workspaceId}` }, + limit: 1, + }); + return !Array.isArray(bindings) || bindings.length === 0; + } + + async singleLoginAccount() { + const bindings = await this.dataProvider.select("app_workspace_members", { + select: "workspace_id,user_id,role", + filters: { + workspace_id: `eq.${this.workspaceId}`, + }, + limit: 2, + }); + if (!Array.isArray(bindings) || bindings.length !== 1) { + throw new HttpError(503, "single_user_account_invalid", "本机管理员账号状态异常,请检查安装配置。"); + } + const binding = bindings[0]; + const profiles = await this.dataProvider.select("app_users", { + select: "id,display_name", + filters: { id: `eq.${binding.user_id}` }, + limit: 1, + }); + const username = normalizeUsername(profiles?.[0]?.display_name || ""); + if (!username) { + throw new HttpError(503, "single_user_account_invalid", "本机管理员用户名缺失,请检查安装配置。"); + } + return { id: binding.user_id, username }; + } + + async resolveLoginEmail(identifier) { + if (String(identifier).includes("@")) return validateEmail(identifier); + const username = validateUsername(identifier); + const account = await this.singleLoginAccount(); + if (normalizeUsername(account.username).toLowerCase() !== username.toLowerCase()) { + throw new HttpError(401, "invalid_credentials", "用户名或密码不正确,或登录会话已经过期。"); + } + const result = await this.authRequest(`admin/users/${encodeURIComponent(account.id)}`); + const user = result?.user || result; + if (!user?.email) { + throw new HttpError(503, "single_user_account_invalid", "本机管理员账号无法登录,请检查安装配置。"); + } + return validateEmail(user.email); + } + + async principalForUser(user) { + const memberships = await this.dataProvider.select("app_workspace_members", { + select: "workspace_id,user_id,role", + filters: { + workspace_id: `eq.${this.workspaceId}`, + user_id: `eq.${user.id}`, + }, + limit: 1, + }); + const membership = memberships?.[0]; + if (!membership || !Object.hasOwn(ROLE_LEVEL, membership.role)) { + throw new HttpError(403, "workspace_access_denied", "当前账号没有访问此工作区的权限。"); + } + const profiles = await this.dataProvider.select("app_users", { + select: "id,display_name", + filters: { id: `eq.${user.id}` }, + limit: 1, + }); + return Object.freeze({ + id: user.id, + email: normalizeEmail(user.email), + username: normalizeUsername(profiles?.[0]?.display_name || user.user_metadata?.username || user.user_metadata?.display_name || "管理员"), + display_name: normalizeUsername(profiles?.[0]?.display_name || user.user_metadata?.username || user.user_metadata?.display_name || "管理员"), + workspace_id: membership.workspace_id, + role: membership.role, + }); + } + + async verifyAccessToken(accessToken) { + if (!accessToken) throw new HttpError(401, "authentication_required", "请先登录。"); + const key = tokenHash(accessToken); + const cached = this.cache.get(key); + if (cached && cached.expiresAt > Date.now()) return cached.principal; + const user = await this.authRequest("user", { accessToken }); + const principal = await this.principalForUser(user); + this.cache.set(key, { principal, expiresAt: Date.now() + this.cacheTtlMs }); + return principal; + } + + async passwordSessionByEmail(email, password) { + const session = await this.authRequest("token?grant_type=password", { + method: "POST", + body: { email, password }, + }); + const principal = await this.verifyAccessToken(session.access_token); + return { ...session, principal }; + } + + async passwordSession(identifier, password) { + return this.passwordSessionByEmail(await this.resolveLoginEmail(identifier), password); + } + + async refreshSession(refreshToken) { + if (!refreshToken) throw new HttpError(401, "authentication_required", "请先登录。"); + const session = await this.authRequest("token?grant_type=refresh_token", { + method: "POST", + body: { refresh_token: refreshToken }, + }); + const principal = await this.verifyAccessToken(session.access_token); + return { ...session, principal }; + } + + setSessionCookies(res, session, csrfToken = randomBytes(24).toString("base64url")) { + const accessMaxAge = Math.max(60, Number(session.expires_in) || 3600); + res.setHeader("Set-Cookie", [ + serializeCookie(this.cookieNames.access, session.access_token, { + httpOnly: true, + secure: this.cookieSecure, + maxAge: accessMaxAge, + }), + serializeCookie(this.cookieNames.refresh, session.refresh_token, { + httpOnly: true, + secure: this.cookieSecure, + maxAge: this.refreshMaxAge, + }), + serializeCookie(this.cookieNames.csrf, csrfToken, { + httpOnly: false, + secure: this.cookieSecure, + maxAge: this.refreshMaxAge, + }), + ]); + return csrfToken; + } + + clearSessionCookies(res) { + res.setHeader("Set-Cookie", Object.values(this.cookieNames).map((name) => serializeCookie(name, "", { + httpOnly: name !== this.cookieNames.csrf, + secure: this.cookieSecure, + maxAge: 0, + }))); + } + + async authenticateRequest(req, res) { + if (!this.authEnabled) { + return { + principal: Object.freeze({ + id: "auth-disabled-diagnostic", + email: "", + display_name: "本地开发者", + workspace_id: this.workspaceId, + role: "owner", + }), + source: "disabled", + }; + } + this.assertConfigured(); + const authorization = String(req.headers?.authorization || ""); + const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1]?.trim() || ""; + const cookies = parseCookies(req.headers?.cookie); + const accessToken = bearer || cookies[this.cookieNames.access] || ""; + if (!accessToken) { + if (!cookies[this.cookieNames.refresh]) return null; + try { + const session = await this.refreshSession(cookies[this.cookieNames.refresh]); + this.setSessionCookies(res, session, cookies[this.cookieNames.csrf] || undefined); + return { principal: session.principal, source: "cookie" }; + } catch (refreshError) { + this.clearSessionCookies(res); + if (refreshError?.status === 403) throw refreshError; + return null; + } + } + try { + return { + principal: await this.verifyAccessToken(accessToken), + source: bearer ? "bearer" : "cookie", + }; + } catch (error) { + if (bearer || error?.status === 403 || !cookies[this.cookieNames.refresh]) throw error; + try { + const session = await this.refreshSession(cookies[this.cookieNames.refresh]); + this.setSessionCookies(res, session, cookies[this.cookieNames.csrf] || undefined); + return { principal: session.principal, source: "cookie" }; + } catch (refreshError) { + this.clearSessionCookies(res); + if (refreshError?.status === 403) throw refreshError; + return null; + } + } + } + + requireRole(auth, minimumRole) { + if (!auth?.principal) throw new HttpError(401, "authentication_required", "请先登录。"); + const actual = ROLE_LEVEL[auth.principal.role]; + const required = ROLE_LEVEL[minimumRole]; + if (!Number.isInteger(actual) || !Number.isInteger(required) || actual < required) { + throw new HttpError(403, "insufficient_role", "当前账号没有执行此操作的权限。", { + required_role: minimumRole, + }); + } + } + + async recordAudit(auth, event = {}) { + const action = String(event.action || "").trim().slice(0, 120); + if (!action || !AUDIT_FILTER_PATTERN.test(action) || !this.workspaceId || !this.dataProvider?.insert) return false; + try { + await this.dataProvider.insert("audit_events", [{ + id: `audit_${randomUUID()}`, + workspace_id: this.workspaceId, + actor_user_id: /^[0-9a-f-]{36}$/i.test(String(auth?.principal?.id || "")) ? auth.principal.id : null, + action, + entity_type: String(event.entity_type || "").trim().slice(0, 80) || null, + entity_id: String(event.entity_id || "").trim().slice(0, 240) || null, + request_id: String(event.request_id || "").trim().slice(0, 120) || null, + before_json: sanitizeAuditValue(event.before), + after_json: sanitizeAuditValue(event.after), + }], { returning: false }); + return true; + } catch (error) { + console.error("Audit write failed.", { action, code: String(error?.code || "audit_write_failed") }); + return false; + } + } + + async listAuditEvents(auth, options = {}) { + this.requireRole(auth, "admin"); + this.assertConfigured(); + const action = auditFilter(options.action, "action", 120); + const entityType = auditFilter(options.entity_type, "entity_type", 80); + const entityId = auditFilter(options.entity_id, "entity_id", 240); + const limit = Math.min(200, Math.max(1, Number.parseInt(options.limit, 10) || 50)); + const filters = { workspace_id: `eq.${this.workspaceId}` }; + if (action) filters.action = `eq.${action}`; + if (entityType) filters.entity_type = `eq.${entityType}`; + if (entityId) filters.entity_id = `eq.${entityId}`; + const rows = await this.dataProvider.select("audit_events", { + select: "id,actor_user_id,action,entity_type,entity_id,request_id,before_json,after_json,created_at", + filters, + order: "created_at.desc", + limit, + }); + return (rows || []).map((row) => ({ + id: row.id, + actor_user_id: row.actor_user_id || null, + action: row.action, + entity_type: row.entity_type || null, + entity_id: row.entity_id || null, + request_id: row.request_id || null, + before: sanitizeAuditValue(row.before_json), + after: sanitizeAuditValue(row.after_json), + created_at: row.created_at || null, + })); + } + + assertCsrf(req, auth) { + if (!this.authEnabled || auth?.source !== "cookie") return; + const cookies = parseCookies(req.headers?.cookie); + const cookieToken = cookies[this.cookieNames.csrf] || ""; + const headerToken = req.headers?.["x-csrf-token"] || ""; + if (!safeEqual(cookieToken, headerToken)) { + throw new HttpError(403, "csrf_failed", "请求校验失败,请刷新页面后重试。"); + } + } + + async sessionStatus(req, res) { + if (!this.authEnabled) { + return { + enabled: false, + authenticated: true, + bootstrap_required: false, + user: { username: "本机管理员", display_name: "本机管理员" }, + }; + } + this.assertConfigured(); + const bootstrapRequired = await this.isBootstrapRequired(); + let auth = null; + try { + auth = await this.authenticateRequest(req, res); + } catch (error) { + if (error?.status === 403) throw error; + this.clearSessionCookies(res); + } + const cookies = parseCookies(req.headers?.cookie); + return { + enabled: true, + authenticated: Boolean(auth?.principal), + bootstrap_required: bootstrapRequired, + csrf_token: auth?.source === "cookie" ? cookies[this.cookieNames.csrf] || "" : "", + user: publicUser(auth?.principal), + }; + } + + async login(body, res) { + if (!this.authEnabled) throw new HttpError(409, "auth_disabled", "当前配置未启用登录。"); + const { identifier, password } = validateLoginCredentials(body); + const session = await this.passwordSession(identifier, password); + const csrfToken = this.setSessionCookies(res, session); + return { + authenticated: true, + csrf_token: csrfToken, + user: publicUser(session.principal), + }; + } + + cliSessionPayload(session) { + return { + token_type: "bearer", + access_token: session.access_token, + refresh_token: session.refresh_token, + expires_in: Math.max(60, Number(session.expires_in) || 3600), + user: publicUser(session.principal), + }; + } + + async cliLogin(body) { + if (!this.authEnabled) throw new HttpError(409, "auth_disabled", "当前配置未启用登录。"); + const { identifier, password } = validateLoginCredentials(body); + return this.cliSessionPayload(await this.passwordSession(identifier, password)); + } + + async cliRefresh(body) { + if (!this.authEnabled) throw new HttpError(409, "auth_disabled", "当前配置未启用登录。"); + const refreshToken = String(body?.refresh_token || "").trim(); + if (!refreshToken || refreshToken.length > 4096) { + throw new HttpError(401, "authentication_required", "CLI 登录会话已过期,请重新登录。"); + } + return this.cliSessionPayload(await this.refreshSession(refreshToken)); + } + + async bootstrap(body, res) { + if (!this.authEnabled || !this.bootstrapEnabled) { + throw new HttpError(404, "not_found", "API route was not found."); + } + if (this.bootstrapPromise) { + await this.bootstrapPromise.catch(() => {}); + throw new HttpError(409, "bootstrap_completed", "本机管理员已经创建,请直接登录。"); + } + this.bootstrapPromise = this.bootstrapAccount(body, res); + try { + return await this.bootstrapPromise; + } finally { + this.bootstrapPromise = null; + } + } + + async bootstrapAccount(body, res) { + this.assertConfigured(); + if (!(await this.isBootstrapRequired())) { + throw new HttpError(409, "bootstrap_completed", "本机管理员已经创建,请直接登录。"); + } + const username = validateUsername(body?.username || body?.display_name); + const password = validatePassword(body?.password); + const email = internalOwnerEmail(this.workspaceId); + const created = await this.authRequest("admin/users", { + method: "POST", + body: { + email, + password, + email_confirm: true, + user_metadata: { display_name: username, username }, + }, + }); + const user = created.user || created; + if (!user?.id) throw new HttpError(502, "auth_provider_error", "身份服务没有返回有效账号。"); + try { + await this.dataProvider.upsert("app_users", [{ id: user.id, display_name: username }], { onConflict: "id" }); + await this.dataProvider.upsert("app_workspace_members", [{ + workspace_id: this.workspaceId, + user_id: user.id, + role: "owner", + }], { onConflict: "workspace_id,user_id" }); + await this.dataProvider.update("app_workspaces", { created_by: user.id }, { + id: `eq.${this.workspaceId}`, + created_by: "is.null", + }, { returning: false }); + } catch (error) { + await this.authRequest(`admin/users/${encodeURIComponent(user.id)}`, { method: "DELETE" }).catch(() => {}); + throw new HttpError(502, "bootstrap_persistence_failed", "个人账号未能写入工作区,已撤销本次创建。", { + provider_code: String(error?.code || "persistence_failed").slice(0, 80), + }); + } + const session = await this.passwordSessionByEmail(email, password); + const csrfToken = this.setSessionCookies(res, session); + return { + authenticated: true, + csrf_token: csrfToken, + user: publicUser(session.principal), + }; + } + + async refresh(req, res) { + if (!this.authEnabled) throw new HttpError(409, "auth_disabled", "当前配置未启用登录。"); + const cookies = parseCookies(req.headers?.cookie); + const session = await this.refreshSession(cookies[this.cookieNames.refresh]); + const csrfToken = this.setSessionCookies(res, session, cookies[this.cookieNames.csrf] || undefined); + return { authenticated: true, csrf_token: csrfToken, user: publicUser(session.principal) }; + } + + async logout(req, res) { + const cookies = parseCookies(req.headers?.cookie); + const accessToken = cookies[this.cookieNames.access] || ""; + if (this.authEnabled && accessToken) { + await this.authRequest("logout?scope=local", { method: "POST", accessToken }).catch(() => {}); + this.cache.delete(tokenHash(accessToken)); + } + this.clearSessionCookies(res); + return { authenticated: false }; + } +} + +export function createAuthService(options = {}) { + return new AuthService(options); +} + +export { ROLE_LEVEL, parseCookies, serializeCookie }; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/security/rateLimiter.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/security/rateLimiter.js new file mode 100644 index 00000000..3d352604 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/security/rateLimiter.js @@ -0,0 +1,76 @@ +import { HttpError } from "../utils/http.js"; + +function positiveInteger(value, fallback) { + const number = Number(value); + return Number.isInteger(number) && number > 0 ? number : fallback; +} + +export class MemoryRateLimiter { + constructor(options = {}) { + this.limit = positiveInteger(options.limit, 60); + this.windowMs = positiveInteger(options.windowMs, 60_000); + this.buckets = new Map(); + this.operations = 0; + } + + consume(key, now = Date.now()) { + const normalizedKey = String(key || "unknown").slice(0, 240); + let bucket = this.buckets.get(normalizedKey); + if (!bucket || bucket.resetAt <= now) { + bucket = { count: 0, resetAt: now + this.windowMs }; + this.buckets.set(normalizedKey, bucket); + } + bucket.count += 1; + this.operations += 1; + if (this.operations % 500 === 0) this.cleanup(now); + const remaining = Math.max(0, this.limit - bucket.count); + const retryAfter = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)); + return { + allowed: bucket.count <= this.limit, + limit: this.limit, + remaining, + retryAfter, + resetAt: bucket.resetAt, + }; + } + + cleanup(now = Date.now()) { + for (const [key, bucket] of this.buckets) { + if (bucket.resetAt <= now) this.buckets.delete(key); + } + } +} + +export function enforceRateLimit(res, limiter, key, code = "rate_limit_exceeded") { + const result = limiter.consume(key); + res.setHeader("X-RateLimit-Limit", String(result.limit)); + res.setHeader("X-RateLimit-Remaining", String(result.remaining)); + if (!result.allowed) { + res.setHeader("Retry-After", String(result.retryAfter)); + throw new HttpError(429, code, "请求过于频繁,请稍后重试。", { + retry_after_seconds: result.retryAfter, + }); + } + return result; +} + +export function createRateLimiters(env) { + return Object.freeze({ + general: new MemoryRateLimiter({ + limit: env?.number?.("API_RATE_LIMIT_PER_MIN", 240) || 240, + windowMs: 60_000, + }), + write: new MemoryRateLimiter({ + limit: env?.number?.("API_WRITE_RATE_LIMIT_PER_MIN", 90) || 90, + windowMs: 60_000, + }), + paid: new MemoryRateLimiter({ + limit: env?.number?.("API_PAID_RATE_LIMIT_PER_MIN", 30) || 30, + windowMs: 60_000, + }), + auth: new MemoryRateLimiter({ + limit: env?.number?.("AUTH_RATE_LIMIT_PER_15_MIN", 20) || 20, + windowMs: 15 * 60_000, + }), + }); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/server.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/server.js new file mode 100644 index 00000000..3953d561 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/server.js @@ -0,0 +1,17 @@ +import { createApp } from "./app.js"; + +const port = Number(process.env.PORT || 8787); +const host = process.env.HOST || "127.0.0.1"; +const server = createApp(); + +server.listen(port, host, () => { + console.log(`sales-intelligence-workbench-api listening on http://${host}:${port}`); +}); + +process.on("SIGTERM", () => { + server.close(() => process.exit(0)); +}); + +process.on("SIGINT", () => { + server.close(() => process.exit(0)); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/services/adminStatusService.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/services/adminStatusService.js new file mode 100644 index 00000000..617ab8bd --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/services/adminStatusService.js @@ -0,0 +1,162 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +function finiteNumber(value, fallback = 0) { + const number = Number(value); + return Number.isFinite(number) ? number : fallback; +} + +function safeCode(value) { + return String(value || "").replace(/[^A-Za-z0-9_.-]/g, "").slice(0, 80) || null; +} + +async function readJson(filePath) { + return JSON.parse(await fs.readFile(filePath, "utf8")); +} + +async function inspectBackups(backupDir) { + if (!backupDir) { + return { + configured: false, + status: "unavailable", + backup_count: 0, + invalid_package_count: 0, + latest: null, + }; + } + + try { + const entries = await fs.readdir(backupDir, { withFileTypes: true }); + const packages = []; + let invalidPackageCount = 0; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + try { + const manifest = await readJson(path.join(backupDir, entry.name, "manifest.json")); + if (!manifest?.backup_id || !manifest?.created_at || !manifest?.row_counts) { + invalidPackageCount += 1; + continue; + } + packages.push({ + backup_id: String(manifest.backup_id).slice(0, 160), + created_at: String(manifest.created_at), + format_version: finiteNumber(manifest.format_version, 0), + table_count: Object.keys(manifest.row_counts || {}).length, + row_count: Object.values(manifest.row_counts || {}) + .reduce((total, value) => total + finiteNumber(value, 0), 0), + file_count: Array.isArray(manifest.files) ? manifest.files.length : 0, + checksums_declared: Array.isArray(manifest.files) + && manifest.files.length > 0 + && manifest.files.every((file) => /^[a-f0-9]{64}$/i.test(String(file?.sha256 || ""))), + }); + } catch { + invalidPackageCount += 1; + } + } + packages.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at))); + return { + configured: true, + status: packages.length ? "ready" : "not_created", + backup_count: packages.length, + invalid_package_count: invalidPackageCount, + latest: packages[0] || null, + }; + } catch (error) { + return { + configured: true, + status: error?.code === "ENOENT" ? "not_created" : "unreadable", + backup_count: 0, + invalid_package_count: 0, + latest: null, + }; + } +} + +async function inspectLiveDoctor(filePath, ttlMs) { + if (!filePath) return { configured: false, status: "unavailable", checked_at: null, fresh: false, checks: [] }; + try { + const report = await readJson(filePath); + const checkedAt = report.checked_at || report.backend?.finished_at || null; + const ageMs = checkedAt ? Math.max(0, Date.now() - new Date(checkedAt).getTime()) : null; + const fresh = ageMs !== null && Number.isFinite(ageMs) && ageMs <= ttlMs; + const checks = Object.entries(report.backend?.checks || {}).map(([provider, check]) => { + const normalized = check?.health && check?.find + ? { called: Boolean(check.health.called || check.find.called), ok: Boolean(check.ok), provider_mode: check.health.provider_mode } + : check || {}; + return { + provider, + called: Boolean(normalized.called), + ok: Boolean(normalized.ok), + provider_mode: String(normalized.provider_mode || "unknown").slice(0, 40), + error_code: safeCode(normalized.error?.code), + }; + }); + return { + configured: true, + status: !fresh ? "stale" : report.ok ? "passed" : "failed", + check_type: String(report.check_type || report.backend?.check_type || "read_only_live").slice(0, 80), + selected_provider: safeCode(report.selected_provider || report.backend?.selected_provider), + checked_at: checkedAt, + fresh, + age_ms: ageMs, + ttl_ms: ttlMs, + runtime_ready: Boolean(report.backend?.runtime_ready), + blocker_count: Array.isArray(report.backend?.blockers) ? report.backend.blockers.length : 0, + checks, + }; + } catch (error) { + return { + configured: true, + status: error?.code === "ENOENT" ? "not_run" : "unreadable", + checked_at: null, + fresh: false, + checks: [], + }; + } +} + +export class AdminStatusService { + constructor(options = {}) { + this.env = options.env; + this.runtimePolicy = options.runtimePolicy; + this.getProviderStatus = options.getProviderStatus || (() => ({ providers: [], repository: {} })); + } + + async getStatus() { + const value = (name, fallback = "") => this.env?.value?.(name, fallback) ?? fallback; + const host = String(value("HOST", "127.0.0.1")); + const ttlMs = Math.max(60_000, finiteNumber(value("LIVE_DOCTOR_TTL_MS", "900000"), 900_000)); + const [backup, liveDoctor] = await Promise.all([ + inspectBackups(String(value("SALES_WORKBENCH_BACKUP_DIR", "")).trim()), + inspectLiveDoctor(String(value("SALES_WORKBENCH_LIVE_DOCTOR_FILE", "")).trim(), ttlMs), + ]); + const providerStatus = this.getProviderStatus(); + return { + schema_version: 1, + read_only: true, + deployment: { + repository_mode: providerStatus.repository?.active || value("REPOSITORY_MODE", "supabase"), + fail_closed: Boolean(this.runtimePolicy.fail_closed), + host, + port: finiteNumber(value("PORT", "8787"), 8787), + loopback_only: ["127.0.0.1", "::1", "localhost"].includes(host), + http_auth_enabled: Boolean(this.runtimePolicy.http_auth_enabled), + }, + workspace: { + slug: String(value("APP_WORKSPACE_SLUG", "default")).slice(0, 120), + name: String(value("APP_WORKSPACE_NAME", "Sales Workbench")).slice(0, 160), + }, + providers: (providerStatus.providers || []) + .map((provider) => ({ + id: provider.id, + label: provider.label, + status: provider.status, + configured: !["missing_config", "disabled"].includes(provider.status), + run_enabled: provider.safe_config?.run_enabled !== false, + missing: provider.missing || [], + })), + backup, + live_doctor: liveDoctor, + }; + } +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/services/feishuImportTaskService.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/services/feishuImportTaskService.js new file mode 100644 index 00000000..254d6b88 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/services/feishuImportTaskService.js @@ -0,0 +1,274 @@ +import { runFeishuImport } from "../../scripts/import-feishu-cli.mjs"; +import { HttpError } from "../utils/http.js"; +import { makeId } from "../utils/ids.js"; +import { nowIso } from "../utils/time.js"; + +const ACTIVE_STATUSES = new Set(["queued", "running"]); +const ALLOWED_DOCUMENT_HOSTS = [ + "feishu.cn", + "larkoffice.com", + "larksuite.com", +]; + +function enabledValue(value, fallback) { + const text = String(value ?? "").trim().toLowerCase(); + if (!text) return fallback; + return ["1", "true", "yes", "on"].includes(text); +} + +function compact(value, maxLength) { + return String(value || "").replace(/\s+/g, " ").trim().slice(0, maxLength); +} + +function validateDate(value, field) { + const text = compact(value, 80); + if (!text) return ""; + if (!Number.isFinite(Date.parse(text))) { + throw new HttpError(400, "bad_request", `${field}不是有效日期。`); + } + return text; +} + +function validDocumentTarget(value) { + try { + const url = new URL(value); + const allowedHost = ALLOWED_DOCUMENT_HOSTS.some((host) => ( + url.hostname === host || url.hostname.endsWith(`.${host}`) + )); + return ( + url.protocol === "https:" + && allowedHost + && /^\/(?:wiki|docx)\//.test(url.pathname) + ); + } catch { + return false; + } +} + +function validConversationTarget(value) { + if (/^ou_[A-Za-z0-9_-]+$/i.test(value)) return false; + if (value.startsWith("oc_")) return /^oc_[A-Za-z0-9]+$/.test(value); + return value.length <= 100; +} + +function publicImport(imported) { + return { + source_type: imported.source_type || "", + title: imported.title || "", + action: imported.action || "", + status: imported.status || "", + material_id: imported.material_id || imported.imported_material_id || null, + duration_ms: Number(imported.duration_ms || 0), + error: imported.error?.message + ? { message: compact(imported.error.message, 300) } + : null, + }; +} + +function publicTask(task) { + return { + id: task.id, + company_id: task.company_id, + source_kind: task.source_kind, + source_label: task.source_label, + status: task.status, + summary: task.summary, + created_at: task.created_at, + started_at: task.started_at, + completed_at: task.completed_at, + result: task.result + ? { + ok: Boolean(task.result.ok), + summary: { ...task.result.summary }, + imports: (task.result.imports || []).map(publicImport), + } + : null, + error: task.error ? { message: task.error.message } : null, + }; +} + +export class FeishuImportTaskService { + constructor(options = {}) { + this.env = options.env; + this.salesService = options.salesService; + this.runner = options.runner || runFeishuImport; + this.tasks = new Map(); + this.enabled = enabledValue( + this.env?.value?.("FEISHU_CLI_IMPORT_ENABLED", "") + || this.env?.value?.("FEISHU_SYNC_ENABLED", ""), + false, + ); + this.maxTasks = Math.max(20, Number(this.env?.value?.("FEISHU_CLI_IMPORT_TASK_LIMIT", "100")) || 100); + } + + status() { + return { + available: this.enabled, + supported_sources: ["conversation", "document"], + }; + } + + normalizeRequest(companyId, body = {}) { + if (!this.enabled) { + throw new HttpError( + 503, + "feishu_import_unavailable", + "当前部署未启用飞书资料导入。", + ); + } + this.salesService.requireCompany(companyId); + const sourceKind = compact(body.source_kind, 40); + if (!["conversation", "document"].includes(sourceKind)) { + throw new HttpError(400, "bad_request", "资料类型必须是飞书会话或云文档。"); + } + const target = compact(body.target, sourceKind === "document" ? 1000 : 200); + if (!target) throw new HttpError(400, "bad_request", "请输入要导入的飞书资料。"); + if (/[\u0000-\u001f]/.test(target)) { + throw new HttpError(400, "bad_request", "飞书资料标识包含无效字符。"); + } + if (sourceKind === "document" && !validDocumentTarget(target)) { + throw new HttpError(400, "bad_request", "请输入完整的 https:// 飞书云文档或知识库链接。"); + } + if (sourceKind === "conversation" && !validConversationTarget(target)) { + throw new HttpError(400, "bad_request", "飞书会话请填写联系人姓名或 oc_ 开头的会话 ID,不支持 Open ID。"); + } + + const start = validateDate(body.start, "开始时间"); + const end = validateDate(body.end, "结束时间"); + if (start && end && Date.parse(start) > Date.parse(end)) { + throw new HttpError(400, "bad_request", "开始时间不能晚于结束时间。"); + } + const pageLimit = Math.min(10, Math.max(1, Number(body.page_limit || 3) || 3)); + return { + companyId, + sourceKind, + target, + start, + end, + pageLimit, + }; + } + + pruneTasks() { + if (this.tasks.size < this.maxTasks) return; + const removable = [...this.tasks.values()] + .filter((task) => !ACTIVE_STATUSES.has(task.status)) + .sort((left, right) => String(left.created_at).localeCompare(String(right.created_at))); + while (this.tasks.size >= this.maxTasks && removable.length) { + this.tasks.delete(removable.shift().id); + } + } + + async start(companyId, body = {}) { + const request = this.normalizeRequest(companyId, body); + const active = [...this.tasks.values()].find((task) => ( + task.company_id === companyId && ACTIVE_STATUSES.has(task.status) + )); + if (active) { + throw new HttpError(409, "feishu_import_in_progress", "该企业已有飞书资料正在导入。", { + task_id: active.id, + }); + } + + this.pruneTasks(); + const task = { + id: makeId("feishu_import"), + company_id: companyId, + source_kind: request.sourceKind, + source_label: request.sourceKind === "document" ? "云文档" : "飞书会话", + status: "queued", + summary: "导入任务已创建。", + created_at: nowIso(), + started_at: null, + completed_at: null, + result: null, + error: null, + }; + this.tasks.set(task.id, task); + queueMicrotask(() => { + this.run(task, request).catch(() => { + // run() records a public-safe terminal error on the task. + }); + }); + return publicTask(task); + } + + async run(task, request) { + task.status = "running"; + task.summary = "正在从飞书读取并写入企业资料库。"; + task.started_at = nowIso(); + try { + const options = { + apiUrl: "", + companyId: request.companyId, + docs: request.sourceKind === "document" ? [request.target] : [], + p2pUser: request.sourceKind === "conversation" && !request.target.startsWith("oc_") + ? request.target + : "", + chatId: request.sourceKind === "conversation" && request.target.startsWith("oc_") + ? request.target + : "", + messageQuery: "", + start: request.start, + end: request.end, + pageSize: 50, + pageLimit: request.pageLimit, + titlePrefix: "", + maxAttempts: 3, + retryDelayMs: 800, + incremental: true, + resumeSource: false, + dryRun: false, + authSession: "", + syncStateLoader: async (source) => this.salesService.getMaterialSyncState( + request.companyId, + { + title: source.display_name || request.target, + source, + }, + ), + materialImporter: async (material) => this.salesService.importMaterial( + request.companyId, + material, + ), + }; + const result = await this.runner(options); + task.result = { + ok: Boolean(result.ok), + summary: { ...(result.summary || {}) }, + imports: (result.imports || []).map(publicImport), + }; + task.status = result.ok ? "succeeded" : "failed"; + task.summary = result.ok + ? "飞书资料已导入,可在历史资料中查看。" + : "部分或全部飞书资料导入失败。"; + if (!result.ok) { + const firstError = result.imports?.find((item) => item.error?.message)?.error?.message; + task.error = { message: compact(firstError || "飞书资料导入失败。", 300) }; + } + } catch (error) { + task.status = "failed"; + task.summary = "飞书资料导入失败。"; + task.error = { + message: compact( + error?.code === "ENOENT" + ? "本机未安装或无法找到飞书 CLI。" + : error?.message || "飞书资料导入失败。", + 300, + ), + }; + } finally { + task.completed_at = nowIso(); + } + return publicTask(task); + } + + get(companyId, taskId) { + this.salesService.requireCompany(companyId); + const task = this.tasks.get(taskId); + if (!task || task.company_id !== companyId) { + throw new HttpError(404, "feishu_import_not_found", "未找到该飞书导入任务。"); + } + return publicTask(task); + } +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/services/providerService.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/services/providerService.js new file mode 100644 index 00000000..7b3a0249 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/services/providerService.js @@ -0,0 +1,83 @@ +import { HttpError } from "../utils/http.js"; + +export class ProviderService { + constructor(options = {}) { + this.getProviderStatusSnapshot = options.getProviderStatus || (() => ({})); + this.webSearchProvider = options.webSearchProvider || null; + this.modelProvider = options.modelProvider || null; + this.dataProProvider = options.dataProProvider || null; + this.openVikingProvider = options.openVikingProvider || null; + this.supabaseDataProvider = options.supabaseDataProvider || null; + } + + getProviderStatus() { + return this.getProviderStatusSnapshot(); + } + + async probeWebSearch(body) { + if (!this.webSearchProvider) throw new HttpError(500, "provider_unavailable", "Web search provider is not available."); + const query = String(body.query || "").trim(); + if (!query) throw new HttpError(400, "bad_request", "query is required."); + const result = await this.webSearchProvider.search({ + query, + count: body.count, + search_type: body.search_type, + time_range: body.time_range, + auth_level: body.auth_level, + need_summary: body.need_summary, + }); + return this.requireSuccess("web_search", result, "Web search probe failed."); + } + + async probeDataPro(body = {}) { + if (!this.dataProProvider) throw new HttpError(500, "provider_unavailable", "DataPro provider is not available."); + const query = String(body.query || "").trim(); + if (!query) throw new HttpError(400, "bad_request", "query is required."); + const result = await this.dataProProvider.callTool(query); + return this.requireSuccess("datapro", result, "DataPro probe failed."); + } + + async probeModel() { + if (!this.modelProvider) throw new HttpError(500, "provider_unavailable", "Model provider is not available."); + const result = await this.modelProvider.callJson({ + operation: "connectivity_probe", + system: "只输出 JSON,返回 {\"ok\":true}。", + payload: { task: "验证 Agent Plan 模型结构化响应连接" }, + maxTokens: 80, + }); + return this.requireSuccess("model", result, "Model probe failed."); + } + + async probeOpenViking(body = {}) { + if (!this.openVikingProvider) throw new HttpError(500, "provider_unavailable", "OpenViking provider is not available."); + const query = String(body.query || "").trim(); + const result = query + ? await this.openVikingProvider.findMemories(query, { limit: body.limit }) + : await this.openVikingProvider.health(); + return this.requireSuccess("openviking", result, "OpenViking probe failed."); + } + + async probeSupabase() { + if (!this.supabaseDataProvider) { + throw new HttpError(500, "provider_unavailable", "Supabase Data API provider is not available."); + } + let result; + try { + result = await this.supabaseDataProvider.probe(); + } catch (error) { + throw new HttpError(502, error.code || "provider_error", error.message || "Supabase probe failed.", { + provider: "supabase", + }); + } + return this.requireSuccess("supabase", result, "Supabase probe failed."); + } + + requireSuccess(provider, result, fallbackMessage) { + if (result?.ok) return result; + const status = result?.error?.code === "missing_config" ? 503 : 502; + throw new HttpError(status, result?.error?.code || "provider_error", result?.error?.message || fallbackMessage, { + provider, + request_id: result?.request_id || null, + }); + } +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/services/salesService.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/services/salesService.js new file mode 100644 index 00000000..16385458 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/services/salesService.js @@ -0,0 +1,6375 @@ +import { createHash } from "node:crypto"; +import { createEnvReader } from "../config/runtimeEnv.js"; +import { createRuntimePolicy } from "../config/runtimePolicy.js"; +import { ProviderRunStore } from "../observability/providerRunStore.js"; +import { PaidWorkflowGuard } from "../limits/paidWorkflowGuard.js"; +import { ProviderCircuitBreaker } from "../limits/providerCircuitBreaker.js"; +import { + buildDossierAgentContext, + DossierAgent, + dossierSourceUsageErrors, +} from "../agents/dossierAgent.js"; +import { + deriveEvidenceDataAsOf, + extractGroundingDates, + extractGroundingNumbers, + groundedTextErrors, +} from "../evidence/claimGrounding.js"; +import { compileDossierEvidenceAtoms } from "../evidence/dossierEvidenceCompiler.js"; +import { + analyzeQaQuestion, + assessQaAnswerability, + buildDossierEvidencePack, + buildQaEnumerationRequirements, + buildQaEvidence, + evidencePackCitations, + fuseQaRetrievalContexts, + makeDossierFingerprint, + resolveCompanyEntity, + validateDossierModelAnswer, + validateProductionEvidencePack, + validateQaModelAnswer, +} from "../evidence/salesEvidence.js"; +import { + buildMaterialSyncIdentity, + decodeMaterialSnapshot, + encodeMaterialSnapshot, + makeMaterialContentHash, + mergeSourceItems, + normalizeSourceItems, + renderSourceItems, +} from "../sync/materialSync.js"; +import { HttpError } from "../utils/http.js"; +import { makeId } from "../utils/ids.js"; +import { nowIso } from "../utils/time.js"; + +const clone = (value) => JSON.parse(JSON.stringify(value)); +const enabled = (value) => ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase()); +const QA_SESSION_MESSAGE_PATTERN = /(?:\n|^)\s*$/; +const ASYNC_JOB_TYPES = new Set([ + "sales_dossier_generation", + "sales_material_openviking_sync", +]); +const DOSSIER_SECTION_TITLES = Object.freeze([ + "企业与业务概览", + "经营与业务动态", + "近期公开动态", + "风险与关注事项", + "销售机会判断", + "建议行动", +]); +const DOSSIER_INTERNAL_META_PATTERNS = Object.freeze([ + /关键(?:字段|数字).{0,20}(?:来源(?:存在)?差异|来源冲突|口径冲突)/, + /(?:来源|口径)[^。;\n]{0,80}(?:不一致|冲突|存在差异|等级不足|一致性问题)/, + /冲突字段|evidence_conflicts|source_selection_policy/i, + /本次(?:未|没有)(?:检索|获取|返回|发现|查询到)/, + /(?:专业数据集|豆包搜索|联网搜索|(?:企业)?数据库).{0,20}(?:调用成功|没有返回|未返回|可用于核验|完成核验但)/, + /缺少(?:两个|独立).{0,10}来源/, + /(?:资料|信息|证据)(?:仍然|依然|尚)?(?:不足|缺口|不充分|未覆盖)/, + /(?:不作为|不写为|不将[^。;\n]{0,20}写为)(?:确定|已确认)?事实/, + /(?:需|仍需|建议)(?:进一步|持续|交叉)?核验(?:来源|口径|日期|主体|数字)/, + /(?:已|可)核验的(?:风险|信息|数据|来源|经营|变化|事项)/, +]); +const DOSSIER_EVIDENCE_DEBRIS_PATTERNS = Object.freeze([ + /查看详情|查看更多(?:相关)?|立即注册|免费查看|点击查看|登录后查看|打开\s*(?:APP|客户端)/i, + /<\/?(?:table|thead|tbody|tr|th|td)\b/i, + /(?:^|[\s::])Untitled(?:[\s。;]|$)/i, + /来源返回可引用信息/, + /\b20\d{2}年\d{1,2}月\d{1,2}日\s+\d{1,2}:\d{2}\b/u, + /(?:市场|行业|公司|商业)?资讯\s*[((]来源[::]/u, + /[((]来源[::][^))]{1,80}[))]/u, +]); +const DOSSIER_LOW_VALUE_PUBLIC_SOURCE_PATTERNS = Object.freeze([ + /for better experience.{0,80}(?:verification|verify)/iu, + /(?:complete|pass).{0,40}(?:the )?verification process/iu, + /(?:verify you are human|captcha|access denied|robot check|security check)/iu, + /(?:请|需要).{0,16}(?:完成|通过).{0,12}(?:人机|安全|访问|滑动)?验证/iu, + /(?:人机验证|安全验证|访问验证|滑动验证|验证码页面|页面不存在|内容已下线)/iu, + /(?:网站|官网|网页|站群)(?:建设|设计|制作|改版|升级)(?:案例|服务|项目|方案)?/iu, + /(?:建站|SEO|数字营销|品牌网站).{0,24}(?:案例|服务商|公司|解决方案)/iu, + /(?:客户案例|成功案例).{0,24}(?:网站|官网|网页|建站)/iu, + /(?:我们|小伙伴们|项目团队).{0,32}(?:网站|官网).{0,32}(?:上线|交付|建设)/iu, + /(?:全新|新版|品牌)?官网(?:全面)?(?:焕新|上线).{0,36}(?:网站建设|建站|网页设计)/iu, + /(?:杀人诛心|让对方下不来台|狠狠打脸|瞬间打脸|当场傻眼|彻底慌了|坐不住了|真相曝光|惊天内幕)/iu, +]); +const DOSSIER_ACTION_TERMS = /发布|公告|披露|签署|合作|中标|招标|采购|投产|量产|扩产|建设|回购|融资|研发|推出|上线|召回|处罚|诉讼|失信|经营异常|监管|交付|供应链|营收|利润|销量|市占率/; +const DOSSIER_RISK_TERMS = /企业风险数据库|风险事项|行政处罚|司法诉讼|失信被执行|限制高消费|经营异常|监管处罚|产品召回|安全事故|供应中断|交付延期|合规整改/; +const DOSSIER_SPECIFIC_RISK_TERMS = /行政处罚|司法诉讼|失信被执行|限制高消费|经营异常|监管处罚|产品召回|安全事故|供应中断|交付延期|交付周期延长|被罚|索赔|赔偿/; +const DOSSIER_COMPANY_WIDE_INFERENCE = /(?:说明|表明|显示|可见|由此可见)[^。!?\n]{0,48}(?:订单结构|客户结构|业务结构|收入结构|采购结构|项目结构)[^。!?\n]{0,36}(?:为主|集中|分散|偏[大小高低]|单一|多元|稳定|不稳定|依赖)/u; +const DOSSIER_BUSINESS_TRAJECTORY_INFERENCE = /(?:业务|能力|产品|市场)[^。!?\n]{0,16}(?:(?:已|正)?(?:从|由)[^。!?\n]{1,36}(?:扩展|转向|升级|延伸)(?:到|至|为)|(?:布局)?(?:延伸|扩展)(?:到|至))/u; +const DOSSIER_RECENT_DEMAND_INFERENCE = /(?:采购|配套|交付|项目|资源)[^。!?\n]{0,12}(?:需求|意向)[^。!?\n]{0,20}(?:活跃|明确|形成|增加|释放|旺盛|存在)/u; +const DOSSIER_SENTENCE_PREDICATE_TERMS = /(?:为|是|成立|设立|注册|位于|经营|主营|从事|提供|覆盖|包含|涉及|专注|聚焦|布局|拥有|具备|采用|应用|承担|承接|生产|制造|销售|投资|收购|发布|披露|签署|合作|中标|招标|采购|建设|上线|推出|新增|更新|升级|交付|部署|扩展|扩大|进入|成为|列为|入选|获评|增长|提升|保持|减少|下降|实现|达到|存在|需要|需|应当|应|可以|可|建议|确认|核实|核验|准备|跟进|联系|验证|判断|表明|显示|反映|计划|推进|开展|完成|获得|发生|面临|影响|有助于|属于|形成|支持|服务于|负责|拟)/u; +const DOSSIER_TITLE_FRAGMENT_PATTERNS = Object.freeze([ + /(?:有限责任公司|股份有限公司|集团|公司)\s*[-—|]\s*(?:最新|近期)?.{0,24}(?:结果|公告|新闻|动态|发布)$/u, + /(?:最新|近期).{0,24}(?:中标|招标|采购|合作|签约|融资|处罚|诉讼)(?:结果)?(?:发布|公告)$/u, + /(?:中标|招标|采购|合作|签约|融资|处罚|诉讼)(?:结果|公告|新闻|动态)$/u, +]); +const DOSSIER_GENERIC_TEMPLATE_PATTERNS = Object.freeze([ + /上述业务动作指向.{0,40}(?:经营与技术方向|相关方向)/u, + /当前信息更适合作为.{0,30}背景材料/u, + /可优先验证.{0,40}相关的采购、技术协同或项目交付场景/u, + /企业近期发布产品升级公告并需要继续关注/u, + /可进一步核验重点产品线/u, + /需持续关注相关风险/u, +]); + +function safeValidationErrors(value, limit = 16) { + return firstJsonArray(value) + .map((item) => String(item || "") + .replace(/Bearer\s+[^\s,;]+/gi, "Bearer [REDACTED]") + .replace(/ark-[0-9a-f-]{24,}/gi, "[REDACTED]") + .replace(/\s+/g, " ") + .trim() + .slice(0, 500)) + .filter(Boolean) + .slice(0, limit); +} + +function hasDossierInternalMetaText(value) { + const text = String(value || ""); + return DOSSIER_INTERNAL_META_PATTERNS.some((pattern) => pattern.test(text)); +} + +function hasUnbalancedDossierPunctuation(value) { + const text = String(value || ""); + return [ + ["(", ")"], + ["(", ")"], + ["[", "]"], + ["【", "】"], + ["“", "”"], + ].some(([left, right]) => ( + text.split(left).length - 1 !== text.split(right).length - 1 + )); +} + +function hasTruncatedDossierNumber(value) { + return /(?:营业收入|营收|净利润|利润|金额|产能|市占率)[^。;\n]{0,24}\d+(?:\.\d+)?(?=\s*(?:[。;]|$))/.test( + String(value || ""), + ); +} + +function hasDossierEvidenceDebris(value) { + const text = String(value || ""); + return DOSSIER_EVIDENCE_DEBRIS_PATTERNS.some((pattern) => pattern.test(text)); +} + +function isQuestionLikeDossierText(value) { + const text = stripDossierSectionTitle(value) + .replace(/[。!?!?]+$/gu, "") + .trim(); + if (!text || text.length > 120) return false; + if (/[??]\s*$/u.test(stripDossierSectionTitle(value))) return true; + const interrogative = text.match(/是否|有无|有没有|能否|可否|如何|为什么|为何|怎样|怎么/u); + if (!interrogative) return false; + const prefix = text.slice(0, interrogative.index); + return !/(?:需要|需|应当|应|建议|确认|核实|核验|评估|判断|了解|询问|联系|验证|调查)/u.test(prefix); +} + +function dossierPointQualityErrors(value) { + const errors = []; + if (hasDossierEvidenceDebris(value)) errors.push("包含搜索站点模板或引流文字"); + if (isQuestionLikeDossierText(value)) errors.push("把检索问题或问句当作企业事实"); + if (hasUnbalancedDossierPunctuation(value)) errors.push("存在未闭合的括号、引号或方括号"); + if (hasTruncatedDossierNumber(value)) errors.push("存在缺少单位或上下文的截断数字"); + return errors; +} + +function isSubstantiveDossierSummary(value) { + const text = compactText(value, 360); + return text.length >= 40 + && !hasBadDisplayText(text) + && !hasDossierInternalMetaText(text) + && dossierPointQualityErrors(text).length === 0; +} + +function stripDossierSectionTitle(value) { + return String(value || "") + .replace(new RegExp(`^(?:${DOSSIER_SECTION_TITLES.join("|")})[::]\\s*`), "") + .trim(); +} + +function normalizeChineseDossierPunctuation(value) { + return String(value || "") + .replace(/([\p{Script=Han}”’)】])\s*:\s*/gu, "$1:") + .replace(/(? item.replace(/^\d{1,2}[.、]\s*/u, "").trim()) + .filter((item) => item.length >= 12) + .map((item) => item + .toLowerCase() + .replace(/\[[0-9]+\]/gu, "") + .replace(/[\s,。;:!?、,.!?;:'"“”‘’()()【】[\]《》<>-]/gu, "")); +} + +function dossierSentenceUnits(value) { + return stripDossierSectionTitle(value) + .split(/(?:\n+|[。!?]\s*)/u) + .map((item) => item.replace(/^\d{1,2}[.、]\s*/u, "").trim()) + .filter(Boolean); +} + +function dossierSentenceQualityErrors(value) { + const errors = []; + for (const sentence of dossierSentenceUnits(value)) { + if (DOSSIER_TITLE_FRAGMENT_PATTERNS.some((pattern) => pattern.test(sentence))) { + errors.push("包含被当作正文的搜索标题或事件标题残片"); + continue; + } + if (!DOSSIER_SENTENCE_PREDICATE_TERMS.test(sentence)) { + errors.push("包含缺少明确陈述或行动谓语的名词片段"); + } + } + if (DOSSIER_GENERIC_TEMPLATE_PATTERNS.some((pattern) => pattern.test(String(value || "")))) { + errors.push("包含不能直接形成销售结论的通用模板话术"); + } + return [...new Set(errors)]; +} + +function dossierBigramSimilarity(left, right) { + if (!left || !right) return 0; + if (left === right) return 1; + const shorter = left.length <= right.length ? left : right; + const longer = left.length > right.length ? left : right; + if (shorter.length >= 28 && longer.includes(shorter)) { + return shorter.length / longer.length; + } + const bigrams = (value) => { + const result = new Set(); + for (let index = 0; index < value.length - 1; index += 1) { + result.add(value.slice(index, index + 2)); + } + return result; + }; + const leftBigrams = bigrams(left); + const rightBigrams = bigrams(right); + if (!leftBigrams.size || !rightBigrams.size) return 0; + let overlap = 0; + for (const item of leftBigrams) { + if (rightBigrams.has(item)) overlap += 1; + } + return (2 * overlap) / (leftBigrams.size + rightBigrams.size); +} + +function dossierSectionContentErrors(body) { + const errors = []; + DOSSIER_SECTION_TITLES.forEach((title, index) => { + const text = String(body[index]?.text || ""); + const content = stripDossierSectionTitle(text); + if (!content) { + errors.push(`${title}缺少正文`); + } + if (content.length > 1200) { + errors.push(`${title}超过 1200 个字符的异常输出保护上限`); + } + const incompleteLines = content + .split(/\n+/u) + .map((item) => item.trim()) + .filter(Boolean) + .filter((item) => !/[。!?]$/u.test(item)); + if (incompleteLines.length) { + errors.push(`${title}存在未使用完整句末标点的段落或分点`); + } + if (hasDossierInternalMetaText(content)) { + errors.push(`${title}包含仅供系统内部使用的检索或证据诊断话术`); + } + dossierPointQualityErrors(content).forEach((error) => { + errors.push(`${title}${error}`); + }); + dossierSentenceQualityErrors(content).forEach((error) => { + errors.push(`${title}${error}`); + }); + }); + const seenFacts = []; + DOSSIER_SECTION_TITLES.forEach((title, index) => { + for (const fact of dossierFactUnits(body[index]?.text)) { + const duplicate = seenFacts.find((item) => dossierBigramSimilarity(item.fact, fact) >= 0.82); + if (duplicate) { + errors.push(`${title}与${duplicate.title}存在重复或高度相似的事实表述`); + continue; + } + seenFacts.push({ title, fact }); + } + }); + return errors; +} + +function dossierSourceIds(citations, predicate) { + return new Set( + citations + .filter(predicate) + .map((item) => String(item.id)), + ); +} + +function isUsableProfessionalDossierCitation(item) { + const point = safeDeterministicDossierPoint(conciseProfessionalPoint(item)); + return Boolean( + point + && !isLowValueProfessionalPoint(point) + && isSubstantiveDossierEvidencePoint(point) + ); +} + +function isUsablePublicDossierCitation(item) { + const point = safeDeterministicDossierPoint(concisePublicPoint(item)); + return Boolean( + point + && !isLowValuePublicDossierSource(item) + && isSubstantiveDossierEvidencePoint(point) + ); +} + +function dossierSectionSourcePolicy(citations, company = null) { + const professional = dossierSourceIds( + citations, + (item) => item.source_kind === "专业数据集" && isUsableProfessionalDossierCitation(item), + ); + const web = dossierSourceIds( + citations, + (item) => ( + item.source_kind === "联网搜索" + && isUsablePublicDossierCitation(item) + && ( + !company + || isRecentPublicDossierCitation(item, concisePublicPoint(item), company) + ) + ), + ); + const business = dossierSourceIds( + citations, + (item) => { + if ( + item.source_kind !== "专业数据集" + || !/企业工商数据库/.test(String(item.label || "")) + || !isUsableProfessionalDossierCitation(item) + ) return false; + if (!company) return true; + const record = dossierBusinessEntityRecord(item); + const targetName = String(company?.name || company?.legal_name || "").trim(); + return Boolean( + record + && targetName + && normalizeLegalEntityName(record.name) === normalizeLegalEntityName(targetName) + ); + }, + ); + const risk = dossierSourceIds( + citations, + (item) => ( + item.source_kind === "专业数据集" + && /企业风险数据库/.test(String(item.label || "")) + && !dossierBusinessEntityRecord(item) + && isUsableProfessionalDossierCitation(item) + ), + ); + const market = dossierSourceIds( + citations, + (item) => ( + item.source_kind === "专业数据集" + && /金融数据库|汽车销量数据库|科研学术数据搜索服务/.test(String(item.label || "")) + && !dossierBusinessEntityRecord(item) + && isUsableProfessionalDossierCitation(item) + ), + ); + const businessDynamics = market.size + ? new Set(market) + : web.size >= 2 + ? new Set(web) + : new Set(); + return { professional, web, business, risk, market, businessDynamics }; +} + +function dossierSectionSourceErrors(body, citations, company = null) { + const policy = dossierSectionSourcePolicy(citations, company); + const errors = []; + const usesAny = (index, ids) => ( + ids.size > 0 && (body[index]?.citation_ids || []).some((id) => ids.has(String(id))) + ); + const requireWhenAvailable = (index, ids, message) => { + if (ids.size && !usesAny(index, ids)) errors.push(message); + }; + + requireWhenAvailable(0, policy.business, "企业与业务概览必须优先引用企业工商数据库"); + requireWhenAvailable(1, policy.market, "经营与业务动态必须优先引用语义匹配的专业数据库"); + requireWhenAvailable(2, policy.web, "近期公开动态必须引用豆包搜索的可追溯公开来源"); + if (policy.risk.size) { + requireWhenAvailable(3, policy.risk, "风险与关注事项必须优先引用企业风险数据库"); + } + return errors; +} + +function normalizeLegalEntityName(value) { + return String(value || "") + .normalize("NFKC") + .toLowerCase() + .replace(/[\s·•()()\[\]【】_-]+/gu, ""); +} + +function escapeRegularExpression(value) { + return String(value || "").replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function dossierBusinessEntityRecord(citation) { + if (citation?.source_kind !== "专业数据集") return null; + const summary = String(citation?.summary || ""); + const name = summary.match(/(?:^|[;;])\s*公司名称\s*[::]\s*([^;;]+)/u)?.[1]?.trim() || ""; + const registryFieldCount = [ + /(?:^|[;;])\s*统一社会信用代码\s*[::]/u, + /(?:^|[;;])\s*注册号\s*[::]/u, + /(?:^|[;;])\s*(?:公司组织类型|企业类型)\s*[::]/u, + /(?:^|[;;])\s*(?:注册地址|住所)\s*[::]/u, + /(?:^|[;;])\s*成立日期\s*[::]/u, + /(?:^|[;;])\s*(?:经营范围|法人姓名|法定代表人)\s*[::]/u, + ].filter((pattern) => pattern.test(summary)).length; + // DataPro can return a registry row from a query labelled as finance, + // research, sales or risk data. Entity isolation must therefore be based on + // the structured fields in the payload instead of trusting the query label. + if (!name || registryFieldCount < 1) return null; + return name ? { id: String(citation.id || ""), name, summary } : null; +} + +function normalizeDossierCitationSemantics(citations) { + const registryKeeperByFingerprint = new Map(); + const registryFingerprint = (citation, record) => `${normalizeLegalEntityName(record.name)}:${String( + citation.summary || "", + ) + .normalize("NFKC") + .replace(/\s+/gu, "") + .replace(/[;;]/gu, ";") + .replace(/[::]/gu, ":")}`; + + firstJsonArray(citations).forEach((citation) => { + const record = dossierBusinessEntityRecord(citation); + if (!record) return; + const fingerprint = registryFingerprint(citation, record); + const current = registryKeeperByFingerprint.get(fingerprint); + if ( + !current + || ( + /企业工商数据库/u.test(String(citation.label || "")) + && !/企业工商数据库/u.test(String(current.label || "")) + ) + ) { + registryKeeperByFingerprint.set(fingerprint, citation); + } + }); + + return firstJsonArray(citations).flatMap((citation) => { + const record = dossierBusinessEntityRecord(citation); + if (!record) return [citation]; + const fingerprint = registryFingerprint(citation, record); + if (registryKeeperByFingerprint.get(fingerprint) !== citation) return []; + if (/企业工商数据库/u.test(String(citation.label || ""))) return [citation]; + const recordSuffix = String(citation.label || "").match(/\s*·\s*记录\s*\d+/u)?.[0] || ""; + return [{ + ...citation, + label: `企业工商数据库${recordSuffix || " · 自动识别记录"}`, + }]; + }); +} + +function isExplicitTargetBranchRecord(record, targetName) { + const recordKey = normalizeLegalEntityName(record?.name || ""); + const targetKey = normalizeLegalEntityName(targetName || ""); + return Boolean( + recordKey + && targetKey + && recordKey !== targetKey + && recordKey.startsWith(targetKey) + && /分公司$/u.test(String(record?.name || "").trim()) + ); +} + +function businessEntityAnchorErrors(body, citations, company) { + const targetName = String(company?.name || company?.legal_name || "").trim(); + const targetKey = normalizeLegalEntityName(targetName); + if (!targetKey) return []; + const records = citations.map(dossierBusinessEntityRecord).filter(Boolean); + const selectedIds = new Set(firstJsonArray(body[0]?.citation_ids).map(String)); + const selectedRecords = records.filter((record) => selectedIds.has(record.id)); + const targetRecords = records.filter((record) => normalizeLegalEntityName(record.name) === targetKey); + const selectedTargetRecords = selectedRecords.filter((record) => normalizeLegalEntityName(record.name) === targetKey); + if (!targetRecords.length) return []; + const errors = []; + if (!selectedTargetRecords.length) { + errors.push(`企业与业务概览必须引用公司名称完全等于“${targetName}”的工商记录`); + return errors; + } + const branchPattern = new RegExp( + `${escapeRegularExpression(targetName)}[\\p{Script=Han}A-Za-z0-9()()·]{1,24}(?:分公司|子公司)`, + "gu", + ); + for (const match of String(body[0]?.text || "").matchAll(branchPattern)) { + const referencedName = match[0]; + if (!selectedRecords.some((record) => ( + normalizeLegalEntityName(record.name) === normalizeLegalEntityName(referencedName) + ))) { + errors.push(`企业与业务概览提到“${referencedName}”,但本章没有引用该分支机构自己的工商记录`); + } + } + const sentences = dossierSentenceUnits(body[0]?.text || ""); + const sameAnchor = (anchor, recordAnchors) => recordAnchors.some((candidate) => ( + String(candidate).replace(/[,,]/gu, "") === String(anchor).replace(/[,,]/gu, "") + )); + sentences.forEach((sentence, sentenceIndex) => { + const explicitOtherRecords = selectedRecords.filter((record) => ( + normalizeLegalEntityName(record.name) !== targetKey + && sentence.includes(record.name) + )); + const allowedRecords = explicitOtherRecords.length ? explicitOtherRecords : selectedTargetRecords; + const allowedSummaries = allowedRecords.map((record) => record.summary); + for (const date of extractGroundingDates(sentence)) { + if (!allowedSummaries.some((summary) => extractGroundingDates(summary).includes(date))) { + errors.push(`企业与业务概览第 ${sentenceIndex + 1} 条把日期 ${date} 归属给“${explicitOtherRecords[0]?.name || targetName}”,但对应工商记录不支持该归属`); + } + } + for (const number of extractGroundingNumbers(sentence)) { + if (!allowedSummaries.some((summary) => sameAnchor(number, extractGroundingNumbers(summary)))) { + errors.push(`企业与业务概览第 ${sentenceIndex + 1} 条把数值 ${number} 归属给“${explicitOtherRecords[0]?.name || targetName}”,但对应工商记录不支持该归属`); + } + } + const identifiers = sentence.match(/\b[0-9A-Z]{12,24}\b/gu) || []; + for (const identifier of identifiers) { + if (!allowedSummaries.some((summary) => summary.includes(identifier))) { + errors.push(`企业与业务概览第 ${sentenceIndex + 1} 条把登记标识 ${identifier} 归属给“${explicitOtherRecords[0]?.name || targetName}”,但对应工商记录不支持该归属`); + } + } + }); + return [...new Set(errors)]; +} + +function unrelatedBusinessEntityCitationErrors(body, citations, company) { + const targetName = String(company?.name || company?.legal_name || "").trim(); + const targetKey = normalizeLegalEntityName(targetName); + if (!targetKey) return []; + const recordById = new Map( + citations + .map(dossierBusinessEntityRecord) + .filter(Boolean) + .map((record) => [record.id, record]), + ); + const errors = []; + firstJsonArray(body).slice(1).forEach((paragraph, offset) => { + const sectionIndex = offset + 1; + const paragraphText = String(paragraph?.text || ""); + const unrelated = firstJsonArray(paragraph?.citation_ids) + .map((id) => recordById.get(String(id))) + .filter((record) => { + if (!record || normalizeLegalEntityName(record.name) === targetKey) return false; + return !( + isExplicitTargetBranchRecord(record, targetName) + && paragraphText.includes(record.name) + ); + }); + for (const record of unrelated) { + errors.push( + `${DOSSIER_SECTION_TITLES[sectionIndex]}不得把未明确点名或未经关系核验的其他主体工商记录归属到目标企业:${record.name}`, + ); + } + }); + return [...new Set(errors)]; +} + +function staticRegistryInferenceErrors(body, citations) { + const citationById = new Map(citations.map((item) => [String(item.id), item])); + const registryOnly = (sectionIndex) => { + const selected = firstJsonArray(body[sectionIndex]?.citation_ids) + .map((id) => citationById.get(String(id))) + .filter(Boolean); + return Boolean( + selected.length + && selected.every((citation) => dossierBusinessEntityRecord(citation)), + ); + }; + const errors = []; + const dynamicsText = String(body[1]?.text || ""); + if ( + registryOnly(1) + && ( + /业务动作(?:主要)?聚焦/u.test(dynamicsText) + || /构成[^。!?]{0,40}(?:独立产品线|业务增长|业务变化)/u.test(dynamicsText) + || /具备直接开展[^。!?]{0,40}(?:经营条件|业务条件)/u.test(dynamicsText) + ) + ) { + errors.push("经营与业务动态不能把静态工商登记范围提升为当前业务动作、独立产品线或现实经营能力"); + } + const overviewText = String(body[0]?.text || ""); + if ( + registryOnly(0) + && /(?:同时承担|形成[^。!?]{0,30}业务定位|制造基地[^。!?]{0,20}法定主体|实际从事|主营)/u.test(overviewText) + ) { + errors.push("企业与业务概览只能把工商信息表述为登记范围,不能提升为实际主营、制造主体或现实业务定位"); + } + const opportunityText = String(body[4]?.text || ""); + if ( + registryOnly(4) + && /(?:同时承担|已具备|具备直接|已形成|现实业务能力)/u.test(opportunityText) + ) { + errors.push("销售机会判断可以把登记范围作为对接方向,但不能写成企业已承担该业务或已具备现实能力"); + } + return errors; +} + +function dossierSectionSemanticErrors(body, citations, company) { + const errors = [ + ...businessEntityAnchorErrors(body, citations, company), + ...unrelatedBusinessEntityCitationErrors(body, citations, company), + ...staticRegistryInferenceErrors(body, citations), + ]; + const citationById = new Map(citations.map((item) => [String(item.id), item])); + const sectionEvidenceText = (index) => firstJsonArray(body[index]?.citation_ids) + .map((id) => citationById.get(String(id))) + .filter(Boolean) + .map((item) => `${item.label || ""} ${item.summary || ""}`) + .join(" "); + body.slice(0, 4).forEach((paragraph, index) => { + const paragraphCitations = firstJsonArray(paragraph?.citation_ids) + .map((id) => citationById.get(String(id))) + .filter(Boolean); + if ( + paragraphCitations.some((item) => item.entity_match === "alias_scoped") + && !paragraphCitations.some((item) => item.entity_match === "verified") + && !/(?:品牌|集团|相关业务|在华业务|中国业务|公开信息显示)/u.test(String(paragraph?.text || "")) + ) { + errors.push(`${DOSSIER_SECTION_TITLES[index]}使用品牌或简称来源时必须明确主体边界`); + } + }); + const recentCitations = firstJsonArray(body[2]?.citation_ids) + .map((id) => citationById.get(String(id))) + .filter((item) => item?.source_kind === "联网搜索"); + if ( + recentCitations.length + && recentCitations.some((item) => ( + !isRecentPublicDossierCitation(item, concisePublicPoint(item), company) + )) + ) { + errors.push("近期公开动态引用了不具备明确业务事件的网页或低价值营销页面"); + } + [0, 1].forEach((sectionIndex) => { + const trajectoryText = stripDossierSectionTitle(body[sectionIndex]?.text || ""); + if ( + DOSSIER_BUSINESS_TRAJECTORY_INFERENCE.test(trajectoryText) + && !DOSSIER_BUSINESS_TRAJECTORY_INFERENCE.test(sectionEvidenceText(sectionIndex)) + ) { + errors.push("企业概览或经营动态不能把静态经营范围或少量项目外推为业务转型或能力扩展"); + } + }); + const recentText = stripDossierSectionTitle(body[2]?.text || ""); + if ( + DOSSIER_RECENT_DEMAND_INFERENCE.test(recentText) + && !/(?:采购|配套|交付|项目|资源)[^。!?\n]{0,12}(?:需求|意向)/u.test(sectionEvidenceText(2)) + ) { + errors.push("近期公开动态不能把中标或公告节奏写成来源未披露的采购需求或采购意向"); + } + const riskText = stripDossierSectionTitle(body[3]?.text || ""); + if (DOSSIER_COMPANY_WIDE_INFERENCE.test(riskText)) { + errors.push("风险与关注事项不能把个别项目或单条公开信息外推为企业整体结构性结论"); + } + if (!DOSSIER_SPECIFIC_RISK_TERMS.test(riskText)) return errors; + const riskCitations = firstJsonArray(body[3]?.citation_ids) + .map((id) => citationById.get(String(id))) + .filter(Boolean); + const hasProfessionalRisk = riskCitations.some((item) => ( + item.source_kind === "专业数据集" + && /企业风险数据库/.test(String(item.label || "")) + && isUsableProfessionalDossierCitation(item) + )); + const hasTargetSpecificPublicRisk = riskCitations.some((item) => ( + item.source_kind === "联网搜索" + && isPublicRiskEvidenceForCompany(item, concisePublicPoint(item), company) + )); + if (!hasProfessionalRisk && !hasTargetSpecificPublicRisk) { + errors.push("风险与关注事项包含未明确归属于目标企业的风险事实"); + } + return errors; +} + +function dossierSectionEvidenceGroundingErrors(body, citations) { + const citationById = new Map(citations.map((item) => [String(item?.id || ""), item])); + const errors = []; + firstJsonArray(body).forEach((paragraph, sectionIndex) => { + const title = DOSSIER_SECTION_TITLES[sectionIndex] || `第 ${sectionIndex + 1} 章`; + const segments = firstJsonArray(paragraph?.segments).length + ? firstJsonArray(paragraph.segments) + : [{ + text: stripDossierSectionTitle(paragraph?.text || ""), + citation_ids: firstJsonArray(paragraph?.citation_ids), + }]; + segments.forEach((segment, segmentIndex) => { + const evidenceTexts = firstJsonArray(segment?.citation_ids) + .map((id) => citationById.get(String(id))) + .filter(Boolean) + .flatMap((citation) => [citation.summary, citation.excerpt].filter(Boolean)); + errors.push(...groundedTextErrors({ + text: segment?.text || "", + evidenceTexts, + path: `${title}第 ${segmentIndex + 1} 段`, + requireEventFamily: false, + checkOrganizations: false, + })); + }); + }); + return [...new Set(errors)]; +} + +const JOB_STAGE_LABELS = Object.freeze({ + queued: "等待执行", + retry_wait: "正在等待自动重试", + starting: "正在准备", + collecting_evidence: "正在收集可信资料", + collecting_professional: "正在核验专业资料", + collecting_public: "正在检索公开资料", + building_evidence: "正在整理可信资料", + retrieving_memory: "正在检索历史资料", + validating_evidence: "正在校验资料", + generating_dossier: "正在生成档案", + validating_dossier: "正在核验档案", + storing_memory: "正在保存长期资料", + persisting_result: "正在保存结果", + syncing_materials: "正在同步历史资料", + cancelling: "正在取消", + succeeded: "已完成", + failed: "执行失败", + cancelled: "已取消", +}); + +function objectValue(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +function safeJobProgressDetail(value) { + const detail = objectValue(value); + const current = Number(detail.current); + const total = Number(detail.total); + const nextRetryAt = String(detail.next_retry_at || ""); + return { + ...(detail.message ? { message: String(detail.message).replace(/\s+/g, " ").trim().slice(0, 100) } : {}), + ...(Number.isInteger(current) && current >= 0 ? { current } : {}), + ...(Number.isInteger(total) && total > 0 ? { total } : {}), + ...(nextRetryAt && Number.isFinite(new Date(nextRetryAt).getTime()) + ? { next_retry_at: new Date(nextRetryAt).toISOString() } + : {}), + }; +} + +async function mapWithConcurrency(items, limit, operation) { + const values = [...items]; + const results = new Array(values.length); + let cursor = 0; + const workers = Array.from( + { length: Math.min(Math.max(1, Number(limit) || 1), Math.max(1, values.length)) }, + async () => { + while (cursor < values.length) { + const index = cursor; + cursor += 1; + results[index] = await operation(values[index], index); + } + }, + ); + await Promise.all(workers); + return results; +} + +function workflowQueryKey(provider, query) { + return `${provider}:${createHash("sha256").update(String(query || "")).digest("hex").slice(0, 24)}`; +} + +function reusableDossierCheckpoint(checkpoint, companyId, ttlMs) { + const value = objectValue(checkpoint); + if ( + Number(value.schema_version) !== 1 + || String(value.company_id || "") !== String(companyId || "") + ) return null; + const savedAt = new Date(value.updated_at || value.collected_at || "").getTime(); + if (!Number.isFinite(savedAt) || Date.now() - savedAt > ttlMs) return null; + return value; +} + +function emptySalesData() { + return { + goals: [], + companies: {}, + dossiers: {}, + materials: {}, + qa_messages: {}, + sync_sources: {}, + sync_checkpoints: {}, + jobs: {}, + }; +} + +function compactText(value, maxLength = 900) { + return normalizeImportedText(value).replace(/\s+/g, " ").trim().slice(0, maxLength); +} + +function compactCompleteSentences(value, maxLength = 300) { + const text = normalizeImportedText(value).replace(/\s+/g, " ").trim(); + if (!text || text.length <= maxLength) return text; + const sentences = text.match(/[^。!?!?]+[。!?!?]/gu) || []; + let result = ""; + for (const sentence of sentences) { + const normalized = sentence.trim(); + const candidate = result ? `${result} ${normalized}` : normalized; + if (candidate.length > maxLength) break; + result = candidate; + } + if (result) return result; + const bounded = text.slice(0, maxLength); + const boundary = Math.max( + bounded.lastIndexOf(";"), + bounded.lastIndexOf(";"), + bounded.lastIndexOf(","), + bounded.lastIndexOf(","), + ); + const completeClause = boundary >= 40 ? bounded.slice(0, boundary) : bounded; + return ensureDossierLinePunctuation(completeClause); +} + +function qaConversationHistory(messages, { maxMessages = 10, maxCharacters = 6000 } = {}) { + const history = []; + let remaining = maxCharacters; + for (const message of firstJsonArray(messages).slice(-maxMessages).reverse()) { + if (!message || !["user", "assistant"].includes(message.role) || remaining <= 0) continue; + const text = compactText(message.text || "", Math.min(1200, remaining)); + if (!text) continue; + history.push({ role: message.role, text }); + remaining -= text.length; + } + return history.reverse(); +} + +function encodeQaSessionMessage(message) { + const snapshot = { + id: String(message?.id || ""), + role: ["assistant", "user"].includes(message?.role) ? message.role : "user", + text: String(message?.text || "").trim(), + paragraphs: firstJsonArray(message?.paragraphs), + citation_ids: firstJsonArray(message?.citation_ids).map(String), + citations: firstJsonArray(message?.citations), + insufficient: Boolean(message?.insufficient), + created_at: message?.created_at || null, + }; + const encoded = Buffer.from(JSON.stringify(snapshot), "utf8").toString("base64"); + return `${snapshot.text}\n`; +} + +function decodeQaSessionMessage(message, index = 0) { + const sourceText = String(message?.text || message?.content || "").trim(); + const match = sourceText.match(QA_SESSION_MESSAGE_PATTERN); + let snapshot = null; + if (match?.[1]) { + try { + snapshot = JSON.parse(Buffer.from(match[1], "base64").toString("utf8")); + } catch { + snapshot = null; + } + } + const plainText = sourceText.replace(QA_SESSION_MESSAGE_PATTERN, "").trim(); + return { + id: String(snapshot?.id || message?.id || `openviking-qa-${index + 1}`), + role: ["assistant", "user"].includes(snapshot?.role) + ? snapshot.role + : ["assistant", "user"].includes(message?.role) ? message.role : "user", + text: String(snapshot?.text || plainText).trim(), + paragraphs: firstJsonArray(snapshot?.paragraphs), + citation_ids: firstJsonArray(snapshot?.citation_ids).map(String), + citations: firstJsonArray(snapshot?.citations), + insufficient: Boolean(snapshot?.insufficient), + created_at: snapshot?.created_at || message?.created_at || null, + }; +} + +function openVikingNotFound(result) { + const code = String(result?.error?.code || "").toLowerCase(); + const message = String(result?.error?.message || "").toLowerCase(); + return Number(result?.http_status || 0) === 404 + || ["404", "not_found", "session_not_found"].includes(code) + || /not found|does not exist|不存在|未找到/.test(message); +} + +function legacyMaterialText(content) { + const text = String(content || ""); + const body = text.match(/资料正文:([\s\S]*?)(?:\n使用边界:|$)/)?.[1]; + return cleanMaterialText(body || ""); +} + +function normalizeImportedText(value) { + return String(value || "") + .replace(//g, "") + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, " "); +} + +function normalizeInitial(name) { + const trimmed = String(name || "").trim(); + return trimmed ? trimmed.slice(0, 1) : "企"; +} + +const dataProCompanyFields = Object.freeze({ + name: ["公司名称", "企业名称", "企业全称", "company_name", "companyName", "ent_name", "entName", "name"], + unified_social_credit_code: ["统一社会信用代码", "社会信用代码", "信用代码", "unified_social_credit_code", "credit_code", "creditCode"], + legal_representative: ["法定代表人", "法人姓名", "法人", "legal_representative", "legalRepresentative", "legal_person", "legalPerson"], + registered_capital: ["注册资本", "注册资金", "registered_capital", "registeredCapital", "reg_capital", "regCapital"], + business_status: ["经营状态", "企业状态", "登记状态", "business_status", "businessStatus", "ent_status", "entStatus", "status"], + industry: ["所属行业", "行业分类", "行业", "industry_name", "industryName", "industry"], + address: ["注册地址", "住所", "企业地址", "address", "registered_address", "registeredAddress"], + province: ["省", "省份", "province", "province_name", "provinceName"], + city: ["市", "城市", "city", "city_name", "cityName"], + district: ["区县", "区/县", "区", "县", "district", "district_name", "districtName"], + established_at: ["成立日期", "成立时间", "established_at", "establishedAt", "establish_date", "establishDate"], + business_scope: ["经营范围", "business_scope", "businessScope"], +}); + +function normalizeDataFieldName(value) { + return String(value || "").normalize("NFKC").toLowerCase().replace(/[\s_.\-/()()]/g, ""); +} + +function scalarDataValue(value, maxLength = 500) { + if (!["string", "number", "boolean"].includes(typeof value)) return ""; + return String(value).replace(/\s+/g, " ").trim().slice(0, maxLength); +} + +function dataProField(item, aliases, maxLength = 500) { + if (!item || typeof item !== "object" || Array.isArray(item)) return ""; + const byNormalizedKey = new Map(Object.entries(item).map(([key, value]) => [normalizeDataFieldName(key), value])); + for (const alias of aliases) { + const value = byNormalizedKey.get(normalizeDataFieldName(alias)); + const text = scalarDataValue(value, maxLength); + if (text) return text; + } + return ""; +} + +function dataProItemLooksLikeCompany(item) { + const name = dataProField(item, dataProCompanyFields.name, 180); + if (!name) return false; + return [ + "unified_social_credit_code", + "legal_representative", + "registered_capital", + "business_status", + "address", + "established_at", + "business_scope", + ].some((field) => dataProField(item, dataProCompanyFields[field], 500)); +} + +function collectDataProCompanyItems(parsed, limit = 8) { + if (!parsed || typeof parsed !== "object") return []; + const queue = [parsed]; + const visited = new Set(); + const items = []; + while (queue.length && visited.size < 300 && items.length < limit) { + const current = queue.shift(); + if (!current || typeof current !== "object" || visited.has(current)) continue; + visited.add(current); + if (!Array.isArray(current) && dataProItemLooksLikeCompany(current)) items.push(current); + const children = Array.isArray(current) ? current : Object.values(current); + for (const child of children) { + if (child && typeof child === "object") queue.push(child); + } + } + return items; +} + +function companyItemFromDataProSummary(summary) { + const text = String(summary || ""); + const item = {}; + for (const aliases of Object.values(dataProCompanyFields)) { + for (const alias of aliases.filter((value) => /[\u4e00-\u9fff]/.test(value))) { + const match = text.match(new RegExp(`(?:^|[;;|])\\s*${alias}\\s*[::]\\s*([^;;|]+)`)); + if (match?.[1]) { + item[alias] = match[1].trim(); + break; + } + } + } + return dataProItemLooksLikeCompany(item) ? item : null; +} + +function compactCompanyLocation(item, address) { + const explicit = [ + dataProField(item, dataProCompanyFields.province, 40), + dataProField(item, dataProCompanyFields.city, 40), + dataProField(item, dataProCompanyFields.district, 40), + ].filter((value, index, values) => value && values.indexOf(value) === index).join(""); + if (explicit) return explicit.slice(0, 80); + const text = String(address || "").trim(); + const municipality = text.match(/^(北京市|上海市|天津市|重庆市)/)?.[1]; + if (municipality) return municipality; + const provinceAndCity = text.match(/^(.{2,10}?(?:省|自治区))(.{2,10}?市)/); + if (provinceAndCity) return `${provinceAndCity[1]}${provinceAndCity[2]}`.slice(0, 80); + return text.match(/^(.{2,10}?市)/)?.[1] || ""; +} + +function normalizedCompanyIdentity(value) { + return String(value || "").normalize("NFKC").toLowerCase().replace(/[\s·_.\-/()()]/g, ""); +} + +function parentheticalBrandAlias(value) { + const match = String(value || "").trim().match(/^([^()()]{2,16})\s*[((]\s*(?:中国|China)\s*[))]/iu); + return String(match?.[1] || "").trim(); +} + +const GENERIC_COMPANY_SEARCH_TERMS = new Set([ + "公司", + "企业", + "集团", + "车企", + "汽车", + "新能源", + "科技", + "制造业", + "供应商", +]); + +function companyIdentityAliases(company = {}) { + const canonicalName = normalizedCompanyIdentity(company.name); + const names = [ + company.name, + ...firstJsonArray(company.aliases), + parentheticalBrandAlias(company.name), + ].map(normalizedCompanyIdentity).filter(Boolean); + const safeNames = names.filter((name) => ( + name.length >= 4 + || ( + name.length >= 2 + && canonicalName.includes(name) + && !GENERIC_COMPANY_SEARCH_TERMS.has(name) + ) + )); + const derived = safeNames.flatMap((name) => { + const withoutLegalSuffix = name.replace(/(?:股份有限公司|有限责任公司|有限公司|股份公司|集团公司|集团)$/u, ""); + const withoutIndustrySuffix = withoutLegalSuffix.replace(/(?:新能源科技|汽车工业|汽车科技|信息技术|网络科技)$/u, ""); + return [name, withoutLegalSuffix, withoutIndustrySuffix]; + }); + return [...new Set(derived)] + .filter((item) => ( + item.length >= 4 + || ( + item.length >= 2 + && canonicalName.includes(item) + && !GENERIC_COMPANY_SEARCH_TERMS.has(item) + ) + )) + .sort((left, right) => right.length - left.length); +} + +function dossierTextMentionsCompany(value, company) { + const text = normalizedCompanyIdentity(value); + return companyIdentityAliases(company).some((alias) => text.includes(alias)); +} + +function dossierTextHasCompetingCompany(value, company) { + let text = normalizedCompanyIdentity(value); + for (const alias of companyIdentityAliases(company)) { + text = text.split(alias).join(""); + } + return /[\p{Script=Han}a-z0-9]{2,24}(?:有限责任公司|股份有限公司|有限公司|集团|股份|科技|汽车|新能源|能源|银行|证券|电建)/iu.test(text); +} + +function isPublicCitationRelevantToCompany(source, point, company) { + const label = String(source?.label || ""); + if (dossierTextMentionsCompany(point, company)) return true; + if (!dossierTextMentionsCompany(label, company)) return false; + return !dossierTextHasCompetingCompany(point, company); +} + +function isPublicRiskEvidenceForCompany(source, point, company) { + return Boolean( + point + && DOSSIER_SPECIFIC_RISK_TERMS.test(point) + && isPublicCitationRelevantToCompany(source, point, company) + && !dossierTextHasCompetingCompany(source?.label, company) + && !dossierTextHasCompetingCompany(point, company) + ); +} + +function companySearchAlias(query, companyName) { + const rawQuery = String(query || "").trim().slice(0, 80); + const normalizedQuery = normalizedCompanyIdentity(rawQuery); + const normalizedName = normalizedCompanyIdentity(companyName); + if ( + normalizedQuery.length < 2 + || normalizedQuery.length > 24 + || GENERIC_COMPANY_SEARCH_TERMS.has(normalizedQuery) + || !normalizedName.includes(normalizedQuery) + ) { + return ""; + } + return rawQuery; +} + +function preferredCompanySearchName(company) { + const aliases = [ + ...firstJsonArray(company?.aliases), + parentheticalBrandAlias(company?.name), + ] + .map((value) => companySearchAlias(value, company?.name)) + .filter(Boolean) + .sort((left, right) => normalizedCompanyIdentity(left).length - normalizedCompanyIdentity(right).length); + return aliases[0] || company?.name || ""; +} + +function stableProfessionalCompanyId(identity) { + return `company_dp_${createHash("sha256").update(identity).digest("hex").slice(0, 24)}`; +} + +function formatCitationText(paragraph) { + const ids = paragraph.citation_ids || []; + const marks = ids.map((id) => `[${id}]`).join(""); + return `${paragraph.text}${marks}`; +} + +function citationRank(citation) { + const text = `${citation?.source_kind || ""} ${citation?.label || ""}`; + if (/专业数据|专业数据库|工商|招投标/.test(text)) return 0; + if (/联网搜索|公开|新闻|公告|媒体|官网/.test(text)) return 1; + return 2; +} + +function isPlaceholderUrl(value) { + return /(^https?:\/\/)?(www\.)?example\.(com|test)\b/i.test(String(value || "")); +} + +function publicSourceUrl(value) { + const text = compactText(value, 500); + if (!text || isPlaceholderUrl(text)) return ""; + try { + const url = new URL(text); + return ["http:", "https:"].includes(url.protocol) ? url.toString() : ""; + } catch { + return ""; + } +} + +function publicSourceHostname(value) { + try { + return new URL(publicSourceUrl(value)).hostname.replace(/^www\./i, "").toLowerCase(); + } catch { + return ""; + } +} + +function publicCitationView(citation, id = citation?.id) { + const sourceKind = compactText(citation?.source_kind || "资料来源", 40); + const rawLabel = compactText(normalizeSalesText(citation?.label || ""), 160); + const sanitizedRawLabel = sourceKind === "联网搜索" + ? cleanPublicEvidenceLabel(rawLabel) + : rawLabel; + const label = /^(?:viking|openviking|datapro|model|fixture|demo-[^:]*):\/\//i.test(sanitizedRawLabel) + || /^(?:viking|openviking|datapro|model|fixture|demo-[^:]*):/i.test(rawLabel) + ? sourceKind + : sanitizedRawLabel || sourceKind; + const entityMatch = String(citation?.entity_match || ""); + const cleanedPublicSummary = sourceKind === "联网搜索" + ? cleanPublicEvidenceText(citation?.summary || citation?.excerpt || "", 2400) + : ""; + const summary = sourceKind === "联网搜索" + ? (cleanedPublicSummary || label) + : businessText(citation?.summary || citation?.excerpt || "", "", 2400); + return { + id: String(id || ""), + label, + source_kind: sourceKind, + url: publicSourceUrl(citation?.url), + summary, + site_name: sourceKind === "联网搜索" + ? compactText(citation?.site_name || "", 160) + : "", + published_at: citation?.published_at || null, + source_updated_at: citation?.source_updated_at || null, + source_quality_label: compactText(citation?.source_quality_label || "", 80), + freshness_label: compactText(citation?.freshness_label || "", 80), + verification_label: entityMatch === "alias_scoped" + ? "品牌或简称相关,需核验法定主体归属" + : /^(verified|query_bound|company_scoped)$/.test(entityMatch) + ? "企业主体已核验" + : "", + }; +} + +function firstJsonArray(value) { + return Array.isArray(value) ? value : []; +} + +function hasTechnicalErrorText(value) { + const text = String(value || ""); + return /APIKey|鉴权失败|Unauthorized|provider_error|fetch failed/.test(text) + || /"code"\s*:\s*(?:4\d{3}|5\d{3})/.test(text) + || /(?:错误码|error_code|code)\s*[::]\s*(?:4\d{3}|5\d{3})/i.test(text) + || /企业ID\s*[\((]\s*关联主键\s*[\))]\s*[::]/i.test(text) + || /(?:trace|request|record|relation)[ _-]?id\s*[::]/i.test(text); +} + +function businessText(value, fallback, maxLength = 900) { + const text = compactText(value, maxLength); + if (!text || hasTechnicalErrorText(text)) return fallback; + return text; +} + +function providerUnavailable(provider, message, details = {}) { + const error = new HttpError(503, `${provider}_unavailable`, message, { + provider, + ...details, + }); + error.retryable = Boolean(details.retryable); + if (details.category) error.category = details.category; + return error; +} + +function providerFailureDetails(failures = []) { + const lastFailure = failures[failures.length - 1] || {}; + return { + reason: lastFailure.code || "provider_error", + category: lastFailure.category || "upstream", + retryable: failures.some((failure) => ( + Boolean(failure?.retryable) + || Number(failure?.status || failure?.http_status || 0) >= 500 + )), + }; +} + +function hasBadDisplayText(value) { + const text = String(value || ""); + return hasTechnicalErrorText(text) || /�|\\u[0-9a-fA-F]{4}|undefined|null/.test(text); +} + +function cleanEvidenceSummary(value, fallback = "", maxLength = 420) { + const text = compactText(normalizeSalesText(value), maxLength); + if (!text || hasBadDisplayText(text)) return fallback; + return text; +} + +function dataProEvidenceSummaries(result) { + const itemSummaries = firstJsonArray(result?.item_summaries) + .map((item) => cleanEvidenceSummary(item, "", 1600)) + .filter(Boolean); + if (itemSummaries.length) return itemSummaries; + const summary = cleanEvidenceSummary(result?.summary, "", 2400); + return summary ? [summary] : []; +} + +function qaRetrievalQueries(company, question, conversationHistory = []) { + const plan = analyzeQaQuestion(question, conversationHistory); + const intentTerms = { + risk: "风险 合规 处罚 诉讼 顾虑", + timeline: "时间 节点 计划 进度", + people: "负责人 联系人 决策部门 对接人", + requirement: "需求 痛点 关注 场景 预算", + action: "下一步 建议 跟进 推进", + overview: "业务概览 当前情况", + fact: "", + }; + const expansion = plan.intents.map((intent) => intentTerms[intent] || "").filter(Boolean).join(" "); + return [...new Set([ + `${company.name} ${plan.resolved_question}`, + ...plan.subqueries.map((query) => `${company.name} ${query} ${expansion}`), + ].map((query) => compactText(query, 1800)).filter(Boolean))].slice(0, 3); +} + +function shortSourcePoint(source, maxLength = 120) { + const label = cleanEvidenceSummary(source?.label, "", 80); + const summary = cleanEvidenceSummary(source?.summary, "", maxLength); + const sentence = summary.split(/[。.!!??]/).find(Boolean) || summary; + return compactText(sentence || label || "来源返回可引用信息", maxLength); +} + +function cleanPublicEvidenceText(value, maxLength = 900) { + return cleanEvidenceSummary(value, "", maxLength) + .replace(/^雷递网\s+\S+\s+\d{1,2}月\d{1,2}日\s*/u, "") + .replace(/^[^。;]{0,28}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s*/u, "") + .replace(/\b20\d{2}年\d{1,2}月\d{1,2}日\s+\d{1,2}:\d{2}(?::\d{2})?\s*(?:(?:市场|行业|公司|商业)?资讯)?\s*(?:[((]来源[::][^))]{1,80}[))])?\s*/gu, "") + .replace(/(?:市场|行业|公司|商业)?资讯\s*[((]来源[::][^))]{1,80}[))]\s*/gu, "") + .replace(/[((]来源[::][^))]{1,80}[))]\s*/gu, "") + .replace(/查看更多(?:相关)?[\s\S]*$/u, "") + .replace(/(?:立即注册|免费查看|点击查看|登录后查看)[\s\S]*$/u, "") + .trim(); +} + +function cleanPublicEvidenceLabel(value) { + return cleanPublicEvidenceText(value, 260) + .replace(/_(?:新浪财经|新浪网|财经头条|雷递|百科|搜狐|腾讯新闻).*$/u, "") + .replace(/_[^_]{2,24}$/u, "") + .replace(/[\s_-]+(?:首页|Untitled)$/iu, "") + .trim(); +} + +function dossierEvidencePointScore(value, { fromSummary = false } = {}) { + const text = String(value || ""); + let score = fromSummary ? 2 : 0; + if (DOSSIER_ACTION_TERMS.test(text)) score += 6; + if (/(?:20\d{2}年|\d{1,2}月\d{1,2}日)/.test(text)) score += 2; + if (text.length >= 28 && text.length <= 220) score += 2; + if (/[。!?!?]$/.test(text)) score += 1; + if (/如何|为什么|为何|怎样|是否|吗[??]?$|[??]$/.test(text)) score -= 8; + if (/^(?:公司简介|企业信息|招标信息|最新消息|新闻资讯)$/.test(text)) score -= 10; + return score; +} + +function isSubstantiveDossierEvidencePoint(value) { + const text = compactText(value, 500); + return text.length >= 18 + && !hasBadDisplayText(text) + && !hasDossierInternalMetaText(text) + && !/(?:^|[-—::])(?:招标信息|公司简介|企业信息|最新消息|新闻资讯)$/.test(text) + && dossierPointQualityErrors(text).length === 0; +} + +function concisePublicPoint(source, maxLength = 220) { + const label = cleanPublicEvidenceLabel(source?.label); + const rawSummary = cleanPublicEvidenceText(source?.summary, 1200); + const comparableLabel = normalizeChineseDossierPunctuation(label).replace(/[。!?;\s]+$/u, ""); + const summary = comparableLabel && normalizeChineseDossierPunctuation(rawSummary).startsWith(comparableLabel) + ? normalizeChineseDossierPunctuation(rawSummary) + .slice(comparableLabel.length) + .replace(/^[\s,。;:!?!?:、-]+/u, "") + .trim() + : rawSummary; + const summarySegments = summary + .split(/(?<=[。!?!?])\s*/u) + .map((item) => item.trim()) + .filter(Boolean); + const candidates = [ + ...summarySegments.map((text) => ({ text, fromSummary: true })), + { text: label, fromSummary: false }, + ] + .filter((item) => isSubstantiveDossierEvidencePoint(item.text)) + .filter((item) => item.text.length <= maxLength) + .sort((a, b) => ( + dossierEvidencePointScore(b.text, b) + - dossierEvidencePointScore(a.text, a) + )); + return String(candidates[0]?.text || "").replace(/[。;\s]+$/u, ""); +} + +function publicDossierSourceText(source, point = "") { + return [ + source?.label, + source?.site_name, + source?.auth_description, + source?.summary, + point, + source?.url, + ].map((value) => String(value || "")).join(" "); +} + +function isLowValuePublicDossierSource(source, point = "") { + const text = publicDossierSourceText(source, point); + return DOSSIER_LOW_VALUE_PUBLIC_SOURCE_PATTERNS.some((pattern) => pattern.test(text)); +} + +function isDisplayableDossierCitation(citation, company) { + if (!/专业数据集|联网搜索/.test(String(citation?.source_kind || ""))) return false; + if (hasDossierEvidenceDebris(citation?.label || "")) return false; + if (!businessText(citation?.summary || citation?.excerpt, "", 600)) return false; + if (citation.source_kind !== "联网搜索") { + const point = safeDeterministicDossierPoint(conciseProfessionalPoint(citation)); + return Boolean( + point + && !isLowValueProfessionalPoint(point) + && isSubstantiveDossierEvidencePoint(point) + ); + } + const point = concisePublicPoint(citation); + if (!point || isLowValuePublicDossierSource(citation, point)) return false; + if (!isPublicCitationRelevantToCompany(citation, point, company)) return false; + const hasSpecificRisk = DOSSIER_SPECIFIC_RISK_TERMS.test(`${citation.label || ""} ${point}`); + return !hasSpecificRisk || isPublicRiskEvidenceForCompany(citation, point, company); +} + +function dossierCitationAnchorsLegalEntity(citation, company = {}) { + if (citation?.source_kind !== "专业数据集") return false; + const sourceText = normalizedCompanyIdentity(`${citation.label || ""} ${citation.summary || citation.excerpt || ""}`); + const canonicalName = normalizedCompanyIdentity(company.name); + const creditCode = normalizedCompanyIdentity( + company.unified_social_credit_code || company.credit_code || "", + ); + return Boolean( + (canonicalName && sourceText.includes(canonicalName)) + || (creditCode && sourceText.includes(creditCode)) + ); +} + +function dossierGroundingErrors(citations = [], body = null, company = {}) { + const citedIds = Array.isArray(body) + ? new Set(body.flatMap((paragraph) => firstJsonArray(paragraph?.citation_ids).map(String))) + : null; + const used = citations.filter((citation) => !citedIds || citedIds.has(String(citation.id))); + const errors = []; + if (!used.length) errors.push("档案没有引用可展示的外部来源"); + if (!used.some((citation) => dossierCitationAnchorsLegalEntity(citation, company))) { + errors.push("档案没有实际引用能够确认目标法定主体的专业来源"); + } + return errors; +} + +function isGenericCompanyLandingPage(source, point, company) { + const label = normalizedCompanyIdentity(cleanPublicEvidenceLabel(source?.label)); + if (!label) return false; + const genericLabel = companyIdentityAliases(company).some((alias) => { + const remainder = label + .split(alias).join("") + .replace(/(?:官方网站|官网|首页|officialsite|official|website)/giu, "") + .replace(/[a-z]{2,12}\d{0,6}/giu, "") + .replace(/\d{2,8}/gu, ""); + return remainder.length === 0; + }); + if (!genericLabel) return false; + let rootPage = false; + try { + const url = new URL(String(source?.url || "")); + rootPage = /^\/(?:index\.(?:html?|shtml))?$/iu.test(url.pathname || "/"); + } catch { + rootPage = false; + } + return rootPage || !DOSSIER_ACTION_TERMS.test(`${point || ""} ${source?.label || ""}`); +} + +function isRecentPublicDossierCitation(source, point, company) { + const text = `${point || ""} ${source?.label || ""}`; + return Boolean( + point + && isSubstantiveDossierEvidencePoint(point) + && !isLowValuePublicDossierSource(source, point) + && !isGenericCompanyLandingPage(source, point, company) + && DOSSIER_ACTION_TERMS.test(text) + && isPublicCitationRelevantToCompany(source, point, company) + ); +} + +export function assessDossierEvidenceCoverage(company, collected = {}) { + const professional = firstJsonArray(collected.professional) + .map((source) => ({ ...source, source_kind: "专业数据集" })) + .filter((source) => isDisplayableDossierCitation(source, company)); + const publicSources = firstJsonArray(collected.public_sources) + .map((source) => ({ ...source, source_kind: "联网搜索" })) + .filter((source) => isDisplayableDossierCitation(source, company)); + const recentPublic = publicSources.filter((source) => ( + isRecentPublicDossierCitation(source, concisePublicPoint(source), company) + )); + const riskSources = [ + ...professional.filter((source) => ( + /企业风险数据库/.test(String(source.label || "")) + && !dossierBusinessEntityRecord(source) + )), + ...publicSources.filter((source) => ( + isPublicRiskEvidenceForCompany(source, concisePublicPoint(source), company) + )), + ]; + const operationsSources = [ + ...professional.filter((source) => ( + /金融数据库|汽车销量数据库|科研学术数据搜索服务/.test(String(source.label || "")) + && !dossierBusinessEntityRecord(source) + )), + ...recentPublic.filter((source) => ( + DOSSIER_ACTION_TERMS.test(`${source.label || ""} ${concisePublicPoint(source)}`) + && !DOSSIER_SPECIFIC_RISK_TERMS.test(`${source.label || ""} ${concisePublicPoint(source)}`) + )), + ]; + const procurementSources = recentPublic.filter((source) => ( + /招标|采购|中标|供应商|框架协议|项目/.test(`${source.label || ""} ${concisePublicPoint(source)}`) + )); + const publicHosts = new Set( + publicSources.map((source) => publicSourceHostname(source.url)).filter(Boolean), + ); + const coverage = { + legal_entity: professional.some((source) => ( + /企业工商数据库/.test(String(source.label || "")) + || dossierTextMentionsCompany(`${source.label || ""} ${source.summary || ""}`, company) + )), + operations: operationsSources.length > 0, + recent_public: recentPublic.length > 0, + risk: riskSources.length > 0, + procurement_or_project: procurementSources.length > 0, + public_host_count: publicHosts.size, + usable_professional_count: professional.length, + usable_public_count: publicSources.length, + }; + coverage.missing_topics = [ + ...(!coverage.recent_public ? ["recent_public"] : []), + ...(!coverage.operations ? ["operations"] : []), + ...(!coverage.risk ? ["risk"] : []), + ...(!coverage.procurement_or_project ? ["procurement_or_project"] : []), + ...(coverage.public_host_count < Math.min(3, coverage.usable_public_count + 1) + ? ["source_diversity"] + : []), + ]; + return coverage; +} + +function publicDossierEvidenceScore(source, point, company) { + if (isLowValuePublicDossierSource(source, point)) return -100; + let score = dossierEvidencePointScore(point, { fromSummary: true }); + if (source?.published_at) score += 3; + const authLevel = Number(source?.auth_level); + if (Number.isFinite(authLevel) && authLevel > 0) score += Math.min(authLevel, 4); + const sourceText = publicDossierSourceText(source, point); + if (/(?:gov\.cn|cninfo\.com\.cn|sse\.com\.cn|szse\.cn)\b/iu.test(sourceText)) score += 5; + if (/官方公告|投资者关系|证券交易所|政府网站|监管机构|官网新闻/iu.test(sourceText)) score += 3; + if (isRecentPublicDossierCitation(source, point, company)) score += 4; + return score; +} + +function sourcePointList(sources, limit = 3) { + return sources + .slice(0, limit) + .map((source) => shortSourcePoint(source)) + .filter(Boolean); +} + +function isWeakCompanySituationText(value) { + const text = String(value || ""); + return /专业数据库(?:返回|显示|依据|来源).*专业数据库/.test(text) + || /专业数据集(?:返回|显示|依据|来源).*专业数据库/.test(text) + || /只.*返回.*数据库/.test(text); +} + +function isLowValueProfessionalPoint(value) { + return /^企业ID\s*[\((]\s*关联主键\s*[\))]/.test(String(value || "").trim()); +} + +function isOverlongLatestText(value) { + const text = String(value || ""); + return text.length > 420 || (/来源:|发布时间:|NYSE|HK/.test(text) && /联网搜索|公开来源/.test(text)); +} + +function extractSourceField(summary, key) { + const match = String(summary || "").match(new RegExp(`${key}\\s*[::]\\s*([^;;|。]+)`)); + return match ? match[1].trim() : ""; +} + +function conciseProfessionalPoint(source, targetCompanyName = "") { + const summary = String(source?.summary || ""); + if (/公司名称|统一社会信用代码|法人姓名|法定代表人/.test(summary)) { + const leadingCompanyName = summary.match(/^([^;;|。]{4,80})[;;]/)?.[1]?.trim() || ""; + const companyName = extractSourceField(summary, "公司名称") + || extractSourceField(summary, "企业名称") + || ( + targetCompanyName + && normalizedCompanyIdentity(leadingCompanyName) === normalizedCompanyIdentity(targetCompanyName) + ? leadingCompanyName + : "" + ); + if ( + targetCompanyName + && companyName + && normalizedCompanyIdentity(companyName) !== normalizedCompanyIdentity(targetCompanyName) + ) { + return ""; + } + const creditCode = extractSourceField(summary, "统一社会信用代码"); + const legalPerson = extractSourceField(summary, "法人姓名") || extractSourceField(summary, "法定代表人"); + const address = extractSourceField(summary, "注册地址"); + const startedAt = extractSourceField(summary, "成立日期").slice(0, 10); + const businessScope = extractSourceField(summary, "经营范围"); + return [ + companyName ? `公司名称:${companyName}` : "", + creditCode ? `统一社会信用代码:${creditCode}` : "", + legalPerson ? `法定代表人:${legalPerson}` : "", + address ? `注册地址:${address}` : "", + startedAt ? `成立日期:${startedAt}` : "", + businessScope ? `经营范围:${businessScope}` : "", + ].filter(Boolean).join(";"); + } + return shortSourcePoint(source, 120); +} + +function safeDeterministicDossierPoint(value) { + const text = String(value || "").trim(); + if (!text) return ""; + const unsafeNumericClaim = /(?:注册资本|营业收入|营收|净利润|利润|融资|估值|回购|市值|市占率)[^。;\n]{0,48}\d/; + const unsafeRiskClaim = /(?:(?:行政处罚|司法诉讼|失信被执行|限制高消费|经营异常|重大风险).{0,24}(?:存在|涉及|新增|发生|受到|列入|被执行|\d))|(?:(?:存在|涉及|新增|发生|受到|列入|被执行|\d).{0,24}(?:行政处罚|司法诉讼|失信被执行|限制高消费|经营异常|重大风险))/; + return text + .split(/(?<=[。!?])\s*|(?<=;)\s*/u) + .map((item) => item.trim()) + .filter((item) => item && !unsafeNumericClaim.test(item) && !unsafeRiskClaim.test(item)) + .join("") + .replace(/[。;\s]+$/u, ""); +} + +function dossierSalesThemes(values = [], company = {}) { + const text = values.filter(Boolean).join(" "); + const candidates = [ + [/知识库|知识管理|内容检索|智能问答/, "知识库与智能问答"], + [/数据安全|隐私|合规|私有化|权限/, "数据安全与合规部署"], + [/人工智能|大模型|智能化|算法/, "AI 应用与智能化升级"], + [/储能|电池|电芯|锂电|光伏/, "储能与电池供应链"], + [/汽车|车企|座舱|车主服务|新能源车/, "汽车智能化与车主服务"], + [/供应链|采购|招标|中标|供应商/, "供应链与采购协同"], + [/软件|系统|平台|SaaS/, "企业软件与系统集成"], + [/产线|制造|工厂|设备|量产/, "生产制造与设备交付"], + ] + .filter(([pattern]) => pattern.test(text)) + .map(([, label]) => label); + const industry = compactText(company?.industry || "", 40); + if (industry && !candidates.includes(industry)) candidates.push(industry); + return [...new Set(candidates)].slice(0, 3).length + ? [...new Set(candidates)].slice(0, 3) + : ["主营业务相关产品与服务"]; +} + +function dossierDisplayText(dossier) { + return [ + dossier?.title, + dossier?.summary, + dossier?.memory_summary, + ...firstJsonArray(dossier?.body).map((paragraph) => paragraph?.text), + ].filter(Boolean).join(" "); +} + +function isDisplayableDossier(dossier) { + const text = dossierDisplayText(dossier); + if (!compactText(dossier?.summary || firstJsonArray(dossier?.body)[0]?.text || dossier?.memory_summary, 80)) return false; + if (hasTechnicalErrorText(text) || /这份档案需要重新获取|provider_error|fetch failed|鉴权失败|Unauthorized/.test(text)) { + return false; + } + const body = firstJsonArray(dossier?.body); + if (body.length !== DOSSIER_SECTION_TITLES.length) return false; + const citationIds = new Set(firstJsonArray(dossier?.citations).map((item) => String(item?.id || ""))); + if (!citationIds.size) return false; + if (dossierSectionContentErrors(body).length) return false; + if (body.some((paragraph) => ( + !firstJsonArray(paragraph?.citation_ids).some((id) => citationIds.has(String(id))) + ))) { + return false; + } + return true; +} + +function cleanMaterialText(value) { + return normalizeImportedText(value) + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim() + .slice(0, 12000); +} + +function isFeishuMaterial(material) { + const identity = [ + material?.source_type, + material?.title, + material?.source_url, + ].filter(Boolean).join(" "); + return /(?:^|\b)feishu[_-]|lark|飞书|云文档|会议纪要|会话/i.test(identity); +} + +function feishuMaterialSourceKind(material) { + const sourceType = String(material?.source_type || "").toLowerCase(); + if (/feishu_(?:chat|p2p|search)|会话|群聊|单聊|消息/.test(sourceType)) return "飞书会话"; + if (/feishu_doc|云文档|会议纪要|文档/.test(sourceType)) return "云文档"; + return "飞书资料"; +} + +function canonicalOpenVikingResourceUri(value) { + return String(value || "") + .trim() + .replace(/[?#].*$/, "") + .replace(/\/+$/, "") + .replace(/\.(?:md|markdown|txt)$/i, "") + .toLowerCase(); +} + +function isOpenVikingOverviewItem(item) { + const uri = String(item?.uri || "").replace(/[?#].*$/, "").replace(/\/+$/, ""); + const leaf = uri.split("/").pop() || ""; + const title = compactText(item?.title || item?.name || "", 80); + return /^overview(?:\.(?:md|markdown|txt))?$/i.test(leaf) + || /^overview$/i.test(title); +} + +function sanitizeQaDisplayText(value) { + const normalized = normalizeSalesText(value); + const containsInternalImplementation = /(?:viking|openviking):\/\//i.test(normalized) + || /\/materials(?:\/|\b)/i.test(normalized) + || /\b(?:company|mat|sync)_[a-z0-9_-]{8,}\b/i.test(normalized) + || /(?:内部|资源)?(?:目录|路径).{0,80}\bmaterials\b/i.test(normalized); + if (containsInternalImplementation) { + return "该历史回答包含内部实现信息,已隐藏;请重新提问以获取仅基于业务资料的回答。"; + } + return normalized; +} + +function qaDisplayCitationIdentity(citation, index = 0) { + const materialId = compactText(citation?.material_id || "", 240); + if (materialId) return `material:${materialId}`; + const sourceKind = compactText(citation?.source_kind || "资料来源", 80); + const label = compactText(citation?.label || "", 240); + if (sourceKind === "企业档案") { + return `dossier-section:${label || index}`; + } + const uri = canonicalOpenVikingResourceUri(citation?.uri || ""); + if (uri) return `uri:${uri}`; + const url = publicSourceUrl(citation?.url); + if (url) return `url:${url}`; + return `source:${sourceKind}:${label || index}`; +} + +function mergeQaDisplayCitations(message) { + const groups = []; + const groupByIdentity = new Map(); + const citationIdMap = new Map(); + firstJsonArray(message?.citations).forEach((citation, index) => { + const originalId = String(citation?.id || index + 1); + const identity = qaDisplayCitationIdentity(citation, index); + let group = groupByIdentity.get(identity); + if (!group) { + group = { + citation: { + ...citation, + id: String(groups.length + 1), + }, + original_ids: [], + }; + groups.push(group); + groupByIdentity.set(identity, group); + } + group.original_ids.push(originalId); + citationIdMap.set(originalId, String(group.citation.id)); + }); + const remapIds = (ids) => [...new Set( + firstJsonArray(ids) + .map((id) => citationIdMap.get(String(id))) + .filter(Boolean), + )]; + return { + citations: groups.map((group) => group.citation), + citation_ids: remapIds(message?.citation_ids), + paragraphs: firstJsonArray(message?.paragraphs).map((paragraph) => ({ + ...paragraph, + citation_ids: remapIds(paragraph?.citation_ids), + })), + }; +} + +function hasLegacyGenericQaCitations(message) { + return firstJsonArray(message?.citations).some((citation) => { + const sourceKind = compactText(citation?.source_kind || "", 80); + const label = compactText(citation?.label || "", 240); + return sourceKind === "内部资料" || label === "内部资料"; + }); +} + +function progressLevel(label) { + const text = String(label || ""); + if (/签约|成交|已确认|方案|推进/.test(text)) return 78; + if (/需求确认/.test(text)) return 58; + if (/初步|接触/.test(text)) return 34; + if (/暂无|不足/.test(text)) return 12; + if (/新商机/.test(text)) return 22; + return 42; +} + +function normalizedSalesStatus(label) { + const text = String(label || ""); + if (/签约|成交|归档|已成交/.test(text)) return "成交归档"; + if (/方案|报价|商务|推进/.test(text)) return "商务推进"; + if (/需求确认|需求/.test(text)) return "需求确认中"; + if (/初步|接触/.test(text)) return "初步接触"; + if (/暂无|不足/.test(text)) return "暂无有效信号"; + return "新商机"; +} + +function conciseProgressSummary(label, summary = "") { + const status = normalizedSalesStatus(label); + const text = compactText(summary, 220); + if (text && text.length <= 28 && !/最近档案|企业情况|近期动态|销售判断|下一步建议|专业数据库|联网搜索|但|需要/.test(text)) { + return text; + } + const fallback = { + 新商机: "已加入目标企业池,当前无历史资料,待生成最新档案。", + 初步接触: "已完成基础信息了解,尚未形成明确采购计划。", + 需求确认中: "已识别数据安全与私有化部署需求,待确认预算和排期。", + 商务推进: "已进入方案沟通阶段,待确认商务条件和决策流程。", + 成交归档: "已完成合作归档,后续关注续约和扩展机会。", + 暂无有效信号: "当前资料不足,需先补充有效企业信息。", + }; + return fallback[status] || "当前进度待补充。"; +} + +function normalizeSalesText(value) { + return String(value || ""); +} + +export class SalesService { + constructor(options = {}) { + this.env = options.env || createEnvReader(); + this.runtimePolicy = options.runtimePolicy || createRuntimePolicy({ env: this.env }); + const initialData = options.seed !== undefined ? options.seed : emptySalesData(); + this.data = clone(initialData); + this.data.jobs = this.data.jobs || {}; + this.dataProProvider = options.dataProProvider || null; + this.webSearchProvider = options.webSearchProvider || null; + this.modelProvider = options.modelProvider || null; + this.openVikingProvider = options.openVikingProvider || null; + this.repository = options.repository || null; + this.workspaceId = String(this.env.value("APP_WORKSPACE_ID", "local-workspace") || "local-workspace").trim(); + this.qaAutoCommitEvery = Math.max(0, Math.min( + 20, + Number(this.env.value("OPENVIKING_QA_AUTO_COMMIT_EVERY", "4")) || 0, + )); + this.qaKeepRecentMessages = Math.max(0, Math.min( + 40, + Number(this.env.value("OPENVIKING_QA_KEEP_RECENT_MESSAGES", "6")) || 0, + )); + this.asyncJobsEnabled = enabled(this.env.value( + "ASYNC_JOBS_ENABLED", + "true", + )); + this.dossierCheckpointTtlMs = Math.max( + 5 * 60_000, + Math.min( + 2 * 60 * 60_000, + Number(this.env.value("DOSSIER_CHECKPOINT_TTL_MS", "1800000")) || 1_800_000, + ), + ); + this.dossierDataProConcurrency = Math.max( + 1, + Math.min(3, Number(this.env.value("DOSSIER_DATAPRO_CONCURRENCY", "2")) || 2), + ); + this.dossierWebConcurrency = Math.max( + 1, + Math.min(4, Number(this.env.value("DOSSIER_WEB_CONCURRENCY", "3")) || 3), + ); + this.providerRuns = options.providerRunStore || new ProviderRunStore({ + repository: this.repository, + failOnPersistenceError: this.runtimePolicy.fail_closed, + circuitBreaker: options.providerCircuitBreaker || new ProviderCircuitBreaker({ + enabled: enabled(this.env.value( + "PROVIDER_CIRCUIT_BREAKER_ENABLED", + "true", + )), + failureThreshold: Number(this.env.value("PROVIDER_CIRCUIT_BREAKER_FAILURE_THRESHOLD", "5")), + cooldownSeconds: Number(this.env.value("PROVIDER_CIRCUIT_BREAKER_COOLDOWN_SECONDS", "60")), + }), + }); + this.paidWorkflowGuard = options.paidWorkflowGuard || new PaidWorkflowGuard({ + env: this.env, + repository: this.repository, + failClosed: this.runtimePolicy.fail_closed, + listLocalJobs: () => Object.values(this.data.jobs || {}), + }); + this.persistence = { enabled: false, last_error: null }; + this.lastPersistedRefreshAt = 0; + this.persistedRefreshPromise = null; + this.initialization = this.loadPersistedState(); + } + + async loadPersistedState() { + if (typeof this.repository?.getSalesState !== "function") return; + try { + const state = await this.repository.getSalesState(this.data); + if (state && Array.isArray(state.goals)) { + this.data = { + goals: state.goals, + companies: state.companies || {}, + dossiers: state.dossiers || {}, + materials: state.materials || {}, + qa_messages: state.qa_messages || {}, + sync_sources: state.sync_sources || {}, + sync_checkpoints: state.sync_checkpoints || {}, + jobs: state.jobs || {}, + }; + } + this.persistence = { enabled: true, last_error: null }; + this.lastPersistedRefreshAt = Date.now(); + } catch (error) { + this.persistence = { enabled: false, last_error: error.message }; + } + } + + async refreshPersistedState(options = {}) { + await this.initialization; + if (typeof this.repository?.getSalesState !== "function") return false; + const minIntervalMs = Math.max(0, Number(options.minIntervalMs ?? 250) || 0); + if (!options.force && Date.now() - this.lastPersistedRefreshAt < minIntervalMs) return false; + if (this.persistedRefreshPromise) return this.persistedRefreshPromise; + + const refresh = this.loadPersistedState().then(() => { + if (this.runtimePolicy.fail_closed && !this.persistence.enabled) { + throw providerUnavailable("supabase", "Persistent storage refresh failed.", { + reason: this.persistence.last_error || "repository_refresh_failed", + }); + } + return true; + }); + this.persistedRefreshPromise = refresh.finally(() => { + this.persistedRefreshPromise = null; + }); + return this.persistedRefreshPromise; + } + + async assertRuntimeReady() { + await this.initialization; + if (this.runtimePolicy.fail_closed && !this.persistence.enabled) { + throw providerUnavailable("supabase", "Persistent storage is unavailable.", { + reason: this.persistence.last_error || "repository_not_ready", + }); + } + } + + async persist(operation) { + await this.initialization; + if (!this.persistence.enabled || !this.repository) return null; + try { + const result = await operation(); + this.persistence.last_error = null; + return result; + } catch (error) { + this.persistence.last_error = error.message; + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("supabase", "Persistent storage write failed.", { + reason: error.message || "repository_write_failed", + }); + } + return null; + } + } + + async listProviderRuns(filters = {}) { + return (await this.providerRuns.list(filters)).map((run) => this.publicProviderRun(run)); + } + + async getProviderRun(runId) { + const run = await this.providerRuns.get(runId); + if (!run) { + throw new HttpError(404, "provider_run_not_found", "Provider 运行记录不存在。", { + provider_run_id: runId, + }); + } + return this.publicProviderRun(run); + } + + publicProviderRun(run) { + return { + id: run.id, + operation: run.operation, + status: run.status, + entity_type: run.entity_type || "", + entity_id: run.entity_id || "", + job_id: run.job_id || null, + started_at: run.started_at || null, + finished_at: run.finished_at || null, + duration_ms: run.duration_ms ?? null, + error: run.error ? clone(run.error) : null, + steps: firstJsonArray(run.steps).map((step) => ({ + id: step.id, + sequence: step.sequence, + provider: step.provider, + operation: step.operation, + status: step.status, + input_summary: step.input_summary || "", + output_summary: step.output_summary || "", + usage: step.usage ? clone(step.usage) : null, + attempts: step.attempts, + started_at: step.started_at || null, + finished_at: step.finished_at || null, + latency_ms: step.latency_ms ?? null, + error: step.error ? clone(step.error) : null, + })), + }; + } + + async requireJob(jobId, options = {}) { + await this.assertRuntimeReady(); + let job = this.data.jobs?.[jobId] || null; + if ((options.refresh || !job) && typeof this.repository?.getJob === "function" && this.persistence.enabled) { + const persisted = await this.repository.getJob(jobId); + if (persisted) { + job = persisted; + this.data.jobs[job.id] = job; + } + } + if (!job) throw new HttpError(404, "job_not_found", "任务记录不存在。", { job_id: jobId }); + return job; + } + + async startJob(input = {}) { + if (input.retry_job_id) { + const existing = await this.requireJob(input.retry_job_id, { refresh: true }); + if (!["failed", "cancelled"].includes(existing.status)) { + throw new HttpError(409, "job_not_retryable", "只有失败或已取消的任务可以重试。", { + job_id: existing.id, + status: existing.status, + }); + } + if (Number(existing.attempt_count || 0) >= Number(existing.max_attempts || 1)) { + throw new HttpError(409, "job_attempts_exhausted", "任务已达到最大执行次数。", { + job_id: existing.id, + attempt_count: Number(existing.attempt_count || 0), + max_attempts: Number(existing.max_attempts || 1), + }); + } + if (input.job_type && input.job_type !== existing.job_type) { + throw new HttpError(409, "job_type_mismatch", "重试任务类型与原任务不一致。", { job_id: existing.id }); + } + existing.status = "running"; + existing.attempt_count = Number(existing.attempt_count || 0) + 1; + existing.started_at = nowIso(); + existing.finished_at = null; + existing.error = null; + existing.result_ref = null; + existing.result = null; + existing.cancel_requested_at = null; + existing.updated_at = existing.started_at; + existing.is_paid = input.is_paid !== false; + return this.reserveJob(existing); + } + + const createdAt = nowIso(); + const job = { + id: makeId("job"), + job_type: String(input.job_type || "workflow"), + status: "running", + entity_type: String(input.entity_type || ""), + entity_id: String(input.entity_id || ""), + idempotency_key: input.idempotency_key || null, + request: clone(input.request || {}), + attempt_count: 1, + max_attempts: Math.max(1, Number(input.max_attempts || 1)), + scheduled_at: createdAt, + started_at: createdAt, + finished_at: null, + error: null, + result_ref: null, + result: null, + is_paid: input.is_paid !== false, + created_at: createdAt, + updated_at: createdAt, + }; + return this.reserveJob(job); + } + + publicJob(job) { + if (!job) return null; + const status = String(job.status || "queued"); + const stage = String(job.stage || status || "queued"); + const retryable = ["failed", "cancelled"].includes(status) + && Number(job.attempt_count || 0) < Number(job.max_attempts || 1); + const safeResult = job.result && typeof job.result === "object" + ? Object.fromEntries(Object.entries(job.result).filter(([key]) => [ + "action", + "dossier_id", + "version_no", + "status", + "material_count", + "failed_count", + ].includes(key))) + : null; + return { + id: job.id, + job_type: job.job_type, + status, + stage, + stage_label: JOB_STAGE_LABELS[stage] || JOB_STAGE_LABELS[status] || "正在处理", + stage_detail: safeJobProgressDetail(job.progress_detail), + progress: Math.max(0, Math.min(Number(job.progress ?? (status === "succeeded" ? 100 : 0)), 100)), + entity_type: job.entity_type || "", + entity_id: job.entity_id || "", + attempt_count: Number(job.attempt_count || 0), + max_attempts: Number(job.max_attempts || 1), + retryable, + error: job.error ? { + code: String(job.error.code || "workflow_failed"), + message: status === "failed" ? "任务执行失败,请重试或联系管理员。" : "", + } : null, + result: safeResult && Object.keys(safeResult).length ? safeResult : null, + scheduled_at: job.scheduled_at || null, + started_at: job.started_at || null, + finished_at: job.finished_at || null, + created_at: job.created_at || null, + updated_at: job.updated_at || null, + }; + } + + async enqueueJob(input = {}) { + await this.assertRuntimeReady(); + const createdAt = nowIso(); + const job = { + id: makeId("job"), + job_type: String(input.job_type || "workflow"), + status: "queued", + stage: "queued", + progress: 0, + checkpoint: {}, + progress_detail: {}, + entity_type: String(input.entity_type || ""), + entity_id: String(input.entity_id || ""), + idempotency_key: input.idempotency_key || null, + request: clone(input.request || {}), + attempt_count: 0, + max_attempts: Math.max(1, Number(input.max_attempts || 3)), + scheduled_at: input.scheduled_at || createdAt, + started_at: null, + finished_at: null, + error: null, + result_ref: null, + result: null, + is_paid: input.is_paid !== false, + created_by: input.created_by || null, + created_at: createdAt, + updated_at: createdAt, + }; + + let queued = job; + if (typeof this.repository?.enqueueJob === "function" && this.persistence.enabled) { + queued = await this.persist(() => this.repository.enqueueJob(job)); + if (!queued) { + throw new HttpError(503, "job_queue_unavailable", "后台任务队列暂不可用,任务未执行。"); + } + } else { + if (this.runtimePolicy.fail_closed) { + throw new HttpError(503, "job_queue_unavailable", "后台任务队列暂不可用,任务未执行。"); + } + this.data.jobs[job.id] = job; + await this.persist(() => this.repository.persistJob(job)); + } + this.data.jobs[queued.id] = queued; + return clone(queued); + } + + async enqueueDossier(companyId, body = {}, options = {}) { + const company = this.requireCompany(companyId); + return this.publicJob(await this.enqueueJob({ + job_type: "sales_dossier_generation", + entity_type: "target_enterprise", + entity_id: company.id, + max_attempts: 3, + idempotency_key: body.idempotency_key || null, + request: { ...body, idempotency_key: undefined }, + created_by: options.created_by || null, + })); + } + + async enqueueMaterialsToOpenViking(companyId, options = {}) { + const company = this.requireCompany(companyId); + const materials = (company.material_ids || []).map((id) => this.data.materials[id]).filter(Boolean); + if (!materials.length) { + return { + status: "skipped", + summary: "当前企业还没有可同步的历史资料。", + records: [], + }; + } + return this.publicJob(await this.enqueueJob({ + job_type: "sales_material_openviking_sync", + entity_type: "target_enterprise", + entity_id: company.id, + max_attempts: 3, + idempotency_key: options.idempotency_key || null, + request: { material_count: materials.length }, + created_by: options.created_by || null, + })); + } + + async activateClaimedJob(input, expectedType) { + const job = clone(input || {}); + if (!job.id || job.status !== "running" || job.job_type !== expectedType || !job.worker_id) { + throw new HttpError(409, "job_claim_invalid", "后台任务领取状态无效,未执行外部调用。"); + } + const reserved = await this.reserveJob(job); + return this.assertJobActive(reserved.id); + } + + async executeQueuedJob(input, options = {}) { + await this.loadPersistedState(); + await this.assertRuntimeReady(); + const job = await this.requireJob(input.id, { refresh: true }); + if (job.status !== "running" || job.worker_id !== options.worker_id) { + throw new HttpError(409, "job_claim_lost", "后台任务领取权已失效。"); + } + await this.assertJobActive(job.id); + const workflowOptions = { + claimed_job: job, + report_progress: options.report_progress, + save_checkpoint: options.save_checkpoint, + }; + if (job.job_type === "sales_dossier_generation") { + return this.createDossier(job.entity_id, job.request || {}, workflowOptions); + } + if (job.job_type === "sales_material_openviking_sync") { + return this.syncMaterialsToOpenViking(job.entity_id, workflowOptions); + } + throw new HttpError(422, "job_type_unsupported", "后台任务类型暂不支持执行。", { + job_type: job.job_type, + }); + } + + async reserveJob(job) { + const reservation = await this.paidWorkflowGuard.reserve(job); + const reservedJob = { + ...reservation.job, + usage_budget: reservation.budget || null, + }; + this.data.jobs[reservedJob.id] = reservedJob; + if (typeof this.repository?.reservePaidWorkflow !== "function") { + await this.persist(() => this.repository.persistJob(reservedJob)); + } + return clone(reservedJob); + } + + async persistTerminalJob(job) { + if (job.is_paid && job.reservation_id) { + await this.paidWorkflowGuard.finish(job); + if (typeof this.repository?.finishPaidWorkflow === "function") return clone(job); + } + await this.persist(() => this.repository.persistJob(job)); + return clone(job); + } + + async completeJob(jobId, input = {}) { + const job = await this.requireJob(jobId, { refresh: true }); + if (["cancelled", "failed"].includes(job.status)) return clone(job); + if (job.cancel_requested_at && this.asyncJobsEnabled && ASYNC_JOB_TYPES.has(job.job_type)) { + await this.acknowledgeJobCancellation(job); + throw new HttpError(409, "job_cancelled", "任务已取消,结果不会继续提交。", { + job_id: job.id, + }); + } + job.status = "succeeded"; + job.finished_at = nowIso(); + job.updated_at = job.finished_at; + job.result_ref = input.result_ref || null; + job.result = input.result || null; + return this.persistTerminalJob(job); + } + + async failJob(jobId, error = {}) { + const job = await this.requireJob(jobId, { refresh: true }); + if (["cancelled", "failed"].includes(job.status)) return clone(job); + if (job.cancel_requested_at && this.asyncJobsEnabled && ASYNC_JOB_TYPES.has(job.job_type)) { + return this.acknowledgeJobCancellation(job); + } + job.status = "failed"; + job.finished_at = nowIso(); + job.updated_at = job.finished_at; + job.error = { + code: String(error.code || "workflow_failed"), + message: String(error.message || "Workflow failed."), + retryable: Boolean(error.retryable), + validation_errors: safeValidationErrors( + error.details?.validation_errors || error.validation_errors, + ), + }; + return this.persistTerminalJob(job); + } + + async assertJobActive(jobId) { + const job = await this.requireJob(jobId, { refresh: true }); + if (job.status === "cancelled" || job.cancel_requested_at) { + if (job.status !== "cancelled") await this.acknowledgeJobCancellation(job); + throw new HttpError(409, "job_cancelled", "任务已取消,后续步骤不会继续执行。", { + job_id: job.id, + }); + } + return job; + } + + async acknowledgeJobCancellation(input) { + const job = typeof input === "string" + ? await this.requireJob(input, { refresh: true }) + : clone(input); + if (job.status === "cancelled") return job; + + if (typeof this.repository?.acknowledgeJobCancellation === "function" + && this.persistence.enabled + && job.worker_id) { + const cancelled = await this.persist( + () => this.repository.acknowledgeJobCancellation(job.id, job.worker_id), + ); + if (cancelled) { + this.data.jobs[cancelled.id] = cancelled; + return clone(cancelled); + } + } + + const cancelledAt = job.cancel_requested_at || nowIso(); + job.status = "cancelled"; + job.stage = "cancelled"; + job.cancel_requested_at = cancelledAt; + job.finished_at = cancelledAt; + job.updated_at = cancelledAt; + job.worker_id = null; + job.lease_expires_at = null; + job.error = null; + this.data.jobs[job.id] = job; + return this.persistTerminalJob(job); + } + + async cancelJob(jobId) { + const job = await this.requireJob(jobId, { refresh: true }); + if (job.status === "cancelled") return clone(job); + if (!["queued", "running"].includes(job.status)) { + throw new HttpError(409, "job_not_cancellable", "只有等待中或执行中的任务可以取消。", { + job_id: job.id, + status: job.status, + }); + } + if (this.asyncJobsEnabled + && ASYNC_JOB_TYPES.has(job.job_type) + && typeof this.repository?.requestJobCancellation === "function" + && this.persistence.enabled) { + const requested = await this.persist(() => this.repository.requestJobCancellation(job.id)); + if (requested) { + this.data.jobs[requested.id] = requested; + return clone(requested); + } + } + const cancelledAt = nowIso(); + job.status = "cancelled"; + job.cancel_requested_at = cancelledAt; + job.finished_at = cancelledAt; + job.updated_at = cancelledAt; + job.error = null; + return this.persistTerminalJob(job); + } + + async retryJob(jobId) { + const job = await this.requireJob(jobId, { refresh: true }); + if (!["failed", "cancelled"].includes(job.status)) { + throw new HttpError(409, "job_not_retryable", "只有失败或已取消的任务可以重试。", { + job_id: job.id, + status: job.status, + }); + } + if (this.asyncJobsEnabled && ASYNC_JOB_TYPES.has(job.job_type)) { + if (Number(job.attempt_count || 0) >= Number(job.max_attempts || 1)) { + throw new HttpError(409, "job_attempts_exhausted", "任务已达到最大执行次数。", { + job_id: job.id, + attempt_count: Number(job.attempt_count || 0), + max_attempts: Number(job.max_attempts || 1), + }); + } + if (typeof this.repository?.retryQueuedJob !== "function") { + throw new HttpError(503, "job_queue_unavailable", "后台任务队列暂不可用,无法重试。"); + } + const queued = await this.persist(() => this.repository.retryQueuedJob(job.id)); + this.data.jobs[queued.id] = queued; + return this.publicJob(queued); + } + if (job.job_type === "sales_dossier_generation") { + return this.createDossier(job.entity_id, job.request || {}, { retry_job_id: job.id }); + } + if (job.job_type === "sales_qa") { + return this.askQuestion(job.entity_id, job.request || {}, { retry_job_id: job.id }); + } + if (job.job_type === "sales_company_search") { + return this.searchCompanies(job.entity_id, job.request || {}, { retry_job_id: job.id }); + } + throw new HttpError(422, "job_retry_unsupported", "该任务类型暂不支持手动重试。", { + job_id: job.id, + job_type: job.job_type, + }); + } + + async listJobs(filters = {}) { + await this.assertRuntimeReady(); + if (typeof this.repository?.listJobs === "function" && this.persistence.enabled) { + return this.repository.listJobs(filters); + } + const requestedLimit = Number(filters.limit || 20); + const limit = Math.max(1, Math.min(Number.isFinite(requestedLimit) ? requestedLimit : 20, 100)); + return Object.values(this.data.jobs || {}) + .filter((job) => !filters.job_type || job.job_type === filters.job_type) + .filter((job) => !filters.status || job.status === filters.status) + .filter((job) => !filters.entity_id || job.entity_id === filters.entity_id) + .sort((a, b) => String(b.created_at || "").localeCompare(String(a.created_at || ""))) + .slice(0, limit) + .map(clone); + } + + async getJob(jobId) { + return clone(await this.requireJob(jobId, { refresh: true })); + } + + async listPublicJobs(filters = {}) { + return (await this.listJobs(filters)).map((job) => this.publicJob(job)); + } + + async getPublicJob(jobId) { + return this.publicJob(await this.requireJob(jobId, { refresh: true })); + } + + async getPaidWorkflowUsage() { + await this.assertRuntimeReady(); + return this.paidWorkflowGuard.snapshot(); + } + + trackProviderStep(runId, input, operation) { + if (!runId) return operation(); + return this.providerRuns.executeStep(runId, input, operation); + } + + async skipProviderStep(runId, input) { + if (!runId) return null; + return this.providerRuns.skipStep(runId, input); + } + + listGoals() { + return this.data.goals.map((goal) => this.goalView(goal)); + } + + exportWorkspaceData() { + const goals = this.data.goals.map((goal) => ({ + ...this.goalView(goal), + target_enterprise_ids: [...new Set(goal.company_ids || [])], + candidate_company_ids: [...new Set(goal.candidate_ids || [])], + })); + const goalIdsByCompany = new Map(); + for (const goal of this.data.goals) { + for (const companyId of goal.company_ids || []) { + const goalIds = goalIdsByCompany.get(companyId) || []; + goalIds.push(goal.id); + goalIdsByCompany.set(companyId, goalIds); + } + } + + const enterprises = Object.values(this.data.companies) + .filter(Boolean) + .sort((left, right) => String(left.name || "").localeCompare(String(right.name || ""), "zh-CN")) + .map((company) => { + const materials = (company.material_ids || []) + .map((id) => this.data.materials[id]) + .filter(Boolean) + .map((material) => ({ + id: material.id, + company_id: company.id, + title: String(material.title || ""), + summary: String(material.summary || ""), + source_type: String(material.source_type || ""), + source_url: publicSourceUrl(material.source_url), + source_id: material.source_id || null, + source_external_id: material.source_external_id || "", + source_version: material.source_version || "", + content_hash: material.content_hash || null, + raw_text: normalizeImportedText(material.text || ""), + source_items: normalizeSourceItems(material.source_items || []), + occurred_at: material.occurred_at || null, + last_synced_at: material.last_synced_at || null, + created_at: material.created_at || null, + updated_at: material.updated_at || material.created_at || null, + })); + const materialSources = this.listMaterialSyncSources(company.id).map((source) => ({ + id: source.id, + source_type: source.source_type, + external_id: source.external_id, + display_name: source.display_name, + status: source.status, + material_ids: source.material_ids, + last_synced_at: source.last_synced_at, + updated_at: source.updated_at, + checkpoint: source.checkpoint ? { + checkpoint_key: source.checkpoint.checkpoint_key, + checkpoint_value: source.checkpoint.checkpoint_value, + last_success_at: source.checkpoint.last_success_at, + updated_at: source.checkpoint.updated_at, + } : null, + })); + return { + ...this.companyView(company, { in_pool: (goalIdsByCompany.get(company.id) || []).length > 0 }), + goal_ids: goalIdsByCompany.get(company.id) || [], + progress_detail: this.progressView(company), + dossiers: (company.dossier_ids || []) + .map((id) => this.data.dossiers[id]) + .filter(Boolean) + .map((dossier) => this.publicDossier(dossier)), + materials, + material_sources: materialSources, + qa: this.cachedQa(company.id), + }; + }); + + return { + format: "sales-intelligence-workbench-workspace-export", + format_version: 1, + exported_at: nowIso(), + scope: "single_workspace", + contains_private_business_data: true, + goals, + enterprises, + }; + } + + async createGoal(body = {}) { + const name = String(body.name || "").trim(); + if (!name) throw new HttpError(400, "bad_request", "销售目标名称不能为空。"); + const now = nowIso(); + const goal = { + id: makeId("sales_goal"), + name, + description: String(body.description || "新的销售目标,等待查找并加入目标企业。").trim(), + keywords: Array.isArray(body.keywords) ? body.keywords.map((item) => String(item).trim()).filter(Boolean) : [], + company_ids: [], + candidate_ids: [], + created_at: now, + updated_at: now, + }; + this.data.goals.unshift(goal); + await this.persist(() => this.repository.persistSalesGoal(goal)); + return this.goalView(goal); + } + + getGoal(goalId) { + const goal = this.data.goals.find((item) => item.id === goalId); + if (!goal) throw new HttpError(404, "sales_goal_not_found", "销售目标不存在。", { goal_id: goalId }); + return goal; + } + + goalView(goal) { + const companies = (goal.company_ids || []).map((id) => this.data.companies[id]).filter(Boolean); + return { + id: goal.id, + name: goal.name, + description: String(goal.description || "").replace(/^新建/, "新的"), + stats: `${companies.length} 家企业`, + keywords: goal.keywords || [], + created_at: goal.created_at, + updated_at: goal.updated_at, + }; + } + + companyView(company, options = {}) { + if (!company) return null; + const progress = company.progress || {}; + const progressFallback = "当前资料不足,需要补充最新档案或历史沟通资料。"; + const status = progress.label || options.status || "新商机"; + return { + id: company.id, + name: company.name, + initial: company.initial || normalizeInitial(company.name), + industry: company.industry || "企业", + location: company.location || "", + tags: company.tags || [company.industry, company.location].filter(Boolean), + status, + progress: conciseProgressSummary(status, businessText(progress.summary, progressFallback)), + evidence: businessText(progress.evidence, "依据:当前企业档案", 180), + progress_level: progressLevel(status), + updated_at: progress.updated_at || company.updated_at || null, + identity_status: company.identity_status || "unverified", + unified_social_credit_code: company.unified_social_credit_code || "", + legal_representative: company.legal_representative || "", + registered_capital: company.registered_capital || "", + business_status: company.business_status || "", + registered_address: company.registered_address || "", + established_at: company.established_at || "", + professional_verified_at: company.professional_verified_at || null, + in_pool: Boolean(options.in_pool), + reason: options.reason || "", + }; + } + + listTargetEnterprises(goalId) { + const goal = this.getGoal(goalId); + return (goal.company_ids || []).map((id) => this.companyView(this.data.companies[id], { in_pool: true })).filter(Boolean); + } + + async searchCompanies(goalId, body = {}, options = {}) { + const goal = this.getGoal(goalId); + const query = String(body.query || "").trim(); + if (!query) return []; + const job = await this.startJob({ + job_type: "sales_company_search", + entity_type: "sales_goal", + entity_id: goal.id, + max_attempts: 3, + request: { query }, + retry_job_id: options.retry_job_id || "", + }); + let run = null; + try { + run = await this.providerRuns.startRun({ + operation: "sales_company_search", + entity_type: "sales_goal", + entity_id: goal.id, + job_id: job.id, + }); + const searchText = query; + const localCandidates = this.localCompanySearch(goal, searchText); + const realEvidence = await this.collectSearchEvidence(searchText, run.id); + await this.assertJobActive(job.id); + const professionalCandidates = await this.professionalCompaniesFromEvidence(realEvidence); + const candidates = [...new Map( + [...professionalCandidates, ...localCandidates].map((company) => [company.id, company]), + ).values()].slice(0, 8); + + if (!candidates.length && query) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("datapro", "Professional data did not return an identifiable company entity.", { + reason: "company_identity_unavailable", + raw_ref: realEvidence.professional?.raw_ref || null, + }); + } + const company = await this.createCompanyFromQuery(query, realEvidence); + goal.candidate_ids = [company.id, ...(goal.candidate_ids || []).filter((id) => id !== company.id)]; + const results = [this.companyView(company, { + reason: "未识别到可核验企业主体,已保留为待确认候选。", + in_pool: goal.company_ids.includes(company.id), + provider_run_id: run.id, + job_id: job.id, + })]; + results[0].provider_run_id = run.id; + results[0].job_id = job.id; + await this.persist(() => this.repository.persistSalesGoal(goal)); + await this.persist(() => this.repository.persistSalesSearchResults(goal.id, query, results)); + await this.providerRuns.completeRun(run.id, { result_ref: `sales_search:${goal.id}:${results.length}` }); + await this.completeJob(job.id, { + result_ref: `sales_search:${goal.id}:${results.length}`, + result: { candidate_ids: results.map((item) => item.id) }, + }); + return results; + } + + goal.candidate_ids = [...new Set(candidates.map((item) => item.id))]; + const results = candidates.map((company) => ({ + ...this.companyView(company, { + reason: company.identity_status === "verified" + ? realEvidence.public_sources.length + ? "专业数据集已核验该企业主体,并已补充公开来源。" + : "专业数据集已核验该企业主体;联网公开信息暂不可用,可先加入企业池。" + : realEvidence.summary || "与当前销售目标关键词匹配。", + in_pool: goal.company_ids.includes(company.id), + }), + warnings: [...realEvidence.issues], + provider_run_id: run.id, + job_id: job.id, + })); + await this.persist(() => this.repository.persistSalesGoal(goal)); + await this.persist(() => this.repository.persistSalesSearchResults(goal.id, query, results)); + await this.providerRuns.completeRun(run.id, { result_ref: `sales_search:${goal.id}:${results.length}` }); + await this.completeJob(job.id, { + result_ref: `sales_search:${goal.id}:${results.length}`, + result: { candidate_ids: results.map((item) => item.id) }, + }); + return results; + } catch (error) { + if (run) { + try { + if (error.code === "job_cancelled") { + await this.providerRuns.cancelRun(run.id, { summary: "企业搜索任务已由用户取消。" }); + } else { + await this.providerRuns.failRun(run.id, { + code: error.code || "company_search_failed", + message: error.message || "Company search failed.", + category: error.category || "workflow", + retryable: error.retryable, + }); + } + } catch (persistenceError) { + if (this.runtimePolicy.fail_closed) throw persistenceError; + } + } + await this.failJob(job.id, error); + throw error; + } + } + + defaultCandidateIds(goal) { + return [...(goal.candidate_ids || [])]; + } + + defaultCandidateCompanies(goal) { + return this.defaultCandidateIds(goal).map((id) => this.data.companies[id]).filter(Boolean); + } + + localCompanySearch(goal, query) { + const text = String(query || "").toLowerCase(); + const ids = goal.candidate_ids || []; + const fromGoal = ids.map((id) => this.data.companies[id]).filter(Boolean); + const allCompanies = Object.values(this.data.companies); + const searched = allCompanies.filter((company) => { + const haystack = [company.name, company.industry, company.location, ...(company.tags || [])].join(" ").toLowerCase(); + return text ? haystack.includes(text) || text.split(/\s+/).some((part) => part && haystack.includes(part)) : ids.includes(company.id); + }); + if (text && searched.length) return searched.slice(0, 8); + return [...new Map([...searched, ...fromGoal].map((company) => [company.id, company])).values()].slice(0, 8); + } + + async professionalCompaniesFromEvidence(evidence = {}) { + const source = evidence.professional; + if (!source || source.ok === false) return []; + const parsedItems = collectDataProCompanyItems(source.parsed); + const summaryItem = parsedItems.length ? null : companyItemFromDataProSummary(source.summary || source.text); + const items = summaryItem ? [summaryItem] : parsedItems; + const companies = []; + for (const item of items.slice(0, 5)) { + const company = await this.upsertProfessionalCompany(item, source, { + search_alias: evidence.search_query || "", + }); + if (company) companies.push(company); + } + return [...new Map(companies.map((company) => [company.id, company])).values()]; + } + + async upsertProfessionalCompany(item, source = {}, options = {}) { + const name = dataProField(item, dataProCompanyFields.name, 180); + if (!name) return null; + const unifiedSocialCreditCode = dataProField(item, dataProCompanyFields.unified_social_credit_code, 80); + const normalizedName = normalizedCompanyIdentity(name); + const existing = Object.values(this.data.companies).find((company) => { + if (unifiedSocialCreditCode && company.unified_social_credit_code === unifiedSocialCreditCode) return true; + return normalizedCompanyIdentity(company.name) === normalizedName; + }); + const identity = unifiedSocialCreditCode || normalizedName; + if (!identity) return null; + + const now = nowIso(); + const address = dataProField(item, dataProCompanyFields.address, 500); + const industry = dataProField(item, dataProCompanyFields.industry, 120) || existing?.industry || "待确认行业"; + const location = compactCompanyLocation(item, address) || existing?.location || ""; + const tags = [...new Set([ + ...(existing?.tags || []), + industry === "待确认行业" ? "" : industry, + location, + "专业数据集已核验", + ].filter(Boolean))].slice(0, 8); + const id = existing?.id || stableProfessionalCompanyId(identity); + const searchAlias = companySearchAlias(options.search_alias, name); + const company = { + ...(existing || {}), + id, + name, + initial: normalizeInitial(name), + industry, + location, + tags, + aliases: [...new Set([ + ...(existing?.aliases || []), + existing?.name, + name, + searchAlias, + parentheticalBrandAlias(name), + ].filter(Boolean))], + unified_social_credit_code: unifiedSocialCreditCode || existing?.unified_social_credit_code || "", + legal_representative: dataProField(item, dataProCompanyFields.legal_representative, 120) || existing?.legal_representative || "", + registered_capital: dataProField(item, dataProCompanyFields.registered_capital, 120) || existing?.registered_capital || "", + business_status: dataProField(item, dataProCompanyFields.business_status, 120) || existing?.business_status || "", + registered_address: address || existing?.registered_address || "", + established_at: dataProField(item, dataProCompanyFields.established_at, 80) || existing?.established_at || "", + business_scope: dataProField(item, dataProCompanyFields.business_scope, 1200) || existing?.business_scope || "", + identity_status: "verified", + data_origin: "datapro", + professional_source_ref: source.raw_ref || source.request_id || existing?.professional_source_ref || null, + professional_verified_at: now, + progress: existing?.progress || { + label: "新商机", + summary: "企业主体已通过专业数据集核验,待生成最新档案。", + evidence: "依据:专业数据集", + updated_at: now, + }, + dossier_ids: existing?.dossier_ids || [], + material_ids: existing?.material_ids || [], + qa_session_id: existing?.qa_session_id || `sales-${id}`, + created_at: existing?.created_at || now, + updated_at: now, + }; + this.data.companies[id] = company; + this.data.qa_messages[id] = this.data.qa_messages[id] || []; + await this.persist(() => this.repository.persistSalesCompany(company)); + return company; + } + + async createCompanyFromQuery(query, evidence = {}) { + const normalizedQuery = normalizedCompanyIdentity(query); + const existing = Object.values(this.data.companies) + .find((company) => normalizedCompanyIdentity(company.name) === normalizedQuery); + if (existing) return existing; + const now = nowIso(); + const id = makeId("company"); + const company = { + id, + name: query, + initial: normalizeInitial(query), + industry: "待确认行业", + location: "", + tags: ["待确认"], + identity_status: "unverified", + data_origin: "user_input", + progress: { + label: "新商机", + summary: evidence.summary || "已创建目标企业,等待获取最新档案和历史资料。", + evidence: "依据:用户输入", + updated_at: now, + }, + dossier_ids: [], + material_ids: [], + qa_session_id: `sales-${id}`, + created_at: now, + updated_at: now, + }; + this.data.companies[id] = company; + this.data.qa_messages[id] = []; + await this.persist(() => this.repository.persistSalesCompany(company)); + return company; + } + + async collectSearchEvidence(query, providerRunId = "") { + const result = { + search_query: String(query || "").trim(), + summary: "", + professional: null, + public_sources: [], + issues: [], + }; + if (!query) return result; + + if (this.dataProProvider?.isRunEnabled?.()) { + try { + const dataPro = await this.trackProviderStep(providerRunId, { + provider: "datapro", + operation: "search_company_professional_data", + input_summary: `查询 ${query} 的企业主体信息`, + output_summary: "已完成企业主体查询。", + }, () => this.dataProProvider.callTool(`${query} 企业工商信息 招投标 公告`)); + if (dataPro.ok) { + result.professional = dataPro; + result.summary = "已调用专业数据集补充企业候选依据。"; + } else { + result.issues.push(`专业数据集暂时不可用:${dataPro.error?.code || "provider_error"}`); + } + } catch (error) { + result.issues.push(`专业数据集暂时不可用:${error.message}`); + } + } else { + await this.skipProviderStep(providerRunId, { + provider: "datapro", + operation: "search_company_professional_data", + input_summary: `查询 ${query} 的企业主体信息`, + output_summary: "DataPro 未启用。", + error: { code: "provider_disabled", message: "DataPro is not enabled." }, + }); + } + + if (this.webSearchProvider?.isRunEnabled?.()) { + try { + const web = await this.trackProviderStep(providerRunId, { + provider: "web_search", + operation: "search_company_public_sources", + input_summary: `检索 ${query} 的公开信息`, + output_summary: "已完成候选企业公开信息检索。", + }, () => this.webSearchProvider.search({ query: `${query} 公司 公告 新闻`.slice(0, 100), count: 3, need_summary: true })); + if (web.ok) { + result.public_sources = web.results || []; + result.summary = result.summary || "已调用联网搜索补充公开信息。"; + } else { + result.issues.push(`联网搜索暂时不可用:${web.error?.code || "provider_error"}`); + } + } catch (error) { + result.issues.push(`联网搜索暂时不可用:${error.message}`); + } + } else { + await this.skipProviderStep(providerRunId, { + provider: "web_search", + operation: "search_company_public_sources", + input_summary: `检索 ${query} 的公开信息`, + output_summary: "联网搜索未启用。", + error: { code: "provider_disabled", message: "Web search is not enabled." }, + }); + } + + if (this.runtimePolicy.fail_closed && !result.professional) { + throw providerUnavailable("datapro", "No verified professional-data result was returned.", { + issues: result.issues, + }); + } + return result; + } + + async addTargetEnterprise(goalId, body = {}) { + const goal = this.getGoal(goalId); + let companyId = String(body.company_id || "").trim(); + if (!companyId && body.company?.name) { + companyId = (await this.createCompanyFromQuery(body.company.name)).id; + } + if (!companyId) throw new HttpError(400, "bad_request", "company_id 不能为空。"); + const company = this.data.companies[companyId]; + if (!company) throw new HttpError(404, "company_not_found", "企业不存在。", { company_id: companyId }); + if (!goal.company_ids.includes(companyId)) goal.company_ids.push(companyId); + goal.updated_at = nowIso(); + await this.persist(() => this.repository.persistSalesGoal(goal)); + await this.persist(() => this.repository.persistSalesTargetEnterprise(goal.id, company)); + return this.enterpriseDetail(companyId, { goal_id: goalId }); + } + + requireCompany(companyId) { + const company = this.data.companies[companyId]; + if (!company) throw new HttpError(404, "company_not_found", "企业不存在。", { company_id: companyId }); + return company; + } + + async enterpriseDetail(companyId, options = {}) { + const company = this.requireCompany(companyId); + return { + ...this.companyView(company, { in_pool: true }), + goal_id: options.goal_id || null, + progress_detail: this.progressView(company), + dossiers: this.listDossiers(companyId), + materials: this.listMaterials(companyId), + qa: await this.getQa(companyId), + }; + } + + progressView(companyOrId) { + const company = typeof companyOrId === "string" ? this.requireCompany(companyOrId) : companyOrId; + const progress = company.progress || {}; + const label = progress.label || "新商机"; + return { + label, + summary: conciseProgressSummary(label, businessText(progress.summary, "暂未形成明确进展。")), + evidence: businessText(progress.evidence, "依据:当前企业档案", 180), + updated_at: progress.updated_at || null, + }; + } + + listDossiers(companyId) { + const company = this.requireCompany(companyId); + return (company.dossier_ids || []) + .map((id) => this.data.dossiers[id]) + .filter(Boolean) + .filter(isDisplayableDossier) + .map((dossier) => ({ + dossier, + publicView: this.publicDossier(dossier), + })) + .filter(({ publicView }) => ( + !this.runtimePolicy.fail_closed + || !this.publicDossierQualityErrors(publicView, company).length + )) + .sort((a, b) => String(b.dossier.created_at).localeCompare(String(a.dossier.created_at))) + .map(({ dossier, publicView }) => ({ + id: publicView.id, + company_id: publicView.company_id, + title: publicView.title, + summary: publicView.summary, + version_no: Number(publicView.version_no || 1), + previous_dossier_id: publicView.previous_dossier_id || null, + change_status: publicView.change_status || "initial", + data_as_of: publicView.data_as_of ?? null, + generated_at: publicView.generated_at || dossier.created_at, + created_at: publicView.created_at, + })); + } + + dossierDetail(dossierId) { + const dossier = this.data.dossiers[dossierId]; + if (!dossier) throw new HttpError(404, "dossier_not_found", "档案不存在。", { dossier_id: dossierId }); + const publicView = this.publicDossier(dossier); + const company = this.data.companies[dossier.company_id] || { + name: String(dossier.title || "").replace(/\s*(?:最近档案|销售情报报告).*/, ""), + aliases: [], + }; + if ( + this.runtimePolicy.fail_closed + && this.publicDossierQualityErrors(publicView, company).length + ) { + throw new HttpError(404, "dossier_not_found", "档案不存在。", { dossier_id: dossierId }); + } + return publicView; + } + + publicDossier(dossier) { + const summary = normalizeSalesText( + businessText(dossier.summary, "这份档案需要重新获取最新资料后再展示。", 300), + ); + const storedCompany = this.data.companies[dossier.company_id]; + const companyName = cleanEvidenceSummary( + storedCompany?.name + || String(dossier.title || "").replace(/\s*(?:最近档案|销售情报报告).*/, ""), + "目标企业", + 80, + ); + const company = storedCompany || { name: companyName, aliases: [] }; + const storedCitations = firstJsonArray(dossier.citations); + const storedCitationIds = new Set(storedCitations.map((citation) => String(citation.id))); + const evidencePackCitationCandidates = evidencePackCitations({ + items: firstJsonArray(dossier.evidence_pack), + }).filter((citation) => !storedCitationIds.has(String(citation.id))); + const keptCitations = [...storedCitations, ...evidencePackCitationCandidates] + .filter((citation) => isDisplayableDossierCitation(citation, company)) + .sort((a, b) => citationRank(a) - citationRank(b)); + const citationIdMap = new Map(); + const keptCitationIds = new Set(keptCitations.map((citation) => String(citation.id))); + const bodySectionsWithRemovedCitation = new Set( + firstJsonArray(dossier.body) + .map((paragraph, index) => ( + firstJsonArray(paragraph.citation_ids).some((id) => !keptCitationIds.has(String(id))) + ? index + : -1 + )) + .filter((index) => index >= 0), + ); + const citations = keptCitations.map((citation, index) => { + const id = String(index + 1); + citationIdMap.set(String(citation.id), id); + return publicCitationView(citation, id); + }); + const validationCitations = keptCitations.map((citation, index) => ({ + ...citation, + id: String(index + 1), + })); + const body = firstJsonArray(dossier.body).map((paragraph) => ({ + text: normalizeSalesText(compactCompleteSentences( + businessText(paragraph.text, summary, 1400), + 1400, + )), + citation_ids: firstJsonArray(paragraph.citation_ids) + .map((id) => citationIdMap.get(String(id))) + .filter(Boolean), + segments: firstJsonArray(paragraph.segments).map((segment) => ({ + text: normalizeSalesText(compactCompleteSentences( + businessText(segment.text, "", 1400), + 1200, + )), + citation_ids: firstJsonArray(segment.citation_ids) + .map((id) => citationIdMap.get(String(id))) + .filter(Boolean), + })).filter((segment) => segment.text), + })); + const publicView = { + id: dossier.id, + company_id: dossier.company_id, + title: `${companyName} 销售情报报告`, + summary, + body: [], + citations, + version_no: Number(dossier.version_no || 1), + previous_dossier_id: dossier.previous_dossier_id || null, + change_status: dossier.change_status || "initial", + data_as_of: dossier.data_as_of ?? null, + generated_at: dossier.generated_at || dossier.created_at || null, + created_at: dossier.created_at || null, + updated_at: dossier.updated_at || dossier.created_at || null, + }; + publicView.body = bodySectionsWithRemovedCitation.size + ? [] + : this.fixedPublicDossierBody(publicView, body, company, validationCitations); + if (!publicView.body.length) { + publicView.summary = ""; + publicView.citations = []; + return publicView; + } + const usedCitationIds = new Set( + publicView.body.flatMap((paragraph) => firstJsonArray(paragraph.citation_ids).map(String)), + ); + const usedCitations = citations.filter((citation) => usedCitationIds.has(String(citation.id))); + const finalCitationIdMap = new Map( + usedCitations.map((citation, index) => [String(citation.id), String(index + 1)]), + ); + publicView.citations = usedCitations.map((citation, index) => ({ + ...citation, + id: String(index + 1), + })); + Object.defineProperty(publicView, "_validation_citations", { + configurable: false, + enumerable: false, + writable: false, + value: usedCitations.map((citation, index) => ({ + ...citation, + id: String(index + 1), + })), + }); + publicView.body = publicView.body.map((paragraph) => ({ + ...paragraph, + citation_ids: [...new Set( + firstJsonArray(paragraph.citation_ids) + .map((id) => finalCitationIdMap.get(String(id))) + .filter(Boolean), + )], + segments: firstJsonArray(paragraph.segments).map((segment) => ({ + ...segment, + citation_ids: [...new Set( + firstJsonArray(segment.citation_ids) + .map((id) => finalCitationIdMap.get(String(id))) + .filter(Boolean), + )], + })).filter((segment) => segment.text && segment.citation_ids.length), + })); + publicView.data_as_of = deriveEvidenceDataAsOf( + publicView.citations, + publicView.generated_at || new Date().toISOString(), + ); + const groundedSummary = [ + publicView.body.find((item) => item.text.startsWith("近期公开动态:"))?.text.replace(/^近期公开动态:/, ""), + publicView.body.find((item) => item.text.startsWith("销售机会判断:"))?.text.replace(/^销售机会判断:/, ""), + ].filter(Boolean).join(" "); + publicView.summary = compactCompleteSentences( + isSubstantiveDossierSummary(groundedSummary) ? groundedSummary : publicView.summary, + 300, + ); + return publicView; + } + + publicDossierQualityErrors(publicView, company) { + const validationCitations = publicView?._validation_citations || publicView?.citations || []; + const validated = validateDossierModelAnswer(publicView, validationCitations); + return [ + ...validated.errors, + ...(this.runtimePolicy.fail_closed + ? dossierGroundingErrors(validationCitations, validated.body, company) + : []), + ...dossierSectionSourceErrors(validated.body, validationCitations, company), + ...dossierSectionContentErrors(validated.body), + ...dossierSectionSemanticErrors(validated.body, validationCitations, company), + ...dossierSectionEvidenceGroundingErrors(validated.body, validationCitations), + ]; + } + + async createDossier(companyId, body = {}, options = {}) { + const company = this.requireCompany(companyId); + const reportProgress = typeof options.report_progress === "function" + ? options.report_progress + : async () => {}; + const saveCheckpoint = typeof options.save_checkpoint === "function" + ? options.save_checkpoint + : async () => null; + const job = options.claimed_job + ? await this.activateClaimedJob(options.claimed_job, "sales_dossier_generation") + : await this.startJob({ + job_type: "sales_dossier_generation", + entity_type: "target_enterprise", + entity_id: company.id, + max_attempts: 3, + request: body, + retry_job_id: options.retry_job_id || "", + }); + let dossierCheckpoint = reusableDossierCheckpoint( + job.checkpoint?.dossier, + company.id, + this.dossierCheckpointTtlMs, + ) || { + schema_version: 1, + company_id: company.id, + created_at: nowIso(), + }; + const persistDossierCheckpoint = async (patch = {}, progressOptions = {}) => { + dossierCheckpoint = { + ...dossierCheckpoint, + ...clone(patch), + schema_version: 1, + company_id: company.id, + updated_at: nowIso(), + }; + await saveCheckpoint( + { dossier: dossierCheckpoint }, + progressOptions, + ); + return dossierCheckpoint; + }; + let run = null; + + try { + run = await this.providerRuns.startRun({ + operation: "sales_dossier_generation", + entity_type: "target_enterprise", + entity_id: company.id, + job_id: job.id, + }); + const generatedAt = nowIso(); + let evidencePack = objectValue(dossierCheckpoint.evidence_pack); + if (Array.isArray(evidencePack.items) && evidencePack.evidence_hash) { + await reportProgress("building_evidence", 50); + await this.skipProviderStep(run.id, { + provider: "rule", + operation: "resume_evidence_checkpoint", + input_summary: `恢复 ${company.name} 当前任务中已经完成的资料采集`, + output_summary: `已从任务检查点恢复 ${evidencePack.items.length} 条资料,未重复调用上游服务。`, + }); + evidencePack = clone(evidencePack); + } else { + const collected = await this.collectDossierEvidence(company, run.id, { + checkpoint: dossierCheckpoint.evidence_collection, + report_progress: reportProgress, + save_checkpoint: async (collection, progressOptions = {}) => persistDossierCheckpoint( + { evidence_collection: collection }, + progressOptions, + ), + }); + await this.assertJobActive(job.id); + await reportProgress("building_evidence", 50); + const packed = await this.trackProviderStep(run.id, { + provider: "rule", + operation: "build_evidence_pack", + input_summary: `校验 ${company.name} 的专业数据集与豆包搜索来源,并计算稳定证据哈希`, + }, async () => { + const builtEvidencePack = buildDossierEvidencePack({ + company, + collected, + memoryContexts: [], + generatedAt, + }); + return { + ok: true, + provider: "rule", + provider_mode: "local", + evidence_pack: builtEvidencePack, + summary: `证据包保留 ${builtEvidencePack.items.length} 条,拒绝 ${builtEvidencePack.rejected.length} 条不满足主体或内容质量门禁的来源。`, + }; + }); + evidencePack = packed.evidence_pack; + await persistDossierCheckpoint( + { + collected_at: evidencePack.collected_at, + evidence_pack: evidencePack, + }, + { + stage: "validating_evidence", + progress: 56, + detail: { message: "正在校验资料与企业主体" }, + }, + ); + } + await this.assertJobActive(job.id); + await reportProgress("validating_evidence", 56); + if (this.runtimePolicy.fail_closed) { + const evidenceValidation = validateProductionEvidencePack(evidencePack); + if (!evidenceValidation.ok) { + throw new HttpError(422, "evidence_quality_insufficient", "现有来源不足以生成可对外使用的最新档案。", { + validation_errors: evidenceValidation.errors, + evidence_policy: evidenceValidation.policy, + }); + } + } + await this.assertJobActive(job.id); + const storedDossiers = (company.dossier_ids || []) + .map((id) => this.data.dossiers[id]) + .filter(Boolean) + .sort((a, b) => Number(b.version_no || 1) - Number(a.version_no || 1) + || String(b.created_at || "").localeCompare(String(a.created_at || ""))); + const latestDossier = storedDossiers + .filter(isDisplayableDossier) + .find((item) => ( + !this.runtimePolicy.fail_closed + || !this.publicDossierQualityErrors(this.publicDossier(item), company).length + )) || null; + const nextVersionNo = Math.max( + 0, + ...storedDossiers.map((item) => Number(item.version_no || 0)).filter(Number.isFinite), + ) + 1; + + const currentCitationInputs = this.buildCitationInputs(evidencePack, []); + const currentSourcePolicy = dossierSectionSourcePolicy(currentCitationInputs, company); + const currentTargetName = String(company.name || company.legal_name || "").trim(); + const currentTargetEntityKey = normalizeLegalEntityName(currentTargetName); + const currentAgentContext = buildDossierAgentContext({ + citations: currentCitationInputs, + evidencePolicy: evidencePack.policy || null, + evidenceConflicts: evidencePack.conflicts || [], + sourceSelectionPolicy: { + business_database_ids: [...currentSourcePolicy.business], + business_dynamics_ids: [...currentSourcePolicy.businessDynamics], + risk_database_ids: [...currentSourcePolicy.risk], + market_database_ids: [...currentSourcePolicy.market], + professional_dataset_ids: [...currentSourcePolicy.professional], + web_search_ids: [...currentSourcePolicy.web], + excluded_entity_citation_ids: currentCitationInputs + .map((citation) => ({ citation, record: dossierBusinessEntityRecord(citation) })) + .filter(({ record }) => ( + record + && normalizeLegalEntityName(record.name) !== currentTargetEntityKey + )) + .map(({ citation }) => String(citation.id)), + }, + }); + const latestDossierValidation = latestDossier + ? validateDossierModelAnswer(latestDossier, currentCitationInputs) + : { body: [], errors: ["没有可复用的历史档案"] }; + const latestDossierQualityErrors = latestDossier + ? [ + ...latestDossierValidation.errors, + ...dossierSectionSourceErrors(latestDossierValidation.body, currentCitationInputs, company), + ...dossierSourceUsageErrors( + latestDossierValidation.body.flatMap((paragraph) => paragraph.citation_ids || []), + currentAgentContext.citations, + currentAgentContext.sourceUsageRequirements, + "现有档案", + ), + ] + : latestDossierValidation.errors; + if ( + latestDossier?.evidence_hash + && latestDossier.evidence_hash === evidencePack.evidence_hash + && latestDossierQualityErrors.length === 0 + ) { + await this.skipProviderStep(run.id, { + provider: "model", + operation: "generate_sales_dossier", + input_summary: `检查 ${company.name} 是否需要生成新版本`, + output_summary: "证据内容未变化,未重复调用模型。", + }); + await this.providerRuns.completeRun(run.id, { result_ref: `dossier:${latestDossier.id}:unchanged` }); + await reportProgress("persisting_result", 95); + await this.completeJob(job.id, { + result_ref: `dossier:${latestDossier.id}:unchanged`, + result: { action: "no_material_change", dossier_id: latestDossier.id }, + }); + return { + action: "no_material_change", + checked_at: generatedAt, + record: this.listDossiers(companyId).find((item) => item.id === latestDossier.id), + detail: this.dossierDetail(latestDossier.id), + progress: this.progressView(company), + memory_record: null, + provider_run_id: run.id, + job_id: job.id, + }; + } + + await reportProgress("generating_dossier", 68); + const modelDossier = await this.generateDossierWithModel(company, evidencePack, [], run.id); + await this.assertJobActive(job.id); + await reportProgress("validating_dossier", 86); + let dossier = modelDossier; + if (!dossier) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("model", "The model did not return a publishable dossier.", { + reason: "dossier_quality_gate_failed", + validation_errors: ["模型结果未通过正文、引用或展示质量门禁,未保存规则兜底档案。"], + }); + } + const ruleResult = await this.trackProviderStep(run.id, { + provider: "rule", + operation: "build_dossier_fallback", + input_summary: `为 ${company.name} 生成证据不足时的明确说明`, + output_summary: "已生成不冒充模型结果的规则档案。", + }, async () => ({ + ok: true, + provider: "rule", + provider_mode: "mixed", + dossier: this.buildRuleDossier(company, evidencePack, []), + })); + dossier = ruleResult.dossier; + } + + dossier.provider_run_id = run.id; + dossier.version_no = nextVersionNo; + dossier.previous_dossier_id = latestDossier?.id || null; + dossier.evidence_hash = evidencePack.evidence_hash; + dossier.change_status = latestDossier ? "changed" : "initial"; + dossier.data_as_of = evidencePack.data_as_of; + dossier.generated_at = generatedAt; + dossier.evidence_pack = evidencePack.items; + const usedCitationIds = new Set( + firstJsonArray(dossier.body) + .flatMap((paragraph) => firstJsonArray(paragraph?.citation_ids).map(String)), + ); + dossier.citations = firstJsonArray(dossier.citations) + .filter((citation) => usedCitationIds.has(String(citation?.id || ""))); + dossier.data_as_of = deriveEvidenceDataAsOf(dossier.citations, generatedAt); + const dossierGroundingValidationErrors = dossierGroundingErrors( + dossier.citations, + dossier.body, + company, + ); + if (this.runtimePolicy.fail_closed && dossierGroundingValidationErrors.length) { + throw providerUnavailable("model", "The dossier did not cite a verified legal-entity anchor.", { + reason: "public_dossier_quality_gate_failed", + validation_errors: dossierGroundingValidationErrors, + }); + } + const finalPublicView = this.publicDossier(dossier); + const finalPublicViewErrors = this.publicDossierQualityErrors(finalPublicView, company); + if (this.runtimePolicy.fail_closed && finalPublicViewErrors.length) { + throw providerUnavailable("model", "The dossier failed the final pre-persistence public-view quality gate.", { + reason: "public_dossier_quality_gate_failed", + validation_errors: finalPublicViewErrors, + }); + } + dossier.dossier_fingerprint = makeDossierFingerprint(dossier); + if ( + latestDossier + && makeDossierFingerprint(latestDossier) === dossier.dossier_fingerprint + ) { + await this.providerRuns.completeRun(run.id, { + result_ref: `dossier:${latestDossier.id}:same_report`, + }); + await reportProgress("persisting_result", 95); + await this.completeJob(job.id, { + result_ref: `dossier:${latestDossier.id}:same_report`, + result: { action: "no_report_change", dossier_id: latestDossier.id }, + }); + return { + action: "no_report_change", + checked_at: generatedAt, + record: this.listDossiers(companyId).find((item) => item.id === latestDossier.id), + detail: this.dossierDetail(latestDossier.id), + progress: this.progressView(company), + memory_record: null, + provider_run_id: run.id, + job_id: job.id, + }; + } + + const nextCompany = { + ...company, + dossier_ids: [dossier.id, ...(company.dossier_ids || []).filter((id) => id !== dossier.id)], + progress: this.progressFromDossier(company, dossier), + updated_at: nowIso(), + }; + await this.skipProviderStep(run.id, { + provider: "openviking", + operation: "store_dossier_memory", + input_summary: `判断 ${company.name} 的档案应由哪一层保存`, + output_summary: "档案属于结构化业务记录,由 Supabase 保存,不重复写入 OpenViking。", + }); + const memoryRecord = null; + + if (this.persistence.enabled && this.repository) { + await reportProgress("persisting_result", 90); + await this.trackProviderStep(run.id, { + provider: "supabase", + operation: "persist_dossier", + input_summary: `保存 ${company.name} 的档案、进度和外部引用`, + output_summary: "档案及关联状态已持久化。", + }, async () => { + await this.persist(() => this.repository.persistSalesCompany(nextCompany)); + await this.persist(() => this.repository.persistSalesDossier(dossier)); + return { ok: true, provider: "supabase", provider_mode: "real" }; + }); + } else { + await this.skipProviderStep(run.id, { + provider: "supabase", + operation: "persist_dossier", + input_summary: `保存 ${company.name} 的档案和进度`, + output_summary: "当前配置未启用持久化仓库。", + error: { code: "repository_disabled", message: "Persistent repository is not enabled." }, + }); + } + + this.data.dossiers[dossier.id] = dossier; + this.data.companies[company.id] = nextCompany; + await this.providerRuns.completeRun(run.id, { result_ref: `dossier:${dossier.id}` }); + await this.completeJob(job.id, { + result_ref: `dossier:${dossier.id}`, + result: { action: "created", dossier_id: dossier.id, version_no: dossier.version_no }, + }); + return { + action: "created", + record: this.listDossiers(companyId)[0], + detail: this.dossierDetail(dossier.id), + progress: this.progressView(nextCompany), + memory_record: memoryRecord, + provider_run_id: run.id, + job_id: job.id, + }; + } catch (error) { + if (run) { + try { + if (error.code === "job_cancelled") { + await this.providerRuns.cancelRun(run.id, { summary: "档案生成任务已由用户取消。" }); + } else { + await this.providerRuns.failRun(run.id, { + code: error.code || "dossier_generation_failed", + message: error.message || "Dossier generation failed.", + category: error.category || "workflow", + retryable: error.retryable, + details: { + validation_errors: safeValidationErrors(error.details?.validation_errors), + }, + }); + } + } catch (persistenceError) { + if (this.runtimePolicy.fail_closed) throw persistenceError; + } + } + if (!options.claimed_job) await this.failJob(job.id, error); + throw error; + } + } + + async collectDossierEvidence(company, providerRunId = "", options = {}) { + const checkpoint = objectValue(options.checkpoint); + const professional = firstJsonArray(checkpoint.professional).map(clone); + const publicSources = firstJsonArray(checkpoint.public_sources).map(clone); + const issues = firstJsonArray(checkpoint.issues).map((item) => String(item)).filter(Boolean); + const completedQueryKeys = new Set( + firstJsonArray(checkpoint.completed_query_keys).map(String).filter(Boolean), + ); + const professionalFailures = []; + const publicFailures = []; + const reportProgress = typeof options.report_progress === "function" + ? options.report_progress + : async () => {}; + const saveCheckpoint = typeof options.save_checkpoint === "function" + ? options.save_checkpoint + : async () => {}; + let checkpointWrite = Promise.resolve(); + const persistCollection = (progressOptions = {}) => { + const snapshot = { + schema_version: 1, + company_id: company.id, + professional: clone(professional), + public_sources: clone(publicSources), + issues: [...new Set(issues)].slice(-40), + completed_query_keys: [...completedQueryKeys].sort(), + updated_at: nowIso(), + }; + checkpointWrite = checkpointWrite.then(() => saveCheckpoint(snapshot, progressOptions)); + return checkpointWrite; + }; + + if (this.dataProProvider?.isRunEnabled?.()) { + const maxProfessionalSources = Math.max(1, Math.min(Number(this.dataProProvider.maxSources || 3), 5)); + const dataProQueries = this.dataProProvider.planDossierQueries?.(company, { + maxSources: maxProfessionalSources, + }) || [ + { + label: "企业风险数据库", + purpose: "风险与关注事项核验", + query: `${company.name} 企业风险数据 司法诉讼 行政处罚 失信被执行 经营异常`, + }, + { + label: "企业工商数据库", + purpose: "主体与经营信息核验", + query: `${company.name} 企业工商数据 经营状况 经营范围 知识产权`, + }, + ].slice(0, maxProfessionalSources); + + const dataProQueryKeys = dataProQueries.map((item) => workflowQueryKey("datapro", item.query)); + const dataProCompletedCount = () => dataProQueryKeys + .filter((key) => completedQueryKeys.has(key)).length; + await reportProgress("collecting_professional", 10); + await mapWithConcurrency(dataProQueries, this.dossierDataProConcurrency, async (item) => { + const queryKey = workflowQueryKey("datapro", item.query); + if (completedQueryKeys.has(queryKey)) return; + try { + const result = await this.trackProviderStep(providerRunId, { + provider: "datapro", + operation: "company_evidence_query", + input_summary: `${item.label}:${item.purpose || company.name}`, + output_summary: `已完成 ${item.label} 查询。`, + }, () => this.dataProProvider.callTool(item.query)); + const summaries = dataProEvidenceSummaries(result); + if (result.ok && summaries.length) { + summaries.forEach((summary, index) => { + professional.push({ + label: summaries.length > 1 ? `${item.label} · 记录 ${index + 1}` : item.label, + source_group: item.label, + source_key: `${item.label}:${result.raw_ref || item.query}:${index + 1}`, + summary, + raw_ref: result.raw_ref || "", + query: item.query, + purpose: item.purpose || "", + }); + }); + completedQueryKeys.add(queryKey); + } else if (!result.ok) { + professionalFailures.push(result.error || {}); + issues.push(`专业数据集暂时不可用:${result.error?.message || result.error?.code || "provider_error"}`); + } else { + issues.push(`${item.label}调用成功,但没有返回可展示的业务字段。`); + completedQueryKeys.add(queryKey); + } + } catch (error) { + professionalFailures.push(error); + issues.push(`专业数据集暂时不可用:${error.message}`); + } + const current = dataProCompletedCount(); + await persistCollection({ + stage: "collecting_professional", + progress: Math.round(10 + (current / Math.max(1, dataProQueries.length)) * 18), + detail: { + current, + total: dataProQueries.length, + message: `正在核验专业资料 ${current}/${dataProQueries.length}`, + }, + }); + }); + await checkpointWrite; + } else { + await this.skipProviderStep(providerRunId, { + provider: "datapro", + operation: "collect_professional_evidence", + input_summary: `为 ${company.name} 获取专业资料`, + output_summary: "DataPro 未启用。", + error: { code: "provider_disabled", message: "DataPro is not enabled." }, + }); + } + + if (this.webSearchProvider?.isRunEnabled?.()) { + const seenPublicSources = new Set( + publicSources.map((source) => source.url || source.label).filter(Boolean), + ); + const authoritativeHosts = new Map(); + const publicQueryKeys = new Set( + [...completedQueryKeys].filter((key) => key.startsWith("web_search:")), + ); + const searchName = preferredCompanySearchName(company); + const currentYear = new Date().getFullYear(); + const webQueries = [...new Map([ + { + purpose: "法定主体近期公告与招采事项", + query: `${company.name} ${currentYear} 招标 采购 中标 公告`, + }, + { + purpose: "法定主体监管、司法与经营风险补充核验", + query: `${company.name} ${currentYear} 行政处罚 司法诉讼 失信被执行 经营异常 监管 召回 官方`, + }, + { + purpose: "法定主体官方公告与投资者信息", + query: `${company.name} ${currentYear} 官网 公告 年报 投资者关系`, + }, + { + purpose: "品牌或简称相关的最新项目与合作", + query: `${searchName} ${currentYear} 最新公告 项目 合作`, + }, + { + purpose: "品牌或简称相关的产能、供应链与业务变化", + query: `${searchName} ${currentYear} 产能 供应链 业务动态`, + }, + ].map((item) => [item.query, item])).values()]; + const maxPublicSources = 18; + const rememberAuthoritativeHost = (candidate) => { + const host = publicSourceHostname(candidate.url); + const authorityLevel = Number(candidate.auth_level); + if ( + !host + || !Number.isFinite(authorityLevel) + || authorityLevel < 2 + || !dossierTextMentionsCompany( + `${candidate.site_name} ${candidate.label} ${candidate.summary}`, + company, + ) + ) return; + const score = authorityLevel * 10 + + (dossierTextMentionsCompany(candidate.site_name, company) ? 12 : 0) + + (/\.cn$/i.test(host) ? 4 : 0) + + (/\/(?:news|press|stories|company)\b/i.test(candidate.url) ? 3 : 0); + authoritativeHosts.set(host, Math.max(score, authoritativeHosts.get(host) || 0)); + }; + publicSources.forEach(rememberAuthoritativeHost); + const registerPublicQueries = (queries) => { + queries.forEach((queryItem) => { + publicQueryKeys.add(workflowQueryKey("web_search", queryItem.query)); + }); + }; + const publicCompletedCount = () => [...publicQueryKeys] + .filter((key) => completedQueryKeys.has(key)).length; + + const runPublicQuery = async (queryItem) => { + const queryKey = workflowQueryKey("web_search", queryItem.query); + if (completedQueryKeys.has(queryKey)) return; + try { + const result = await this.trackProviderStep(providerRunId, { + provider: "web_search", + operation: "public_evidence_query", + input_summary: `检索 ${company.name} 的${queryItem.purpose}`, + output_summary: "已完成公开信息检索。", + }, () => this.webSearchProvider.search({ + query: queryItem.query.slice(0, 100), + count: 3, + need_summary: true, + query_rewrite: true, + auth_level: 1, + })); + if (!result.ok) { + publicFailures.push(result.error || {}); + issues.push(`联网搜索暂时不可用:${result.error?.code || "provider_error"}`); + return; + } + for (const searchResult of result.results || []) { + const key = searchResult.url || searchResult.title; + const summary = cleanEvidenceSummary(searchResult.summary || searchResult.snippet, "", 1600); + if (!key || seenPublicSources.has(key) || !summary) continue; + const candidate = { + label: searchResult.title || searchResult.url || `${company.name} 公开来源`, + summary, + url: searchResult.url || "", + published_at: searchResult.publish_time || null, + site_name: searchResult.site_name || "", + auth_description: searchResult.auth_description || "", + auth_level: searchResult.auth_level ?? null, + rank_score: searchResult.rank_score ?? null, + query: queryItem.query, + purpose: queryItem.purpose, + }; + rememberAuthoritativeHost(candidate); + if (isLowValuePublicDossierSource(candidate, concisePublicPoint(candidate))) continue; + if (!isDisplayableDossierCitation({ ...candidate, source_kind: "联网搜索" }, company)) { + continue; + } + seenPublicSources.add(key); + publicSources.push(candidate); + if (publicSources.length >= maxPublicSources) break; + } + completedQueryKeys.add(queryKey); + } catch (error) { + publicFailures.push(error); + issues.push(`联网搜索暂时不可用:${error.message}`); + } + const current = publicCompletedCount(); + const total = Math.max(1, publicQueryKeys.size); + await persistCollection({ + stage: "collecting_public", + progress: Math.round(30 + (current / total) * 18), + detail: { + current, + total, + message: `正在检索公开资料 ${current}/${total}`, + }, + }); + }; + const runPublicBatch = async (queries) => { + const unique = [...new Map(queries.map((item) => [item.query, item])).values()]; + registerPublicQueries(unique); + await mapWithConcurrency(unique, this.dossierWebConcurrency, runPublicQuery); + await checkpointWrite; + }; + + await reportProgress("collecting_public", 30); + await runPublicBatch(webQueries); + + const hasRecentPublicEvidence = () => publicSources.some((source) => { + const citation = { ...source, source_kind: "联网搜索" }; + const point = concisePublicPoint(citation); + return isDisplayableDossierCitation(citation, company) + && isRecentPublicDossierCitation(citation, point, company); + }); + if (!hasRecentPublicEvidence() && authoritativeHosts.size) { + const officialHosts = [...authoritativeHosts.entries()] + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .slice(0, 2) + .map(([host]) => host); + const officialFollowups = officialHosts.flatMap((host) => [ + { + purpose: `权威站点 ${host} 的新闻、公告与合作`, + query: `site:${host} ${searchName} ${currentYear} 新闻 公告 合作 项目`, + }, + { + purpose: `权威站点 ${host} 的投资、产能与供应链变化`, + query: `site:${host} ${searchName} 投资 产能 供应链 业务`, + }, + ]).slice(0, 3); + if (publicSources.length < maxPublicSources && !hasRecentPublicEvidence()) { + await runPublicBatch(officialFollowups.slice(0, 2)); + } + } + + const coverage = assessDossierEvidenceCoverage(company, { + professional, + public_sources: publicSources, + }); + const coverageFollowups = []; + const addCoverageFollowup = (topic, purpose, query) => { + if (coverage.missing_topics.includes(topic)) { + coverageFollowups.push({ purpose, query }); + } + }; + addCoverageFollowup( + "recent_public", + "近期官方公告、项目与合作事件", + `${company.name} ${currentYear} 官方公告 项目 合作 投资`, + ); + addCoverageFollowup( + "operations", + "经营、产品、产能与供应链变化", + `${searchName} ${currentYear} 产品 产能 交付 供应链 业务`, + ); + addCoverageFollowup( + "risk", + "监管、司法、召回与经营风险", + `${company.name} ${currentYear} 监管 处罚 诉讼 召回 经营异常`, + ); + addCoverageFollowup( + "procurement_or_project", + "招采、中标与项目落地信号", + `${company.name} ${currentYear} 招标 采购 中标 项目 供应商`, + ); + addCoverageFollowup( + "source_diversity", + "不同权威公开渠道的企业动态", + `${searchName} ${currentYear} 政府 公告 行业协会 项目 新闻`, + ); + const boundedCoverageFollowups = [...new Map( + coverageFollowups.map((item) => [item.query, item]), + ).values()].slice(0, 4); + if (publicSources.length < maxPublicSources && boundedCoverageFollowups.length) { + await runPublicBatch(boundedCoverageFollowups); + } + } else { + await this.skipProviderStep(providerRunId, { + provider: "web_search", + operation: "collect_public_evidence", + input_summary: `为 ${company.name} 获取最新公开信息`, + output_summary: "联网搜索未启用。", + error: { code: "provider_disabled", message: "Web search is not enabled." }, + }); + } + + if (this.runtimePolicy.fail_closed && !professional.length) { + throw providerUnavailable("datapro", "No verified professional evidence was returned for the dossier.", { + issues, + ...providerFailureDetails(professionalFailures), + }); + } + if (this.runtimePolicy.fail_closed && !publicSources.length && publicFailures.length) { + throw providerUnavailable("web_search", "No verified public evidence was returned for the dossier.", { + issues, + ...providerFailureDetails(publicFailures), + }); + } + + await persistCollection({ + stage: "building_evidence", + progress: 48, + detail: { message: "正在整理可信资料" }, + }); + await checkpointWrite; + return { + professional: professional.slice(0, 30), + public_sources: publicSources.slice(0, 18), + issues, + coverage: assessDossierEvidenceCoverage(company, { + professional, + public_sources: publicSources, + }), + }; + } + + async searchOpenViking(company, query) { + if (!this.openVikingProvider?.isConfigured?.()) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking is not configured for retrieval."); + } + return []; + } + try { + const result = await this.openVikingProvider.findMemories(query, { + limit: 8, + uri: this.openVikingMaterialsUri(company), + }); + if (!result.ok) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking retrieval failed.", { + reason: result.error?.code || "provider_error", + }); + } + return []; + } + const contexts = this.normalizeOpenVikingContexts(result.result, company); + return contexts; + } catch (error) { + if (this.runtimePolicy.fail_closed) { + if (error instanceof HttpError) throw error; + throw providerUnavailable("openviking", "OpenViking retrieval failed.", { + reason: error.message || "provider_error", + }); + } + return []; + } + } + + normalizeOpenVikingContexts(result, company) { + const items = [ + ...firstJsonArray(result?.memories), + ...firstJsonArray(result?.resources), + ...firstJsonArray(result?.skills), + ]; + const materials = (company.material_ids || []) + .map((id) => this.data.materials[id]) + .filter(Boolean) + .filter(isFeishuMaterial); + return items + .filter((item) => String(item.uri || "").includes("/materials/")) + .filter((item) => !isOpenVikingOverviewItem(item)) + .map((item) => { + const uri = item.uri || ""; + const canonicalUri = canonicalOpenVikingResourceUri(uri); + const material = materials.find((candidate) => { + const materialUri = canonicalOpenVikingResourceUri( + candidate.openviking_uri || candidate.openviking_ref, + ); + return materialUri + && (canonicalUri === materialUri || canonicalUri.startsWith(`${materialUri}/`)); + }); + if (!material) return null; + return { + uri, + material_id: material.id, + title: material.title || `${company.name} 飞书资料`, + source_kind: feishuMaterialSourceKind(material), + abstract: compactText(item.abstract || item.overview || item.text || "", 500), + score: item.score ?? null, + }; + }) + .filter((item) => item?.abstract) + .filter((context, index, contexts) => ( + contexts.findIndex((candidate) => ( + canonicalOpenVikingResourceUri(candidate.uri) + === canonicalOpenVikingResourceUri(context.uri) + )) === index + )) + .slice(0, 8); + } + + async hydrateOpenVikingContexts(company, contexts = []) { + const materials = new Map( + (company.material_ids || []) + .map((id) => this.data.materials[id]) + .filter(Boolean) + .map((material) => [material.id, material]), + ); + return Promise.all((contexts || []).map(async (context) => { + const material = materials.get(context.material_id); + let content = ""; + if (context.uri && typeof this.openVikingProvider?.readTextResource === "function") { + try { + const result = await this.openVikingProvider.readTextResource(context.uri); + if (result?.ok) content = normalizeImportedText(result.content).trim().slice(0, 30000); + } catch { + content = ""; + } + } + if (!content) { + content = normalizeImportedText( + material?.text + || material?.content + || material?.summary + || context.abstract, + ).trim().slice(0, 30000); + } + return { + ...context, + content, + source_updated_at: material?.updated_at || null, + }; + })); + } + + materialContexts(company) { + return (company.material_ids || []) + .map((id) => this.data.materials[id]) + .filter(Boolean) + .filter(isFeishuMaterial) + .slice(0, 5) + .map((material) => ({ + uri: material.openviking_uri || material.openviking_ref || "", + material_id: material.id, + title: material.title || `${company.name} 历史资料`, + source_kind: feishuMaterialSourceKind(material), + abstract: compactText(material.summary || material.text || `${material.title || "历史资料"} 已登记为 ${company.name} 的长期资料。`, 500), + score: null, + })); + } + + openVikingCompanyUri(company) { + if (typeof this.openVikingProvider?.salesCompanyUri !== "function") return ""; + return this.openVikingProvider.salesCompanyUri({ + workspaceId: this.workspaceId, + companyId: company.id, + }); + } + + openVikingMaterialsUri(company) { + const companyUri = this.openVikingCompanyUri(company); + return companyUri ? `${companyUri}/materials` : ""; + } + + async generateDossierWithModel(company, collected, memoryContexts, providerRunId = "") { + const evidencePack = Array.isArray(collected?.items) && collected?.evidence_hash + ? collected + : buildDossierEvidencePack({ + company, + collected, + memoryContexts: [], + generatedAt: nowIso(), + }); + const evidenceCompilation = compileDossierEvidenceAtoms({ + evidencePack: evidencePack.entity + ? evidencePack + : { + ...evidencePack, + entity: resolveCompanyEntity(company), + }, + }); + const citationInputs = this.buildCitationInputs(evidencePack, memoryContexts) + .filter((citation) => isDisplayableDossierCitation(citation, company)); + if (!citationInputs.length) { + await this.skipProviderStep(providerRunId, { + provider: "model", + operation: "generate_sales_dossier", + input_summary: `为 ${company.name} 生成最近档案`, + output_summary: "没有可引用证据,未调用模型。", + error: { code: "missing_sources", message: "No verified citations were available." }, + }); + return null; + } + if (!this.modelProvider?.isRunEnabled?.()) { + await this.skipProviderStep(providerRunId, { + provider: "model", + operation: "generate_sales_dossier", + input_summary: `基于 ${citationInputs.length} 条证据生成 ${company.name} 最近档案`, + output_summary: "模型 Provider 未启用。", + error: { code: "provider_disabled", message: "Model provider is not enabled." }, + }); + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("model", "The model provider is not enabled."); + } + return null; + } + const evidenceGroundingErrors = dossierGroundingErrors(citationInputs, null, company); + if (evidenceGroundingErrors.length) { + if (this.runtimePolicy.fail_closed) { + throw new HttpError(422, "evidence_quality_insufficient", "可展示来源不足以生成正式销售档案。", { + validation_errors: evidenceGroundingErrors, + }); + } + } + const sourcePolicy = dossierSectionSourcePolicy(citationInputs, company); + const targetName = String(company.name || company.legal_name || "").trim(); + const sourceSelectionPolicy = { + business_database_ids: [...sourcePolicy.business], + business_dynamics_ids: [...sourcePolicy.businessDynamics], + risk_database_ids: [...sourcePolicy.risk], + market_database_ids: [...sourcePolicy.market], + professional_dataset_ids: [...sourcePolicy.professional], + web_search_ids: [...sourcePolicy.web], + excluded_entity_citation_ids: citationInputs + .map((citation) => ({ citation, record: dossierBusinessEntityRecord(citation) })) + .filter(({ record }) => ( + record + && normalizeLegalEntityName(record.name) + !== normalizeLegalEntityName(targetName) + )) + .map(({ citation }) => String(citation.id)), + }; + const agentContext = buildDossierAgentContext({ + citations: citationInputs, + evidencePolicy: evidencePack.policy || null, + evidenceConflicts: evidencePack.conflicts || [], + sourceSelectionPolicy, + evidenceAtoms: evidenceCompilation.atoms, + evidenceCoverage: evidenceCompilation.coverage, + }); + const modelInstructions = [ + "你是销售情报平台中负责企业档案生成的受约束 Agent。", + "只能基于每章 allowed_evidence 中的 Evidence Atom 生成,不要补编任何事实。", + "本报告只能使用专业数据集和豆包搜索(联网搜索)两类外部来源;不得使用飞书资料、OpenViking 记忆或历史问答。", + "专业数据集可能来自企业工商、企业风险、金融、汽车或科研学术等不同数据库;必须按章节选用语义匹配的来源,不得把所有专业数据都当作工商信息。", + "专业数据集能够覆盖的主体、风险、财务、销量或科研事实,必须优先使用对应专业数据库;豆包搜索只补充近期公告、新闻、项目合作及专业库未覆盖的时效信息。", + "风险与关注事项应优先使用本章 allowed_evidence 中的专业或官方 Atom;公开网页只用于交叉核验或补充公开动态。", + "风险章节只能写来源直接披露的风险事实,或把明确事实改写为需要核验的具体事项;不得从单个项目、单笔金额或少量公告外推企业整体的订单结构、客户结构、收入结构、业务能力、回款状况或长期趋势。", + ...((sourcePolicy.risk || new Set()).size ? [] : [ + "当前没有通过主体和内容门禁的专业风险数据库来源。风险与关注事项章不得写具体诉讼、处罚、失信、营收、利润、融资或估值结论;只能把 allowed_evidence 中已核验的主体或经营事实改写为具体的对接前核验事项,不得声称对方已存在该风险。", + ]), + "企业与业务概览应优先使用能够锚定法定主体的专业 Atom。", + "经营与业务动态应优先使用本章 allowed_evidence 中语义匹配的专业经营、市场或科研 Atom。", + "输出必须是固定六章节报告,且严格按顺序使用标题:企业与业务概览、经营与业务动态、近期公开动态、风险与关注事项、销售机会判断、建议行动。", + "这是一份供销售人员使用的完整企业情报报告,不是接口执行摘要。每章固定生成一个完整段落,段落可以包含 1-3 个紧密相关的完整句子,并且必须包含有信息量的业务表述,不得只写“已返回数据”“可用于核验”“建议继续关注”等空泛模板句。", + "正文只呈现企业事实、事件、影响、销售判断和行动,不得向用户解释检索过程、证据校验过程或数据源之间的差异。", + "禁止在正文中出现“本次未检索到”“本次没有返回”“资料不足”“资料缺口”“来源冲突”“来源不一致”“来源存在差异”“关键字段存在来源差异”“冲突字段”“来源等级不足”等内部诊断话术。", + "不得照抄搜索结果中的站点导航、作者日期前缀、注册引导、广告文字或被截断的摘要;每个事实句必须语义完整,括号和引号必须闭合,财务、产能和市占率数字必须带完整单位与上下文。", + "某一章节的专业数据不足时,只能从该章 allowed_evidence 中选择语义匹配的公开事实补充;仍无可靠事实时不得编造或用检索状态、空泛模板凑成章节。", + "Evidence Atom 的 entity_match=alias_scoped 表示来源只匹配品牌或简称。可以作为品牌、集团或相关业务动态写入,但必须明确主体边界,不得把它表述成输入法定主体已经发生的确定事实。", + "企业与业务概览用于交代主体、主营方向、业务定位和来源能够直接支持的业务应用场景,不要罗列内部字段名,也不得在本章写采购场景、采购需求、采购计划或采购意向。", + "静态的登记经营范围只能写成“经营范围包括”或“登记业务覆盖”,不得写成“延伸至”“扩展至”“布局扩展”等时序变化,也不得写成“主营”“同时承担”“形成业务定位”“已具备现实能力”或“制造基地法定主体”。", + "销售机会判断可以把登记范围作为待确认的对接方向,但必须明确不代表现实业务、采购意向或预算。建议行动不得根据注册地址虚构已存在的厂区采购窗口或技术部门,应先确认负责相关业务的联系人。", + "企业工商数据包含总公司、分公司或子公司记录时,成立日期、注册地址、注册号、统一社会信用代码和法定代表人必须绑定到公司名称完全一致的那条记录;不得把分支机构字段写成目标法定主体字段。只有正文逐字点名分支机构完整名称时,才允许引用该分支机构记录并描述其自身字段。", + "正文提到任何分公司或子公司时,本章必须选择该分支机构自己的工商 Evidence Atom;如果本章没有该记录,就删除分支机构名称和相关断言,不得根据总公司记录补写分支布局、区域覆盖或市场承载能力。", + "经营与业务动态只能写可由来源证明的经营变化、项目、合作、产能、供应链或业务动作;不得复制注册信息或描述检索结果来凑字数。", + "若来源只是少量中标、成交或公告记录,只能逐项陈述这些项目,不得据此写企业整体已从某类业务扩展、转向或升级到另一类业务,也不得声称整体能力、市场或产品结构已经改变。", + "近期公开动态应优先写清日期、事件、合作方或项目,以及该事件为何值得销售关注;不得只复述搜索标题。", + "近期公开动态只陈述来源披露的事件与直接影响;不得把中标密度、公告节奏或框架入围写成对方采购需求、采购意向、预算或资源需求正在形成或活跃。此类内容只能在销售机会判断中作为明确标注的保守推断。", + "同一个事实、事件或数字只能出现在一个最匹配的章节。经营与业务动态写业务变化,近期公开动态写有日期的公开事件,风险与关注事项写风险影响;不得在不同章节复制或轻微改写同一段来源内容。", + "销售机会判断必须从已经引用的业务动作推导具体切入场景和时机,同时明确这只是机会判断,不能写成对方已有采购意向。", + "建议行动必须具体到拟联系的部门或角色、需要核验的问题、可准备的材料和下一步动作,列出 1-2 项,信息量由证据决定,避免通用销售套话。", + "每个自然段和每条编号行动都必须使用完整句子,并以句号、问号或感叹号结束;不得以逗号、冒号或分号收尾。", + "不得展示企业内部主键、关联主键、trace id、request id、record id、接口名、Provider 名或原始响应字段。", + "建议行动要具体到需要核验的对象、事项或销售动作;不得用内部客户沟通内容补齐外部事实。", + "模型每章只提交 text 和 evidence_ids;quote、citation_id、URL、segment、citation_ids 与最终引用全部由服务端根据 Evidence Atom 确定性派生。", + "只引用与正文事实直接相关的来源,不得为了增加引用数量而加入弱相关或重复来源;来源数量本身不是生成目标。", + "source_usage_requirements 只描述当前可用来源,不设置整份报告引用数量门槛。证据充足时优先选择与各事实直接相关的独立来源;证据确实较少时应缩短报告,不得补编或凑引用。", + "专业数据没有 URL 时也可以作为引用来源,但不能伪造链接。", + "source_quality_label 表示来源等级,freshness_label 表示时效;过期资料和日期未知的公开来源不得表述为最新事实。", + "注册资本、营收、净利润、融资、估值及明确的司法/处罚/失信事实属于高风险事实,至少引用两个独立外部来源,且至少一个必须是专业或官方来源。", + "关键数字只有在两个独立来源返回同一数值时才可写成确定事实;若 evidence_conflicts 标记冲突,必须静默省略该数字,改写为其他有一致证据支持的实质事实,不得列出多个口径,也不得向前端解释冲突。", + ]; + const validateGeneratedDossier = (answer) => { + const normalizedAnswer = { + ...(answer || {}), + body: firstJsonArray(answer?.body).map((item, index) => { + const title = DOSSIER_SECTION_TITLES[index]; + return { + ...item, + text: title + ? cleanDossierBodyText(item?.text, title, "", 1400) + : String(item?.text || ""), + }; + }), + }; + const validatedAnswer = validateDossierModelAnswer(normalizedAnswer, citationInputs); + return { + ...validatedAnswer, + errors: [ + ...validatedAnswer.errors, + ...dossierSectionSourceErrors(validatedAnswer.body, citationInputs, company), + ...dossierSectionContentErrors(validatedAnswer.body), + ...dossierSectionSemanticErrors(validatedAnswer.body, citationInputs, company), + ...dossierSectionEvidenceGroundingErrors(validatedAnswer.body, citationInputs), + ...dossierSourceUsageErrors( + validatedAnswer.body.flatMap((paragraph) => paragraph.citation_ids || []), + agentContext.citations, + agentContext.sourceUsageRequirements, + ), + ...(String(answer?.summary || "").length > 160 ? ["档案摘要超过 160 个字符"] : []), + ...(String(answer?.memory_summary || "").length > 200 ? ["记忆摘要超过 200 个字符"] : []), + ], + }; + }; + const agent = new DossierAgent({ + maxCalls: Number(this.env.value("DOSSIER_AGENT_MAX_CALLS", "3")) || 3, + validate: validateGeneratedDossier, + callModel: async (request) => { + if (typeof this.modelProvider.callRequiredFunction !== "function") { + throw new Error("The model provider does not implement strict Function Calling."); + } + return this.trackProviderStep(providerRunId, { + provider: "model", + operation: request.operation, + input_summary: request.operation === "sales_dossier_agent_plan" + ? `基于 ${citationInputs.length} 条已核验证据规划 ${company.name} 的六章节报告` + : `根据质量门禁反馈修订 ${company.name} 的六章节报告规划`, + output_summary: request.operation === "sales_dossier_agent_plan" + ? "档案 Agent 已提交六章节事实、判断、行动与逐项引用规划。" + : "档案 Agent 已提交修订后的六章节规划。", + }, () => this.modelProvider.callRequiredFunction(request)); + }, + }); + try { + const agentResult = await agent.run({ + company: { + name: company.name, + industry: company.industry, + location: company.location, + }, + citations: citationInputs, + evidenceAtoms: evidenceCompilation.atoms, + evidenceCoverage: evidenceCompilation.coverage, + evidencePolicy: evidencePack.policy || null, + evidenceConflicts: evidencePack.conflicts || [], + sourceSelectionPolicy, + instructions: modelInstructions, + }); + if (!agentResult.ok) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("model", "The dossier Agent did not produce a valid result.", { + reason: agentResult.result?.error?.code || "dossier_quality_gate_failed", + validation_errors: agentResult.validation_errors, + }); + } + return null; + } + const normalizedDossier = this.normalizeModelDossier( + company, + agentResult.submission, + citationInputs, + agentResult.result?.raw_ref, + ); + if (!normalizedDossier && this.runtimePolicy.fail_closed) { + throw providerUnavailable("model", "The dossier Agent result failed final display validation.", { + reason: "dossier_quality_gate_failed", + validation_errors: ["档案在最终结构化与展示清洗后不再满足六章节、有效引用和正文质量要求。"], + }); + } + if (normalizedDossier) { + const publicView = this.publicDossier(normalizedDossier); + const publicViewErrors = this.publicDossierQualityErrors(publicView, company); + if (publicViewErrors.length) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("model", "The dossier Agent result failed the final public-view quality gate.", { + reason: "public_dossier_quality_gate_failed", + validation_errors: publicViewErrors, + }); + } + } + } + return normalizedDossier; + } catch (error) { + if (this.runtimePolicy.fail_closed) { + if (error instanceof HttpError) throw error; + throw providerUnavailable("model", "Dossier generation failed.", { + reason: error.message || "provider_error", + }); + } + return null; + } + } + + buildCitationInputs(collected, memoryContexts) { + if (Array.isArray(collected?.items) && collected?.evidence_hash) { + return normalizeDossierCitationSemantics(evidencePackCitations(collected) + .filter((citation) => /专业数据集|联网搜索/.test(citation.source_kind)) + .filter((citation) => cleanEvidenceSummary(citation.summary))); + } + const citations = []; + for (const source of collected.professional || []) { + citations.push({ + id: String(citations.length + 1), + label: source.label, + source_kind: "专业数据集", + url: "", + summary: source.summary, + provider_mode: source.provider_mode || "", + raw_ref: source.raw_ref || "", + query: source.query || "", + purpose: source.purpose || "", + published_at: source.published_at || null, + site_name: source.site_name || "", + auth_description: source.auth_description || "", + auth_level: source.auth_level ?? null, + rank_score: source.rank_score ?? null, + }); + } + for (const source of collected.public_sources || []) { + citations.push({ + id: String(citations.length + 1), + label: source.label, + source_kind: "联网搜索", + url: source.url || "", + summary: source.summary, + provider_mode: source.provider_mode || "", + raw_ref: source.raw_ref || "", + query: source.query || "", + purpose: source.purpose || "", + }); + } + return normalizeDossierCitationSemantics(citations.filter((citation) => ( + /专业数据集|联网搜索/.test(citation.source_kind) + && cleanEvidenceSummary(citation.summary) + ))); + } + + normalizeModelDossier(company, parsed, citationInputs, rawRef) { + const allowed = new Map(citationInputs.map((item) => [String(item.id), item])); + const body = firstJsonArray(parsed?.body) + .slice(0, DOSSIER_SECTION_TITLES.length) + .map((item, index) => { + const title = DOSSIER_SECTION_TITLES[index]; + const segments = firstJsonArray(item.segments) + .map((segment) => ({ + text: ensureDossierLinePunctuation( + normalizeImportedText(normalizeSalesText(segment.text)) + .replace(/\s+/g, " ") + .trim(), + ), + citation_ids: firstJsonArray(segment.citation_ids) + .map(String) + .filter((id) => ( + allowed.has(id) + && /专业数据集|联网搜索/.test(allowed.get(id)?.source_kind || "") + )), + })) + .filter((segment) => ( + segment.text + && segment.citation_ids.length + && !hasBadDisplayText(segment.text) + && !hasDossierInternalMetaText(segment.text) + )); + const text = cleanDossierBodyText( + segments.length + ? `${title}:${segments.map((segment) => segment.text).join("\n\n")}` + : item.text, + title, + "", + 1400, + ); + return { + text, + citation_ids: [...new Set(segments.length + ? segments.flatMap((segment) => segment.citation_ids) + : firstJsonArray(item.citation_ids).map(String).filter((id) => allowed.has(id)))], + segments, + }; + }); + if (body.length !== DOSSIER_SECTION_TITLES.length) return null; + if (body.some((item) => ( + !item.text + || !item.citation_ids.length + || !item.segments.length + || hasBadDisplayText(item.text) + || hasDossierInternalMetaText(item.text) + ))) return null; + const validatedBody = validateDossierModelAnswer({ body }, citationInputs); + const structuredBody = validatedBody.body; + const finalErrors = [ + ...validatedBody.errors, + ...dossierSectionSourceErrors(structuredBody, citationInputs, company), + ...dossierSectionContentErrors(structuredBody), + ...dossierSectionSemanticErrors(structuredBody, citationInputs, company), + ...dossierSectionEvidenceGroundingErrors(structuredBody, citationInputs), + ]; + if (finalErrors.length) return null; + const structuredSummary = [ + structuredBody.find((item) => item.text.startsWith("近期公开动态:"))?.text.replace(/^近期公开动态:/, ""), + structuredBody.find((item) => item.text.startsWith("销售机会判断:"))?.text.replace(/^销售机会判断:/, ""), + ].filter(Boolean).join(" "); + const parsedSummary = cleanEvidenceSummary(parsed.summary, "", 300); + const now = nowIso(); + return { + id: makeId("dossier"), + company_id: company.id, + title: `${company.name} 销售情报报告`, + summary: compactCompleteSentences( + isSubstantiveDossierSummary(structuredSummary) ? structuredSummary : parsedSummary, + 300, + ), + created_at: now, + body: structuredBody, + citations: citationInputs, + memory_summary: cleanEvidenceSummary(parsed.memory_summary, structuredBody.map((item) => item.text).join(" "), 600), + raw_ref: rawRef || null, + }; + } + + buildRuleDossier(company, collected, memoryContexts) { + const citations = this.buildCitationInputs(collected, memoryContexts); + if (!citations.length) { + const now = nowIso(); + const body = [ + { text: "企业与业务概览:暂未从专业数据集获取到可引用的企业信息。", citation_ids: [] }, + { text: "经营与业务动态:当前没有足够的专业数据支撑经营与业务变化判断。", citation_ids: [] }, + { text: "近期公开动态:暂未从豆包搜索获取到带日期和原始链接的近期公开信息。", citation_ids: [] }, + { text: "风险与关注事项:资料不足,当前不输出确定的风险结论。", citation_ids: [] }, + { text: "销售机会判断:资料不足,当前不推断采购意向、预算或销售阶段。", citation_ids: [] }, + { text: "建议行动:请稍后重新获取报告,并确认专业数据集和豆包搜索可正常返回来源。", citation_ids: [] }, + ]; + return { + id: makeId("dossier"), + company_id: company.id, + title: `${company.name} 销售情报报告`, + summary: "暂未获取到可引用的新变化。", + created_at: now, + body, + citations: [], + memory_summary: `${company.name} 销售情报报告暂未获取到可引用的新变化。`, + raw_ref: null, + }; + } + const now = nowIso(); + const body = this.reportDossierBody(company, citations); + return { + id: makeId("dossier"), + company_id: company.id, + title: `${company.name} 销售情报报告`, + summary: compactCompleteSentences(body.find((item) => item.text.startsWith("近期公开动态:"))?.text.replace(/^近期公开动态:/, "") || body[0].text.replace(/^企业与业务概览:/, ""), 300), + created_at: now, + body, + citations, + memory_summary: compactText(`${company.name} 销售情报报告已更新:${body.map((item) => item.text).join(" ")}`, 600), + raw_ref: null, + }; + } + + reportDossierBody(company, citations, preferredBody = []) { + const externalCitations = citations.filter((item) => /专业数据集|联网搜索/.test(item.source_kind || "")); + const allowedIds = new Set(externalCitations.map((item) => String(item.id))); + const preferred = preferredBody + .slice(0, DOSSIER_SECTION_TITLES.length) + .map((item, index) => ({ + text: cleanDossierBodyText(item.text, DOSSIER_SECTION_TITLES[index], "", 1400), + citation_ids: firstJsonArray(item.citation_ids).map(String).filter((id) => allowedIds.has(id)), + })) + .filter((item) => ( + item.text + && item.citation_ids.length + && !hasBadDisplayText(item.text) + && !hasDossierInternalMetaText(item.text) + )); + const completePreferred = DOSSIER_SECTION_TITLES.every((title, index) => ( + preferred[index]?.text.startsWith(`${title}:`) + )) + && dossierSectionSourceErrors(preferred, externalCitations, company).length === 0 + && dossierSectionContentErrors(preferred).length === 0 + && dossierSectionSemanticErrors(preferred, externalCitations, company).length === 0 + && dossierSectionEvidenceGroundingErrors(preferred, externalCitations).length === 0; + if (completePreferred) return preferred.slice(0, DOSSIER_SECTION_TITLES.length); + + const professional = externalCitations.filter((item) => item.source_kind === "专业数据集"); + const publicSources = externalCitations.filter((item) => item.source_kind === "联网搜索"); + const uniqueEvidence = (items) => items.filter((item, index, values) => { + const identity = String(item.point || "") + .toLowerCase() + .replace(/[\s,。;:!?、,.!?;:'"“”‘’()()【】[\]《》<>-]/gu, ""); + return identity && values.findIndex((candidate) => ( + String(candidate.point || "") + .toLowerCase() + .replace(/[\s,。;:!?、,.!?;:'"“”‘’()()【】[\]《》<>-]/gu, "") + === identity + )) === index; + }); + const targetEntityKey = normalizeLegalEntityName(company.name || company.legal_name || ""); + const professionalEvidence = uniqueEvidence(professional + .map((source) => ({ + source, + point: safeDeterministicDossierPoint(conciseProfessionalPoint(source, company.name)), + })) + .filter((item) => ( + item.point + && !isLowValueProfessionalPoint(item.point) + && isSubstantiveDossierEvidencePoint(item.point) + && (() => { + const record = dossierBusinessEntityRecord(item.source); + return !record || normalizeLegalEntityName(record.name) === targetEntityKey; + })() + ))); + const reportSourcePolicy = dossierSectionSourcePolicy(externalCitations, company); + const companyEvidence = professionalEvidence.filter((item) => { + const record = dossierBusinessEntityRecord(item.source); + return Boolean(record && normalizeLegalEntityName(record.name) === targetEntityKey); + }); + const marketEvidence = professionalEvidence.filter((item) => ( + !dossierBusinessEntityRecord(item.source) + && ( + reportSourcePolicy.market.has(String(item.source.id)) + || /金融数据库|汽车销量数据库|科研学术数据搜索服务/.test(String(item.source.label || "")) + || /经营|市场|技术|产能|销量|科研|专利/.test(`${item.source.purpose || ""} ${item.source.query || ""}`) + ) + )); + const selectedCompanyEvidence = (companyEvidence.length ? companyEvidence : professionalEvidence).slice(0, 2); + const selectedMarketEvidence = marketEvidence + .filter((item) => !selectedCompanyEvidence.some((candidate) => candidate.source.id === item.source.id)) + .slice(0, 2); + const professionalIds = professionalEvidence.map((item) => String(item.source.id)); + const companyIds = selectedCompanyEvidence.map((item) => String(item.source.id)); + const companyPoints = selectedCompanyEvidence.map((item) => item.point); + const publicEvidence = uniqueEvidence(publicSources + .map((source) => ({ + source, + point: safeDeterministicDossierPoint(concisePublicPoint(source)), + })) + .filter((item) => ( + item.point + && !isLowValuePublicDossierSource(item.source, item.point) + && isPublicCitationRelevantToCompany(item.source, item.point, company) + )) + .sort((a, b) => ( + publicDossierEvidenceScore(b.source, b.point, company) + - publicDossierEvidenceScore(a.source, a.point, company) + ))); + const publicIds = publicEvidence.map((item) => String(item.source.id)); + const allIds = [...new Set([...professionalIds, ...publicIds])]; + const professionalRiskEvidence = professionalEvidence + .filter((item) => ( + item.point + && !dossierBusinessEntityRecord(item.source) + && ( + DOSSIER_RISK_TERMS.test(`${item.source.label || ""} ${item.source.purpose || ""} ${item.source.query || ""}`) + || DOSSIER_RISK_TERMS.test(item.point) + ) + )); + const publicRiskEvidence = publicEvidence.filter((item) => ( + isPublicRiskEvidenceForCompany(item.source, item.point, company) + )); + const selectedRiskEvidence = ( + professionalRiskEvidence.length + ? professionalRiskEvidence + : publicRiskEvidence + ).slice(0, 2); + const selectedRiskSourceIds = new Set( + selectedRiskEvidence.map((item) => String(item.source.id)), + ); + const publicBusinessEvidence = publicEvidence + .filter((item) => !selectedRiskSourceIds.has(String(item.source.id))) + .filter((item) => DOSSIER_ACTION_TERMS.test(`${item.point} ${item.source.label || ""}`)) + .slice(0, 1); + const selectedBusinessEvidence = selectedMarketEvidence.length + ? selectedMarketEvidence + : publicBusinessEvidence; + const selectedBusinessSourceIds = new Set( + selectedBusinessEvidence.map((item) => String(item.source.id)), + ); + const recentEvidence = publicEvidence + .filter((item) => !selectedRiskSourceIds.has(String(item.source.id))) + .filter((item) => !selectedBusinessSourceIds.has(String(item.source.id))) + .filter((item) => isRecentPublicDossierCitation(item.source, item.point, company)) + .slice(0, 3); + const riskIds = selectedRiskEvidence.map((item) => String(item.source.id)); + const businessPoints = selectedBusinessEvidence.map((item) => item.point); + const recentPoints = recentEvidence.map((item) => item.point); + const evidencePoints = [ + ...companyPoints, + ...businessPoints, + ...recentPoints, + ...selectedRiskEvidence.map((item) => item.point), + ]; + const themes = dossierSalesThemes(evidencePoints, company); + const themeText = themes.join("、"); + const professionalFallback = professionalEvidence.slice(0, 2).map((item) => item.point); + const companyFactText = (companyPoints.length ? companyPoints : professionalFallback).join(";"); + const companyText = companyFactText + ? ( + companyFactText.length >= 24 + ? companyFactText + : `${companyFactText}。该主体的业务定位集中于${themeText}相关产品与服务。` + ) + : `${company.name}的专业数据记录已完成主体匹配,业务跟进可从${themeText}展开。`; + const businessTextValue = businessPoints.length + ? `${businessPoints.join(";")}。上述业务动作指向${themeText}相关的经营与技术方向,销售团队可据此确认当前产品线、项目节奏和采购责任部门。` + : publicEvidence.length + ? `近期公开业务信息主要涉及${themeText}。经营跟进应进一步确认对应业务部门、实施阶段、合作对象和采购责任链。` + : `专业数据所示业务范围集中在${themeText}。经营跟进应围绕当前产品线、重点项目、交付安排和采购组织核实实际变化。`; + const timelineEvidence = publicEvidence.find((item) => ( + !selectedRiskSourceIds.has(String(item.source.id)) + && isRecentPublicDossierCitation(item.source, item.point, company) + )) || publicEvidence[0]; + const timelineDate = String(timelineEvidence?.source?.published_at || "").slice(0, 10); + const timelinePrefix = timelineDate ? `截至${timelineDate},` : "根据近期公开信息,"; + const recentText = recentPoints.length + ? recentPoints.join(";") + : `${timelinePrefix}${company.name}的公开业务动向主要涉及${themeText}。后续应持续跟踪相关事项的正式公告、项目落地、合作方和采购进展。`; + const riskText = selectedRiskEvidence.length + ? `${selectedRiskEvidence.map((item) => item.point).join(";")}。商务推进应进一步确认相关事项对准入、合同责任、供应保障和交付排期的影响边界。` + : `结合已核验的主体信息和近期公开事项,商务推进应把供应商准入、数据合规、合同责任、供应保障和交付排期作为前置核验项,避免在责任边界未确认前作出方案或时间承诺。`; + const opportunityText = `基于现有专业数据和近期公开事项,可优先验证${themeText}相关的采购、技术协同或项目交付场景。首轮沟通应确认牵头部门、预算窗口和决策链,再判断线索优先级;这属于销售机会判断,不代表对方已经形成采购意向。`; + const actionText = [ + `1. 围绕${themeText}确认牵头业务部门、采购负责人和最终决策角色。`, + "2. 针对近期公开事项逐项核验项目阶段、时间表、采购范围和预算来源。", + `3. 准备与${themeText}匹配的产品方案、客户案例、交付边界和验收指标。`, + "4. 在进入商务报价前确认供应商准入、数据合规、合同责任和实施风险。", + ].join("\n"); + const fallbackIds = allIds.length ? allIds : [...allowedIds]; + const companySectionIds = (companyIds.length ? companyIds : professionalIds.length ? professionalIds : fallbackIds).slice(0, 3); + const businessSectionIds = ( + selectedBusinessEvidence.length + ? [ + ...selectedBusinessEvidence.map((item) => String(item.source.id)), + ...(selectedMarketEvidence.length ? [] : companySectionIds), + ] + : publicEvidence.length + ? [String(publicEvidence[0].source.id), ...companySectionIds] + : companySectionIds + ).filter((id, index, values) => id && values.indexOf(id) === index).slice(0, 4); + const recentSectionIds = ( + recentEvidence.length + ? recentEvidence.map((item) => String(item.source.id)) + : timelineEvidence + ? [String(timelineEvidence.source.id)] + : publicIds.length + ? publicIds + : fallbackIds + ).slice(0, 3); + const riskSectionIds = [ + ...riskIds, + ...companySectionIds, + ].filter((id, index, values) => id && values.indexOf(id) === index).slice(0, 4); + const decisionSectionIds = [ + ...(professionalIds.length ? professionalIds : companySectionIds), + ...recentSectionIds, + ].filter((id, index, values) => id && values.indexOf(id) === index).slice(0, 4); + const report = [ + { + text: `企业与业务概览:${companyText}`, + citation_ids: companySectionIds, + }, + { + text: `经营与业务动态:${businessTextValue}`, + citation_ids: businessSectionIds, + }, + { + text: `近期公开动态:${recentText}`, + citation_ids: recentSectionIds, + }, + { + text: `风险与关注事项:${riskText}`, + citation_ids: riskSectionIds, + }, + { + text: `销售机会判断:${opportunityText}`, + citation_ids: decisionSectionIds, + }, + { + text: `建议行动:${actionText}`, + citation_ids: decisionSectionIds, + }, + ]; + return report.map((item, index) => ({ + text: cleanDossierBodyText(item.text, DOSSIER_SECTION_TITLES[index], item.text, 1400), + citation_ids: item.citation_ids.filter((id) => allowedIds.has(id)), + })); + } + + fixedDossierBody(company, citations, preferredBody = []) { + const professionalIds = citations.filter((item) => item.source_kind === "专业数据集").map((item) => item.id); + const webIds = citations.filter((item) => item.source_kind === "联网搜索").map((item) => item.id); + const internalIds = citations.filter((item) => item.source_kind === "内部资料").map((item) => item.id); + const allIds = citations.map((item) => item.id); + const professionalSources = citations.filter((item) => item.source_kind === "专业数据集"); + const webSources = citations.filter((item) => item.source_kind === "联网搜索"); + const firstProfessional = professionalSources[0]; + const webPoints = webSources + .slice(0, 3) + .map((source) => concisePublicPoint(source)) + .filter(Boolean); + const professionalPoints = professionalSources + .slice(0, 3) + .map((source) => conciseProfessionalPoint(source)) + .filter((point) => point && !isLowValueProfessionalPoint(point)); + const preferredCompanyText = preferredBody.find((item) => /^企业情况:/.test(item.text))?.text; + const preferredCompanyIds = firstJsonArray(preferredBody.find((item) => /^企业情况:/.test(item.text))?.citation_ids) + .filter((id) => professionalIds.includes(id) || webIds.includes(id)); + const companyText = firstProfessional + ? (preferredCompanyText && !isWeakCompanySituationText(preferredCompanyText) + ? preferredCompanyText + : `企业情况:专业数据库显示:${professionalPoints.join(";") || `${company.name} 的可引用企业信息`}。`) + : `企业情况:本次专业数据库未返回可引用结果,当前档案不输出工商核验结论。`; + const preferredLatestText = preferredBody.find((item) => /^近期动态:/.test(item.text))?.text; + const preferredLatestIds = firstJsonArray(preferredBody.find((item) => /^近期动态:/.test(item.text))?.citation_ids) + .filter((id) => professionalIds.includes(id) || webIds.includes(id)); + const latestText = (preferredLatestText && !isOverlongLatestText(preferredLatestText) ? preferredLatestText : "") + || (webPoints.length + ? `近期动态:联网搜索返回 ${webPoints.length} 条可引用公开来源,主要提到:${webPoints.join(";")}。` + : "近期动态:联网搜索暂未返回可引用的新公告、新闻或招投标摘要。"); + const judgmentText = professionalIds.length + ? (preferredBody.find((item) => /^销售判断:/.test(item.text))?.text + || `销售判断:专业数据库可用于核验企业主体事实,联网搜索补充近期公开动态;当前信息适合作为下一轮销售沟通前的背景材料。`) + : `销售判断:本次只能依据联网搜索判断公开动态,缺少专业数据库的工商/风险核验,销售推进判断应保持谨慎。`; + const nextText = preferredBody.find((item) => /^下一步建议:/.test(item.text))?.text + || (professionalIds.length + ? `下一步建议:结合专业数据库核验结果和公开动态,继续确认预算窗口、采购节奏、供应商准入和数据合规要求。` + : `下一步建议:优先补齐专业数据库权限,再围绕预算窗口、采购节奏、供应商准入和数据合规要求继续确认。`); + const preferredJudgmentIds = firstJsonArray(preferredBody.find((item) => /^销售判断:/.test(item.text))?.citation_ids) + .filter((id) => allIds.includes(id)); + const preferredNextIds = firstJsonArray(preferredBody.find((item) => /^下一步建议:/.test(item.text))?.citation_ids) + .filter((id) => allIds.includes(id)); + return [ + { + text: companyText.startsWith("企业情况:") ? companyText : `企业情况:${companyText}`, + citation_ids: preferredCompanyIds.length ? preferredCompanyIds : professionalIds.slice(0, 2), + }, + { + text: latestText.startsWith("近期动态:") ? latestText : `近期动态:${latestText}`, + citation_ids: preferredLatestIds.length + ? preferredLatestIds + : webIds.length ? webIds.slice(0, 3) : professionalIds.slice(0, 1), + }, + { + text: judgmentText.startsWith("销售判断:") ? judgmentText : `销售判断:${judgmentText}`, + citation_ids: preferredJudgmentIds.length + ? preferredJudgmentIds + : [...new Set([...professionalIds.slice(0, 2), ...webIds.slice(0, 3), ...internalIds.slice(0, 2)])].slice(0, 5), + }, + { + text: nextText.startsWith("下一步建议:") ? nextText : `下一步建议:${nextText}`, + citation_ids: preferredNextIds.length + ? preferredNextIds + : [...new Set([...internalIds.slice(0, 2), ...webIds.slice(0, 2), ...professionalIds.slice(-1)])].slice(0, 5), + }, + ].map((item) => ({ + text: cleanEvidenceSummary(item.text, item.text, 520), + citation_ids: item.citation_ids.filter((id) => allIds.includes(id)), + })); + } + + fixedPublicDossierBody(dossier, body, company = {}, validationCitations = null) { + const citations = Array.isArray(validationCitations) + ? validationCitations + : firstJsonArray(dossier.citations); + if (body.length !== DOSSIER_SECTION_TITLES.length) return []; + const normalizedBody = body.slice(0, DOSSIER_SECTION_TITLES.length).map((item, index) => { + const title = DOSSIER_SECTION_TITLES[index]; + const sourceSegments = firstJsonArray(item.segments); + const segments = (sourceSegments.length + ? sourceSegments + : [{ + text: stripDossierSectionTitle(item.text), + citation_ids: firstJsonArray(item.citation_ids), + }]) + .map((segment) => ({ + text: ensureDossierLinePunctuation( + normalizeImportedText(normalizeSalesText(segment.text)) + .replace(/\s+/g, " ") + .trim(), + ), + citation_ids: [...new Set(firstJsonArray(segment.citation_ids).map(String))], + })) + .filter((segment) => segment.text && segment.citation_ids.length); + return { + ...item, + text: normalizeDossierSectionText(item.text, title), + citation_ids: [...new Set(firstJsonArray(item.citation_ids).map(String))], + segments, + }; + }); + const reportReady = DOSSIER_SECTION_TITLES.every((title, index) => ( + normalizedBody[index]?.text?.startsWith(`${title}:`) + && normalizedBody[index]?.citation_ids?.length + && normalizedBody[index]?.segments?.length + && normalizedBody[index].segments.every((segment) => segment.citation_ids.length) + )); + if (!reportReady) return []; + if (normalizedBody.some((item) => hasTechnicalErrorText(item.text))) return []; + if (dossierSectionContentErrors(normalizedBody).length) return []; + if (dossierSectionSemanticErrors(normalizedBody, citations, company).length) return []; + if (dossierSectionEvidenceGroundingErrors(normalizedBody, citations).length) return []; + return normalizedBody; + } + + progressFromDossier(company, dossier) { + const text = [dossier.summary, dossier.memory_summary].join(" "); + let label = "需求确认中"; + if (/预算|排期/.test(text)) label = "需求确认中"; + if (/初步|公开资料|缺少内部/.test(text)) label = "初步接触"; + if (/暂无|不足/.test(text)) label = "暂无有效信号"; + return { + label, + summary: conciseProgressSummary(label, dossier.memory_summary || dossier.summary), + evidence: "依据:最近档案和引用来源", + updated_at: nowIso(), + }; + } + + async storeDossierMemory(company, dossier, providerRunId = "") { + if (!this.openVikingProvider?.isRunEnabled?.()) { + await this.skipProviderStep(providerRunId, { + provider: "openviking", + operation: "store_dossier_memory", + input_summary: `写入 ${company.name} 的档案摘要`, + output_summary: "OpenViking 写入未启用。", + error: { code: "provider_disabled", message: "OpenViking writes are not enabled." }, + }); + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking writes are not enabled."); + } + return { status: "skipped", summary: "OpenViking 写入未启用。" }; + } + try { + const uri = this.openVikingProvider.salesDossierUri({ + workspaceId: this.workspaceId, + companyId: company.id, + dossierId: dossier.id, + }); + const content = [ + `# ${dossier.title}`, + "", + `企业:${company.name}`, + `档案 ID:${dossier.id}`, + `生成时间:${dossier.generated_at || dossier.created_at || nowIso()}`, + `摘要:${dossier.summary || ""}`, + `长期资料:${dossier.memory_summary || dossier.summary || ""}`, + ].join("\n"); + const result = await this.trackProviderStep(providerRunId, { + provider: "openviking", + operation: "store_dossier_memory", + input_summary: `写入 ${company.name} 的档案摘要`, + output_summary: "档案摘要已提交至 OpenViking。", + }, () => this.openVikingProvider.upsertTextResource({ + uri, + content, + mode: "create", + })); + const record = { + status: result.ok ? result.processing_status || "ready" : "failed", + raw_ref: result.raw_ref || null, + summary: result.ok + ? result.processing_status === "queued" + ? "最近档案结论已提交 OpenViking,正在异步建立索引。" + : "最近档案结论已写入 OpenViking 长期记忆。" + : `OpenViking 写入失败:${result.error?.code || "provider_error"}`, + }; + if (!result.ok && this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking dossier-memory write failed.", { + reason: result.error?.code || "provider_error", + }); + } + return record; + } catch (error) { + if (this.runtimePolicy.fail_closed) { + if (error instanceof HttpError) throw error; + throw providerUnavailable("openviking", "OpenViking dossier-memory write failed.", { + reason: error.message || "provider_error", + }); + } + return { + status: "failed", + raw_ref: null, + summary: `OpenViking 写入失败:${error.message || "provider_error"}`, + }; + } + } + + listMaterials(companyId) { + const company = this.requireCompany(companyId); + return (company.material_ids || []).map((id) => this.data.materials[id]).filter(Boolean).map((material) => ({ + id: material.id, + title: compactText(normalizeSalesText(material.title), 120), + summary: compactText(normalizeSalesText(material.summary || ""), 280), + source_type: compactText(material.source_type || "", 24), + source_url: publicSourceUrl(material.source_url), + source_id: material.source_id || null, + source_version: material.source_version || "", + content_hash: material.content_hash || null, + last_synced_at: material.last_synced_at || null, + updated_at: material.updated_at, + memory_status: material.openviking_status || (material.openviking_uri ? "indexed" : "pending"), + memory_ready: ["ready", "indexed"].includes(material.openviking_status || (material.openviking_uri ? "indexed" : "pending")), + })); + } + + listMaterialSyncSources(companyId) { + const company = this.requireCompany(companyId); + const materials = (company.material_ids || []).map((id) => this.data.materials[id]).filter(Boolean); + const grouped = new Map(); + for (const material of materials) { + if (!material.source_id) continue; + const items = grouped.get(material.source_id) || []; + items.push(material); + grouped.set(material.source_id, items); + } + + return [...grouped.entries()].map(([sourceId, sourceMaterials]) => { + const source = this.data.sync_sources?.[sourceId] || null; + const checkpoint = Object.values(this.data.sync_checkpoints || {}) + .filter((item) => item?.source_id === sourceId) + .sort((left, right) => String(right.updated_at || "").localeCompare(String(left.updated_at || "")))[0] || null; + const latestMaterial = [...sourceMaterials] + .sort((left, right) => String(right.updated_at || "").localeCompare(String(left.updated_at || "")))[0]; + const openVikingStatuses = sourceMaterials.reduce((counts, material) => { + const status = material.openviking_status || (material.openviking_uri ? "indexed" : "pending"); + counts[status] = (counts[status] || 0) + 1; + return counts; + }, {}); + return { + id: sourceId, + source_type: compactText(source?.source_type || latestMaterial?.source_type || "manual", 24), + external_id: compactText(source?.external_id || latestMaterial?.source_external_id || "", 240), + display_name: compactText(source?.display_name || latestMaterial?.title || "资料同步源", 120), + status: source?.status || "unmanaged", + material_count: sourceMaterials.length, + material_ids: sourceMaterials.map((material) => material.id), + last_synced_at: source?.last_synced_at || latestMaterial?.last_synced_at || null, + updated_at: source?.updated_at || latestMaterial?.updated_at || null, + checkpoint: checkpoint ? { + checkpoint_key: checkpoint.checkpoint_key || "latest", + checkpoint_value: checkpoint.checkpoint_value || "", + last_success_at: checkpoint.last_success_at || null, + error: checkpoint.error || null, + updated_at: checkpoint.updated_at || null, + } : null, + openviking_statuses: openVikingStatuses, + }; + }).sort((left, right) => String(right.updated_at || "").localeCompare(String(left.updated_at || ""))); + } + + resolveMaterialSyncContext(company, input = {}, { requireExisting = false } = {}) { + const requestedSourceId = compactText(input.source_id || input.sourceId || "", 240); + if (requestedSourceId) { + const material = (company.material_ids || []) + .map((id) => this.data.materials[id]) + .find((item) => item?.source_id === requestedSourceId) || null; + const source = this.data.sync_sources?.[requestedSourceId] || null; + if (!material || !source) { + throw new HttpError(404, "sync_source_not_found", "当前企业未找到对应的资料同步源。", { + source_id: requestedSourceId, + company_id: company.id, + }); + } + const checkpoint = Object.values(this.data.sync_checkpoints || {}) + .filter((item) => item?.source_id === requestedSourceId) + .sort((left, right) => String(right.updated_at || "").localeCompare(String(left.updated_at || "")))[0] || null; + return { + identity: { + source_id: requestedSourceId, + material_id: material.id, + checkpoint_key: checkpoint?.checkpoint_key || "latest", + }, + source, + checkpoint, + material, + }; + } + + const identity = buildMaterialSyncIdentity(company.id, input); + const source = this.data.sync_sources?.[identity.source_id] || null; + const checkpoint = this.data.sync_checkpoints?.[`${identity.source_id}:${identity.checkpoint_key}`] || null; + const material = this.data.materials?.[identity.material_id] + || (company.material_ids || []).map((id) => this.data.materials[id]).find((item) => item?.source_id === identity.source_id) + || null; + if (requireExisting && (!source || !material)) { + throw new HttpError(404, "sync_source_not_found", "当前企业未找到对应的资料同步源。", { + source_id: identity.source_id, + company_id: company.id, + }); + } + return { identity, source, checkpoint, material }; + } + + async restoreMaterialContent(material) { + if (!material) return null; + if (cleanMaterialText(material.text) || normalizeSourceItems(material.source_items).length) { + return material; + } + const uri = compactText(material.openviking_uri || material.openviking_ref || "", 1000); + if (!uri || typeof this.openVikingProvider?.readTextResource !== "function") { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "Existing material content cannot be restored from OpenViking.", { + material_id: material.id, + reason: uri ? "read_not_supported" : "missing_resource_uri", + }); + } + return material; + } + + const result = await this.openVikingProvider.readTextResource(uri); + if (!result.ok) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "Existing material content could not be read from OpenViking.", { + material_id: material.id, + reason: result.error?.code || "provider_error", + }); + } + return material; + } + + const snapshot = decodeMaterialSnapshot(result.content); + const restoredText = cleanMaterialText(snapshot?.text || legacyMaterialText(result.content)); + let restoredItems = normalizeSourceItems(snapshot?.source_items); + if (!restoredItems.length && restoredText) { + restoredItems = normalizeSourceItems([{ + id: `legacy-${String(material.content_hash || material.id || "material").slice(0, 40)}`, + occurred_at: material.occurred_at || null, + sender: "历史导入", + content: restoredText, + source_url: material.source_url || "", + }]); + } + if (!restoredText && !restoredItems.length && this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "The OpenViking material resource did not contain restorable content.", { + material_id: material.id, + reason: "invalid_material_resource", + }); + } + return { + ...material, + ...(snapshot || {}), + id: material.id, + company_id: material.company_id, + text: restoredText || cleanMaterialText(renderSourceItems(restoredItems)), + source_items: restoredItems, + openviking_uri: uri, + }; + } + + async importMaterial(companyId, body = {}) { + const company = this.requireCompany(companyId); + const title = compactText(body.title, 120); + if (!title) throw new HttpError(400, "bad_request", "资料标题不能为空。"); + const identity = buildMaterialSyncIdentity(company.id, { ...body, title }); + const existingMetadata = this.data.materials[identity.material_id] + || (company.material_ids || []) + .map((id) => this.data.materials[id]) + .find((item) => item?.source_id === identity.source_id) + || null; + const incomingItems = normalizeSourceItems(body.source_items || body.items); + const suppliedText = cleanMaterialText(body.raw_text || body.text || body.content); + const existing = existingMetadata + && incomingItems.length + && !cleanMaterialText(existingMetadata.text) + && !normalizeSourceItems(existingMetadata.source_items).length + ? await this.restoreMaterialContent(existingMetadata) + : existingMetadata; + const sourceItems = incomingItems.length + ? mergeSourceItems(existing?.source_items || [], incomingItems) + : existing?.source_items || []; + const rawText = cleanMaterialText( + sourceItems.length && (incomingItems.length || !suppliedText) + ? renderSourceItems(sourceItems) + : suppliedText || existing?.text, + ); + if (!rawText) throw new HttpError(400, "bad_request", "资料内容不能为空。"); + + const previousSource = this.data.sync_sources?.[identity.source_id] || null; + if (previousSource?.status === "paused" && !body.resume_source) { + throw new HttpError(409, "sync_source_paused", "该资料源已暂停,请明确恢复后再同步。", { + source_id: identity.source_id, + }); + } + + const job = await this.startJob({ + job_type: "sales_material_import", + entity_type: "target_enterprise", + entity_id: company.id, + max_attempts: 1, + request: { + title, + source_type: identity.source_type, + source_external_id: identity.external_id, + }, + }); + let run = null; + const now = nowIso(); + try { + run = await this.providerRuns.startRun({ + operation: "feishu_material_import", + entity_type: "target_enterprise", + entity_id: company.id, + job_id: job.id, + }); + const summary = compactText(body.summary || existing?.summary || rawText, 280) || this.inferMaterialSummary(title); + const candidate = { + id: existing?.id || identity.material_id, + company_id: company.id, + title, + source_type: identity.source_type, + source_url: compactText(identity.source_url, 500), + source_id: identity.source_id, + source_external_id: identity.external_id, + source_version: identity.source_version, + summary, + text: rawText, + source_items: sourceItems, + occurred_at: body.occurred_at || existing?.occurred_at || null, + last_synced_at: now, + created_at: existing?.created_at || now, + updated_at: existing?.updated_at || now, + openviking_uri: existing?.openviking_uri || "", + openviking_ref: existing?.openviking_ref || "", + openviking_status: existing?.openviking_status || "pending", + }; + candidate.content_hash = makeMaterialContentHash(candidate); + const contentChanged = !existing || existing.content_hash !== candidate.content_hash; + if (contentChanged && existing) candidate.updated_at = now; + const alreadyIndexed = ["ready", "indexed"].includes(existing?.openviking_status); + const action = !existing ? "created" : contentChanged ? "updated" : alreadyIndexed ? "unchanged" : "retried"; + + const sourceRecord = { + id: identity.source_id, + source_type: identity.source_type, + external_id: identity.external_id, + display_name: identity.display_name, + status: "active", + config: identity.config, + last_synced_at: now, + created_at: previousSource?.created_at || now, + updated_at: now, + }; + const checkpointId = `${identity.source_id}:${identity.checkpoint_key}`; + const previousCheckpoint = this.data.sync_checkpoints?.[checkpointId] || null; + const checkpoint = { + id: checkpointId, + source_id: identity.source_id, + checkpoint_key: identity.checkpoint_key, + checkpoint_value: identity.checkpoint_value, + content_hash: candidate.content_hash, + last_success_at: previousCheckpoint?.last_success_at || null, + error: null, + created_at: previousCheckpoint?.created_at || now, + updated_at: now, + }; + + await this.trackProviderStep(run.id, { + provider: "supabase", + operation: "persist_material_sync_state", + input_summary: `保存 ${company.name} 的资料源、同步游标和材料`, + output_summary: `同步状态已保存,处理结果为 ${action}。`, + }, async () => { + await this.persist(() => this.repository.persistSyncSource(sourceRecord)); + await this.persist(() => this.repository.persistSalesMaterial(candidate)); + await this.persist(() => this.repository.persistSyncCheckpoint(checkpoint)); + return { ok: true, provider: "supabase", provider_mode: this.persistence.enabled ? "real" : "disabled" }; + }); + + this.data.sync_sources = this.data.sync_sources || {}; + this.data.sync_checkpoints = this.data.sync_checkpoints || {}; + this.data.sync_sources[sourceRecord.id] = sourceRecord; + this.data.sync_checkpoints[checkpoint.id] = checkpoint; + this.data.materials[candidate.id] = candidate; + company.material_ids = [candidate.id, ...(company.material_ids || []).filter((id) => id !== candidate.id)]; + + let record; + if (action === "unchanged") { + await this.skipProviderStep(run.id, { + provider: "openviking", + operation: "upsert_material_resource", + input_summary: `检查 ${company.name} 的资料内容指纹`, + output_summary: "内容指纹未变化,未重复写入 OpenViking。", + }); + record = { + ok: true, + material_id: candidate.id, + title: candidate.title, + status: candidate.openviking_status, + raw_ref: candidate.openviking_ref || candidate.openviking_uri || null, + uri: candidate.openviking_uri || "", + summary: "内容未变化,已跳过重复写入。", + created_at: now, + }; + } else { + record = await this.trackProviderStep(run.id, { + provider: "openviking", + operation: "upsert_material_resource", + input_summary: `在 ${company.name} 的独立目录写入资料 ${candidate.title}`, + output_summary: "资料已写入当前企业的 OpenViking 目录。", + }, () => this.writeMaterialToOpenViking(company, candidate, { + mode: existing?.openviking_uri ? "replace" : "create", + })); + } + + candidate.openviking_status = record.status; + candidate.openviking_uri = record.uri || candidate.openviking_uri || ""; + candidate.openviking_ref = record.raw_ref || candidate.openviking_ref || ""; + sourceRecord.status = record.status === "failed" ? "error" : "active"; + checkpoint.last_success_at = record.status === "failed" ? previousCheckpoint?.last_success_at || null : now; + checkpoint.error = record.status === "failed" + ? { code: record.error?.code || "openviking_write_failed", message: record.summary } + : null; + + await this.persist(() => this.repository.persistSyncSource(sourceRecord)); + await this.persist(() => this.repository.persistSalesMaterial(candidate)); + await this.persist(() => this.repository.persistSyncCheckpoint(checkpoint)); + if (record.status !== "skipped" && record.uri) { + await this.persist(() => this.repository.persistSalesOpenVikingRef({ + company_id: company.id, + related_type: "material", + related_id: candidate.id, + ref_kind: "resource_import", + uri: record.uri, + summary: record.summary, + created_at: record.created_at, + payload_json: { source_id: candidate.source_id, content_hash: candidate.content_hash, record }, + })); + } + this.data.sync_sources[sourceRecord.id] = sourceRecord; + this.data.sync_checkpoints[checkpoint.id] = checkpoint; + this.data.materials[candidate.id] = candidate; + + await this.providerRuns.completeRun(run.id, { result_ref: `material:${candidate.id}:${action}` }); + await this.completeJob(job.id, { + result_ref: `material:${candidate.id}:${action}`, + result: { action, material_id: candidate.id }, + }); + return { + action, + source: clone(sourceRecord), + checkpoint: clone(checkpoint), + material: { + id: candidate.id, + title: candidate.title, + summary: candidate.summary, + source_id: candidate.source_id, + source_version: candidate.source_version, + content_hash: candidate.content_hash, + last_synced_at: candidate.last_synced_at, + updated_at: candidate.updated_at, + openviking_status: candidate.openviking_status, + }, + openviking_record: { + material_id: record.material_id, + title: record.title, + status: record.status, + summary: record.summary, + created_at: record.created_at, + }, + provider_run_id: run.id, + job_id: job.id, + materials: this.listMaterials(company.id), + }; + } catch (error) { + if (run) { + try { + await this.providerRuns.failRun(run.id, { + code: error.code || "material_import_failed", + message: error.message || "Material import failed.", + category: error.category || "workflow", + retryable: error.retryable, + }); + } catch (persistenceError) { + if (this.runtimePolicy.fail_closed) throw persistenceError; + } + } + await this.failJob(job.id, error); + throw error; + } + } + + getMaterialSyncState(companyId, input = {}) { + const company = this.requireCompany(companyId); + const { identity, source, checkpoint, material } = this.resolveMaterialSyncContext(company, input); + return { + source_id: identity.source_id, + source: source ? clone(source) : null, + checkpoint: checkpoint ? clone(checkpoint) : null, + material: material ? { + id: material.id, + content_hash: material.content_hash || null, + source_version: material.source_version || "", + last_synced_at: material.last_synced_at || null, + openviking_status: material.openviking_status || "pending", + } : null, + }; + } + + async updateMaterialSyncSource(companyId, body = {}) { + const company = this.requireCompany(companyId); + const action = String(body.action || "").trim().toLowerCase(); + if (!['pause', 'resume', 'delete'].includes(action)) { + throw new HttpError(400, "bad_request", "action 必须是 pause、resume 或 delete。"); + } + const { identity, source } = this.resolveMaterialSyncContext(company, body, { requireExisting: true }); + + const now = nowIso(); + if (action !== "delete") { + const updatedSource = { + ...source, + status: action === "pause" ? "paused" : "active", + updated_at: now, + }; + await this.persist(() => this.repository.persistSyncSource(updatedSource)); + this.data.sync_sources[identity.source_id] = updatedSource; + return { + action, + source: clone(updatedSource), + affected_material_ids: [], + warnings: [], + }; + } + + const job = await this.startJob({ + job_type: "sales_material_source_delete", + entity_type: "sync_source", + entity_id: identity.source_id, + max_attempts: 1, + request: { company_id: company.id, source_id: identity.source_id }, + }); + let run = null; + try { + run = await this.providerRuns.startRun({ + operation: "material_sync_source_delete", + entity_type: "sync_source", + entity_id: identity.source_id, + job_id: job.id, + }); + const materials = (company.material_ids || []) + .map((id) => this.data.materials[id]) + .filter((material) => material?.source_id === identity.source_id); + const warnings = []; + for (const material of materials) { + if (material.openviking_uri) { + if (this.openVikingProvider?.isRunEnabled?.() && typeof this.openVikingProvider.removeResource === "function") { + const removal = await this.trackProviderStep(run.id, { + provider: "openviking", + operation: "remove_material_resource", + input_summary: `删除资料资源 ${material.openviking_uri}`, + output_summary: "OpenViking 资料资源已删除。", + }, () => this.openVikingProvider.removeResource(material.openviking_uri)); + if (!removal.ok) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking material deletion failed.", { + reason: removal.error?.code || "provider_error", + }); + } + warnings.push(`OpenViking 资源删除失败:${material.openviking_uri}`); + } + } else { + await this.skipProviderStep(run.id, { + provider: "openviking", + operation: "remove_material_resource", + input_summary: `删除资料资源 ${material.openviking_uri}`, + output_summary: "OpenViking 删除能力未启用。", + error: { code: "provider_disabled", message: "OpenViking resource removal is not enabled." }, + }); + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking material deletion is not enabled."); + } + warnings.push(`OpenViking 未启用,资源可能仍需人工清理:${material.openviking_uri}`); + } + } + await this.persist(() => this.repository.softDeleteSalesMaterial(material.id, now)); + delete this.data.materials[material.id]; + company.material_ids = (company.material_ids || []).filter((id) => id !== material.id); + } + + const deletedSource = { + ...source, + status: "deleted", + updated_at: now, + }; + await this.persist(() => this.repository.persistSyncSource(deletedSource)); + this.data.sync_sources[identity.source_id] = deletedSource; + await this.providerRuns.completeRun(run.id, { result_ref: `sync-source:${identity.source_id}:deleted` }); + await this.completeJob(job.id, { + result_ref: `sync-source:${identity.source_id}:deleted`, + result: { source_id: identity.source_id, deleted_material_count: materials.length }, + }); + return { + action, + source: clone(deletedSource), + affected_material_ids: materials.map((material) => material.id), + warnings, + provider_run_id: run.id, + job_id: job.id, + }; + } catch (error) { + if (run) { + try { + await this.providerRuns.failRun(run.id, { + code: error.code || "sync_source_delete_failed", + message: error.message || "Sync source deletion failed.", + category: error.category || "workflow", + retryable: error.retryable, + }); + } catch (persistenceError) { + if (this.runtimePolicy.fail_closed) throw persistenceError; + } + } + await this.failJob(job.id, error); + throw error; + } + } + + async syncMaterialsToOpenViking(companyId, options = {}) { + const company = this.requireCompany(companyId); + const materials = (company.material_ids || []).map((id) => this.data.materials[id]).filter(Boolean); + const reportProgress = typeof options.report_progress === "function" + ? options.report_progress + : async () => {}; + if (!materials.length) { + if (options.claimed_job) { + this.data.jobs[options.claimed_job.id] = clone(options.claimed_job); + await this.completeJob(options.claimed_job.id, { + result_ref: `material-sync:${company.id}:skipped`, + result: { status: "skipped", material_count: 0, failed_count: 0 }, + }); + } + return { + status: "skipped", + summary: "当前企业还没有可导入的历史资料。", + records: [], + }; + } + if (!this.openVikingProvider?.isRunEnabled?.()) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking writes are not enabled."); + } + return { + status: "skipped", + summary: "OpenViking 写入未启用。", + records: materials.map((material) => ({ + material_id: material.id, + title: material.title, + status: "skipped", + })), + }; + } + + const job = options.claimed_job + ? await this.activateClaimedJob(options.claimed_job, "sales_material_openviking_sync") + : await this.startJob({ + job_type: "sales_material_openviking_sync", + entity_type: "target_enterprise", + entity_id: company.id, + max_attempts: 3, + request: { material_count: materials.length }, + }); + let run = null; + try { + await reportProgress("syncing_materials", 8); + run = await this.providerRuns.startRun({ + operation: "material_openviking_sync", + entity_type: "target_enterprise", + entity_id: company.id, + job_id: job.id, + }); + const records = []; + for (const [index, material] of materials.entries()) { + await this.assertJobActive(job.id); + await reportProgress("syncing_materials", 10 + Math.floor((index / materials.length) * 75)); + const hasLocalContent = Boolean( + cleanMaterialText(material.text) + || normalizeSourceItems(material.source_items).length, + ); + let record; + if (!hasLocalContent && material.openviking_uri && ["ready", "indexed"].includes(material.openviking_status)) { + await this.skipProviderStep(run.id, { + provider: "openviking", + operation: "upsert_material_resource", + input_summary: `检查 ${company.name} 的资料 ${material.title}`, + output_summary: "正文已由 OpenViking 保存,无需从 Supabase 重复读取或覆盖。", + }); + record = { + ok: true, + material_id: material.id, + title: material.title, + status: material.openviking_status, + raw_ref: material.openviking_ref || material.openviking_uri, + uri: material.openviking_uri, + summary: "资料正文已存在于 OpenViking。", + created_at: nowIso(), + }; + } else if (!hasLocalContent) { + throw providerUnavailable("openviking", "Material metadata exists but its OpenViking content is unavailable.", { + material_id: material.id, + reason: "missing_material_content", + }); + } else { + record = await this.trackProviderStep(run.id, { + provider: "openviking", + operation: "upsert_material_resource", + input_summary: `在 ${company.name} 的独立目录写入资料 ${material.title}`, + output_summary: "资料已写入当前企业的 OpenViking 目录。", + }, () => this.writeMaterialToOpenViking(company, material)); + } + material.openviking_status = record.status; + material.openviking_ref = record.raw_ref || material.openviking_uri || ""; + material.openviking_uri = record.uri || material.openviking_uri || ""; + records.push(record); + await this.trackProviderStep(run.id, { + provider: "supabase", + operation: "persist_material_memory_ref", + input_summary: `保存资料 ${material.title} 的记忆索引状态`, + output_summary: "资料记忆索引状态已保存。", + }, async () => { + await this.persist(() => this.repository.persistSalesMaterial(material)); + if (record.uri) { + await this.persist(() => this.repository.persistSalesOpenVikingRef({ + company_id: company.id, + related_type: "material", + related_id: material.id, + ref_kind: "memory_import", + uri: record.uri, + summary: record.summary, + payload_json: { + material_id: material.id, + source_id: material.source_id || null, + content_hash: material.content_hash || null, + status: record.status, + }, + })); + } + return { ok: true, provider: "supabase", provider_mode: this.persistence.enabled ? "real" : "disabled" }; + }); + } + + const failed = records.filter((record) => record.status === "failed").length; + const status = failed ? "partial" : "ready"; + const summary = failed + ? `${records.length - failed}/${records.length} 条历史资料已写入 OpenViking。` + : `${records.length} 条历史资料已写入 OpenViking。`; + await reportProgress("persisting_result", 94); + await this.providerRuns.completeRun(run.id, { result_ref: `material-sync:${company.id}:${status}` }); + await this.completeJob(job.id, { + result_ref: `material-sync:${company.id}:${status}`, + result: { status, material_count: records.length, failed_count: failed }, + }); + return { + status, + summary, + records: records.map((record) => ({ + material_id: record.material_id, + title: record.title, + status: record.status, + summary: record.summary, + created_at: record.created_at, + })), + provider_run_id: run.id, + job_id: job.id, + }; + } catch (error) { + if (run) { + try { + if (error.code === "job_cancelled") { + await this.providerRuns.cancelRun(run.id, { summary: "资料记忆同步任务已由用户取消。" }); + } else { + await this.providerRuns.failRun(run.id, { + code: error.code || "material_openviking_sync_failed", + message: error.message || "Material memory sync failed.", + category: error.category || "workflow", + retryable: error.retryable, + }); + } + } catch (persistenceError) { + if (this.runtimePolicy.fail_closed) throw persistenceError; + } + } + await this.failJob(job.id, error); + throw error; + } + } + + async writeMaterialToOpenViking(company, material, options = {}) { + if (!this.openVikingProvider?.isRunEnabled?.()) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking writes are not enabled."); + } + return { + material_id: material.id, + title: material.title, + status: "skipped", + raw_ref: null, + uri: material.openviking_uri || "", + summary: "OpenViking 写入未启用。", + created_at: nowIso(), + }; + } + let result; + try { + const content = this.buildMaterialMemory(company, material); + if (typeof this.openVikingProvider.upsertTextResource === "function") { + result = await this.openVikingProvider.upsertTextResource({ + uri: this.openVikingProvider.salesMaterialUri({ + workspaceId: this.workspaceId, + companyId: company.id, + sourceId: material.source_id || material.id, + }), + content, + mode: options.mode || (material.openviking_uri ? "replace" : "create"), + }); + } else { + result = await this.openVikingProvider.storeMemory([{ role: "user", content }]); + } + } catch (error) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking material write failed.", { + reason: error.message || "provider_error", + }); + } + result = { ok: false, error: { code: error.message || "provider_error" }, raw_ref: null }; + } + if (!result.ok && this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking material write failed.", { + reason: result.error?.code || "provider_error", + }); + } + return { + ok: result.ok, + material_id: material.id, + title: material.title, + status: result.ok ? "ready" : "failed", + raw_ref: result.raw_ref || null, + uri: result.uri || result.raw_ref || material.openviking_uri || "", + summary: result.ok + ? "历史资料已写入 OpenViking 长期记忆。" + : `OpenViking 写入失败:${result.error?.code || "provider_error"}`, + created_at: nowIso(), + error: result.error || null, + }; + } + + buildMaterialMemory(company, material) { + return [ + "销售历史资料需要作为长期记忆保存。", + `企业:${company.name}`, + `资料标题:${material.title}`, + `资料来源:${material.source_type || "Codex 整理的飞书沟通、会议纪要或云文档"}`, + material.source_url ? `来源链接:${material.source_url}` : "", + material.occurred_at || material.updated_at ? `资料时间:${material.occurred_at || material.updated_at}` : "", + material.openviking_uri ? `原始资源 URI:${material.openviking_uri}` : "", + `资料摘要:${material.summary || this.inferMaterialSummary(material.title)}`, + material.text ? `资料正文:${material.text}` : "", + "使用边界:后续资料问答可以引用该资料;最近档案不得引用该资料,最近档案只能使用专业数据集和豆包搜索。", + encodeMaterialSnapshot(material), + ].filter(Boolean).join("\n"); + } + + inferMaterialSummary(title) { + const text = String(title || ""); + if (/会议纪要/.test(text)) return "会议资料中通常包含客户关注点、预算排期、部署要求和下一步行动,需要在销售跟进中优先召回。"; + if (/方案|讨论/.test(text)) return "方案讨论资料通常包含客户需求、技术约束和供应商准入要求,需要用于判断当前推进状态。"; + if (/沟通|摘录/.test(text)) return "历史沟通摘录用于补充客户背景、已确认事项和资料缺口。"; + return "该历史资料用于补充销售跟进中的长期上下文。"; + } + + qaView(company, messages = []) { + const hasMaterials = (company.material_ids || []) + .map((id) => this.data.materials[id]) + .filter(Boolean) + .some(isFeishuMaterial); + return { + messages: this.compatibleQaMessages(company, messages) + .map((message) => this.publicQaMessage(message)), + note: hasMaterials + ? "仅根据当前企业档案和用户导入的飞书资料回答。" + : "当前企业暂无飞书资料;问答仅根据当前企业档案回答。", + }; + } + + cachedQa(companyId) { + const company = this.requireCompany(companyId); + return this.qaView(company, this.data.qa_messages[companyId] || []); + } + + async loadQaSessionState(company, options = {}) { + const fallbackMessages = this.compatibleQaMessages( + company, + this.data.qa_messages[company.id] || [], + ); + if ( + !this.openVikingProvider?.isConfigured?.() + || typeof this.openVikingProvider?.getSessionContext !== "function" + ) { + if (options.failOnUnavailable && this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking session retrieval is not configured."); + } + return { + ok: true, + provider: "openviking", + provider_mode: "ephemeral", + session_id: this.openVikingSessionId(company), + messages: fallbackMessages, + latest_archive_overview: "", + summary: "OpenViking 会话读取未配置,当前仅使用进程内会话。", + }; + } + + const sessionId = this.openVikingSessionId(company); + const result = await this.openVikingProvider.getSessionContext(sessionId, { tokenBudget: 6000 }); + if (!result.ok && openVikingNotFound(result)) { + this.data.qa_messages[company.id] = []; + return { + ok: true, + provider: "openviking", + provider_mode: "real", + session_id: sessionId, + messages: [], + latest_archive_overview: "", + summary: "当前企业尚未建立 OpenViking 问答会话。", + }; + } + if (!result.ok) { + if (options.failOnUnavailable && this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking session retrieval failed.", { + reason: result.error?.code || "provider_error", + }); + } + return { + ok: true, + provider: "openviking", + provider_mode: "ephemeral", + session_id: sessionId, + messages: fallbackMessages, + latest_archive_overview: "", + summary: "OpenViking 会话暂不可读,保留当前进程内会话。", + }; + } + + const messages = firstJsonArray(result.messages) + .map((message, index) => decodeQaSessionMessage(message, index)) + .filter((message) => message.text); + this.data.qa_messages[company.id] = messages; + return { + ok: true, + provider: "openviking", + provider_mode: "real", + session_id: result.session_id || sessionId, + messages, + latest_archive_overview: compactText(result.latest_archive_overview || "", 3000), + raw_ref: result.raw_ref || `openviking:session:${sessionId}:context`, + summary: `已从 OpenViking 恢复 ${messages.length} 条近期会话。`, + }; + } + + async getQa(companyId) { + const company = this.requireCompany(companyId); + const session = await this.loadQaSessionState(company); + return this.qaView(company, session.messages); + } + + isAllowedQaCitation(company, citation) { + const sourceKind = compactText(citation?.source_kind || "", 80); + if (sourceKind === "企业档案") return true; + if (!/内部资料|飞书|云文档|会议纪要|会话/.test(sourceKind)) return false; + + const uri = compactText(citation?.uri || "", 1000); + if (uri.includes("/materials/")) return true; + + const label = compactText(citation?.label || "", 240); + const allowedMaterialIdentities = (company.material_ids || []) + .map((id) => this.data.materials[id]) + .filter(Boolean) + .filter(isFeishuMaterial) + .flatMap((material) => [ + compactText(material.title || "", 240), + compactText(material.openviking_uri || material.openviking_ref || "", 1000), + compactText(material.source_url || "", 1000), + ]) + .filter(Boolean); + return allowedMaterialIdentities.includes(label) || allowedMaterialIdentities.includes(uri); + } + + isCompatibleQaAnswer(company, message) { + if (message?.role !== "assistant") return true; + if (hasLegacyGenericQaCitations(message)) return false; + const citations = firstJsonArray(message.citations); + if (!citations.length) return true; + return citations.every((citation) => this.isAllowedQaCitation(company, citation)); + } + + compatibleQaMessages(company, messages = []) { + const source = firstJsonArray(messages); + const compatible = []; + for (let index = 0; index < source.length; index += 1) { + const message = source[index]; + if (message?.role === "user" && source[index + 1]?.role === "assistant") { + const answer = source[index + 1]; + if (this.isCompatibleQaAnswer(company, answer)) compatible.push(message, answer); + index += 1; + continue; + } + if (this.isCompatibleQaAnswer(company, message)) compatible.push(message); + } + return compatible; + } + + publicQaMessage(message) { + const displayText = message.role === "assistant" ? sanitizeQaDisplayText : normalizeSalesText; + const displaySources = mergeQaDisplayCitations(message); + return { + id: message.id, + role: message.role, + text: displayText(message.text), + paragraphs: displaySources.paragraphs.map((paragraph) => ({ + text: displayText(paragraph.text), + citation_ids: firstJsonArray(paragraph.citation_ids).map(String), + })), + citation_ids: displaySources.citation_ids, + citations: displaySources.citations.map((citation) => publicCitationView(citation)), + insufficient: Boolean(message.insufficient), + created_at: message.created_at || null, + }; + } + + async askQuestion(companyId, body = {}, options = {}) { + const company = this.requireCompany(companyId); + const question = String(body.question || "").trim(); + if (!question) throw new HttpError(400, "bad_request", "问题不能为空。"); + const job = await this.startJob({ + job_type: "sales_qa", + entity_type: "target_enterprise", + entity_id: company.id, + max_attempts: 3, + request: { question }, + retry_job_id: options.retry_job_id || "", + }); + let run = null; + + try { + run = await this.providerRuns.startRun({ + operation: "sales_qa", + entity_type: "target_enterprise", + entity_id: company.id, + job_id: job.id, + }); + const sessionState = await this.trackProviderStep(run.id, { + provider: "openviking", + operation: "restore_qa_session", + input_summary: `恢复 ${company.name} 的近期问答和长期会话摘要`, + output_summary: "已从 OpenViking 恢复企业问答上下文。", + }, () => this.loadQaSessionState(company, { failOnUnavailable: true })); + const messages = [...this.compatibleQaMessages(company, sessionState.messages)]; + const conversationHistory = qaConversationHistory(messages); + const userMessage = { + id: makeId("qa_user"), + role: "user", + text: question, + created_at: nowIso(), + }; + userMessage.provider_run_id = run.id; + const retrieval = await this.trackProviderStep(run.id, { + provider: "openviking", + operation: "retrieve_qa_context", + input_summary: `仅在 ${company.name} 的飞书资料目录中执行多查询检索并读取命中原文`, + }, async () => { + const queries = qaRetrievalQueries(company, question, conversationHistory); + const queryResults = []; + for (const query of queries) { + queryResults.push({ + query, + contexts: await this.searchOpenViking(company, query), + }); + } + const matchedContexts = fuseQaRetrievalContexts(queryResults, { + maxContexts: 10, + maxPerMaterial: 2, + }); + const contexts = await this.hydrateOpenVikingContexts(company, matchedContexts); + return { + ok: true, + provider: "openviking", + provider_mode: this.openVikingProvider?.isConfigured?.() ? "real" : "fallback", + contexts, + query_plan: queries, + retrieval_trace: matchedContexts.map((context) => ({ + material_id: context.material_id, + uri: context.uri, + query_hits: context.query_hits, + best_rank: context.best_rank, + fusion_score: context.fusion_score, + })), + summary: `已执行 ${queries.length} 个检索查询,经融合排序后读取 ${contexts.length} 份企业范围内资料。`, + }; + }); + await this.assertJobActive(job.id); + const dossier = (company.dossier_ids || []) + .map((id) => this.data.dossiers[id]) + .filter(Boolean) + .sort((a, b) => Number(b.version_no || 1) - Number(a.version_no || 1) + || String(b.created_at || "").localeCompare(String(a.created_at || "")))[0] || null; + const evidenceResult = await this.trackProviderStep(run.id, { + provider: "rule", + operation: "build_qa_evidence", + input_summary: `对 ${company.name} 当前档案与命中资料分块、重排并执行可回答性判断`, + }, async () => { + const evidence = buildQaEvidence({ + dossier, + contexts: retrieval.contexts, + question, + conversationHistory, + maxItems: 12, + }); + const answerability = assessQaAnswerability(question, evidence, conversationHistory); + return { + ok: true, + provider: "rule", + provider_mode: "local", + evidence, + answerability, + summary: `已建立 ${evidence.length} 个可引用证据片段;可回答性=${answerability.supported ? "通过" : "不足"}。`, + }; + }); + const answer = await this.generateQaAnswer( + company, + question, + dossier, + retrieval.contexts, + evidenceResult.evidence, + run.id, + conversationHistory, + sessionState.latest_archive_overview, + evidenceResult.answerability, + ); + await this.assertJobActive(job.id); + answer.provider_run_id = run.id; + + const captured = await this.trackProviderStep(run.id, { + provider: "openviking", + operation: "capture_qa_session", + input_summary: `把 ${company.name} 的本轮问答写入企业会话`, + output_summary: "问答会话已提交给 OpenViking。", + }, () => this.captureQaSession(company, userMessage, answer, retrieval.contexts)); + await this.assertJobActive(job.id); + messages.push(userMessage, answer); + this.data.qa_messages[companyId] = messages; + + const assistantRounds = messages.filter((message) => message.role === "assistant").length; + let commitStatus = "not_due"; + if ( + captured?.ok + && this.qaAutoCommitEvery > 0 + && assistantRounds > 0 + && assistantRounds % this.qaAutoCommitEvery === 0 + ) { + const committed = await this.trackProviderStep(run.id, { + provider: "openviking", + operation: "commit_qa_long_term_memory", + input_summary: `从 ${company.name} 的问答会话提炼长期记忆并保留最近对话`, + output_summary: "已提交 OpenViking 长期记忆提炼。", + }, () => this.openVikingProvider.commitSession(captured.session_id, { + keepRecentCount: this.qaKeepRecentMessages, + })); + commitStatus = committed?.ok ? "submitted" : "failed"; + } + + if (this.persistence.enabled && this.repository) { + await this.trackProviderStep(run.id, { + provider: "supabase", + operation: "persist_qa_session_metadata", + input_summary: `保存 ${company.name} 的 OpenViking 会话索引和业务状态`, + output_summary: "仅保存了会话 URI、消息计数和同步状态。", + }, async () => { + await this.persist(() => this.repository.persistSalesCompany(company)); + await this.persist(() => this.repository.persistSalesOpenVikingRef({ + company_id: company.id, + related_type: "qa_session", + related_id: captured?.session_id || this.openVikingSessionId(company), + ref_kind: "session", + uri: captured?.raw_ref || `openviking:session:${captured?.session_id || this.openVikingSessionId(company)}`, + summary: "企业资料问答会话由 OpenViking 保存。", + payload_json: { + session_id: captured?.session_id || this.openVikingSessionId(company), + message_count: messages.length, + last_message_at: answer.created_at, + commit_status: commitStatus, + }, + })); + return { ok: true, provider: "supabase", provider_mode: "real" }; + }); + } else { + await this.skipProviderStep(run.id, { + provider: "supabase", + operation: "persist_qa_session_metadata", + input_summary: `保存 ${company.name} 的会话索引`, + output_summary: "当前配置未启用持久化仓库。", + error: { code: "repository_disabled", message: "Persistent repository is not enabled." }, + }); + } + await this.assertJobActive(job.id); + await this.providerRuns.completeRun(run.id, { result_ref: `qa_message:${answer.id}` }); + await this.completeJob(job.id, { + result_ref: `qa_message:${answer.id}`, + result: { message_id: answer.id, insufficient: answer.insufficient }, + }); + return { + message: this.publicQaMessage(answer), + messages: this.compatibleQaMessages(company, messages) + .map((message) => this.publicQaMessage(message)), + provider_run_id: run.id, + job_id: job.id, + }; + } catch (error) { + if (run) { + try { + if (error.code === "job_cancelled") { + await this.providerRuns.cancelRun(run.id, { summary: "资料问答任务已由用户取消。" }); + } else { + await this.providerRuns.failRun(run.id, { + code: error.code || "qa_failed", + message: error.message || "Question answering failed.", + category: error.category || "workflow", + retryable: error.retryable, + details: { + validation_errors: safeValidationErrors( + error.details?.validation_errors || error.validation_errors, + ), + }, + }); + } + } catch (persistenceError) { + if (this.runtimePolicy.fail_closed) throw persistenceError; + } + } + await this.failJob(job.id, error); + throw error; + } + } + + async generateQaAnswer( + company, + question, + dossier, + contexts, + evidence = null, + providerRunId = "", + conversationHistory = [], + conversationMemory = "", + answerability = null, + ) { + const allowedEvidence = Array.isArray(evidence) + ? evidence + : buildQaEvidence({ dossier, contexts, question, conversationHistory }); + const safeConversationHistory = qaConversationHistory(conversationHistory); + const support = answerability + || assessQaAnswerability(question, allowedEvidence, safeConversationHistory); + const enumerationRequirements = buildQaEnumerationRequirements(question, allowedEvidence); + if (!support.evidence_count) { + const text = "现有企业档案和已导入飞书资料中,没有检索到足以可靠回答该问题的相关依据。请补充对应会话或云文档,或先更新企业档案后再提问。"; + return { + id: makeId("qa_assistant"), + role: "assistant", + text, + paragraphs: [{ text, citation_ids: [] }], + citation_ids: [], + citations: [], + insufficient: true, + created_at: nowIso(), + }; + } + if (this.modelProvider?.isRunEnabled?.()) { + try { + const qaSystem = [ + "你是销售资料问答助手。只输出 JSON,不要输出 Markdown。", + "只能基于 evidence 中的企业档案和用户导入的飞书资料回答。", + "企业档案是已由专业数据集和豆包搜索生成并完成引用校验的当前报告;飞书资料来自用户有权访问并主动导入的会话、云文档或会议纪要。", + "conversation_history 仅用于理解代词、承接追问和避免重复;不得把其中未被 evidence 支撑的陈述当成事实。", + "conversation_memory 是 OpenViking 从更早会话中提炼的长期摘要,只能用于保持对话连续性,不能单独作为事实证据。", + "不能自由联网,不能补编资料。资料不足时明确说不足。", + "retrieval_plan 说明问题类型和检索支持度;先直接回答问题,再给依据或下一步,不要介绍系统如何检索、调用了什么能力或资料条数。", + "严格围绕用户明确要求的对象和分项作答;不得自行增加“补充”“延伸信息”“其他说明”等未被提问的旁支内容。只有资料不足会影响结论时,才说明缺口或下一步。", + "当 retrieval_plan.answerability.supported=false 时,只有 evidence 原文明确包含答案才能回答;否则 insufficient 必须为 true,并简洁说明缺少哪类资料。", + "evidence.label 是资料的正式展示标题,询问标题或来源时必须逐字使用 label,不得根据正文另拟标题。", + "不得输出 evidence.uri、内部路径、资源 ID、公司内部 ID 或其他技术实现细节。", + "复杂问题拆成 2 至 5 个简短段落,每个 paragraphs[] 只表达一个主题。第一段必须直接给结论,后续段落再写依据、风险或建议。", + "回答必须针对问题中的对象、时间、需求或动作;禁止输出“可进一步关注”“建议持续跟踪”“资料可用于核验”等没有新增信息的套话。", + "如果 enumeration_requirements 非空,说明证据中存在与问题最相关的明确枚举表。必须逐项覆盖其中每个 label,不得合并、概括或遗漏,也不得增加表中没有的项目。", + "需要层级时,可让段落分别以“结论:”“依据:”“风险:”“建议:”或“下一步:”开头;简单事实问题使用 1 至 2 段,不机械套用全部标签。", + "如果多个证据对同一事实表述不一致,必须指出差异;不得自行选取一个版本。", + "每个非资料不足段落都必须提供 citation_ids,ID 必须逐字来自 evidence。", + "完整回答正文控制在 900 个中文字符以内,优先保证 JSON 完整闭合。", + ]; + const qaPayload = { + question, + conversation_history: safeConversationHistory, + conversation_memory: compactText(conversationMemory, 3000), + company: { name: company.name, industry: company.industry }, + retrieval_plan: { + ...analyzeQaQuestion(question, safeConversationHistory), + answerability: support, + }, + enumeration_requirements: enumerationRequirements, + evidence: allowedEvidence, + output_schema: { + paragraphs: [{ text: "回答段落", citation_ids: ["evidence_id"] }], + insufficient: false, + }, + }; + const callQaModel = ({ + operation, + maxTokens, + jsonRetry = false, + jsonRepairContent = "", + validationFeedback = [], + }) => this.modelProvider.callJson({ + operation, + maxTokens, + system: [ + ...qaSystem, + ...(jsonRetry + ? [ + "上一轮响应因 JSON 未完整闭合而无法解析。本轮必须返回完整 JSON。", + "最多输出 4 个段落,每段不超过 180 个中文字符;不得省略 citation_ids 和 insufficient。", + ] + : []), + ...(jsonRepairContent + ? [ + "你正在修复上一轮模型生成的无效 JSON。只修复 JSON 语法、闭合和转义问题,不得新增、删除或改写回答事实。", + "必须保留原回答段落、citation_ids 和 insufficient;引用仍须来自 evidence[].id。", + "只输出修复后的完整 JSON,不得解释修复过程。", + ] + : []), + ...(validationFeedback.length + ? [ + "上一轮回答未通过结构与引用校验。本轮必须根据 validation_feedback 逐项修正后重新输出完整 JSON。", + "每个非资料不足段落都必须给出 citation_ids,并且只能逐字复制 evidence[].id;不得使用来源序号、标题或自行编造的 ID。", + "如果 validation_feedback 指出遗漏枚举项,必须按 enumeration_requirements 逐项补齐。", + ] + : []), + ].join("\n"), + payload: { + ...qaPayload, + ...(jsonRepairContent ? { invalid_json_content: jsonRepairContent } : {}), + ...(validationFeedback.length ? { validation_feedback: validationFeedback } : {}), + }, + }); + let result = await this.trackProviderStep(providerRunId, { + provider: "model", + operation: "answer_sales_question", + input_summary: `基于 ${allowedEvidence.length} 条允许引用证据和 ${safeConversationHistory.length} 条对话上下文回答 ${company.name} 的资料问题`, + output_summary: "模型已返回结构化逐段回答。", + }, () => callQaModel({ + operation: "sales_qa", + maxTokens: 1600, + })); + if (!result.ok && result.error?.code === "invalid_json") { + const invalidContent = String(result.invalid_content || "").trim(); + if (invalidContent) { + result = await this.trackProviderStep(providerRunId, { + provider: "model", + operation: "repair_sales_question_json", + input_summary: `修复 ${company.name} 首次问答响应的 JSON 语法`, + output_summary: "模型已修复并返回完整结构化回答。", + }, () => callQaModel({ + operation: "sales_qa_json_repair", + maxTokens: 2200, + jsonRepairContent: invalidContent, + })); + } + } + if (!result.ok && result.error?.code === "invalid_json") { + result = await this.trackProviderStep(providerRunId, { + provider: "model", + operation: "retry_sales_question", + input_summary: `首次回答 JSON 未完整闭合,使用更高输出预算重试 ${company.name} 的资料问题`, + output_summary: "模型重试后已返回完整结构化回答。", + }, () => callQaModel({ + operation: "sales_qa_retry", + maxTokens: 2200, + jsonRetry: true, + })); + } + let validated = result.ok + ? validateQaModelAnswer(result.parsed, allowedEvidence, { enumerationRequirements, question }) + : null; + const validationErrors = validated?.errors || []; + if (result.ok && validationErrors.length) { + result = await this.trackProviderStep(providerRunId, { + provider: "model", + operation: "retry_invalid_qa_answer", + input_summary: `首次回答未通过结构或引用校验,重试 ${company.name} 的资料问题`, + output_summary: "模型重试后已返回修正引用与结构的回答。", + }, () => callQaModel({ + operation: "sales_qa_quality_retry", + maxTokens: 2200, + validationFeedback: validationErrors, + })); + validated = result.ok + ? validateQaModelAnswer(result.parsed, allowedEvidence, { enumerationRequirements, question }) + : null; + } + if (result.ok) { + if (validated.errors.length) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("model", "The model returned an answer with invalid or missing citations.", { + validation_errors: validated.errors, + }); + } + } else { + return { + id: makeId("qa_assistant"), + role: "assistant", + text: compactText(validated.text, 1800), + paragraphs: validated.paragraphs, + citation_ids: validated.citation_ids, + citations: validated.citations, + insufficient: validated.insufficient, + created_at: nowIso(), + }; + } + } else if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("model", "The model provider did not return a valid answer.", { + reason: result.error?.code || "provider_error", + }); + } + } catch (error) { + if (this.runtimePolicy.fail_closed) { + if (error instanceof HttpError) throw error; + throw providerUnavailable("model", "Question answering failed.", { + reason: error.message || "provider_error", + }); + } + } + } + + if (!this.modelProvider?.isRunEnabled?.()) { + await this.skipProviderStep(providerRunId, { + provider: "model", + operation: "answer_sales_question", + input_summary: `回答 ${company.name} 的资料问题`, + output_summary: "模型 Provider 未启用。", + error: { code: "provider_disabled", message: "Model provider is not enabled." }, + }); + } + + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("model", "The model provider did not return an answer."); + } + + const hasMaterials = (company.material_ids || []).length > 0; + const fallbackText = dossier + ? hasMaterials + ? `基于当前档案和历史资料,${company.name} 当前重点线索是:${dossier.memory_summary || dossier.summary}` + : `基于当前最新档案,${company.name} 当前重点线索是:${dossier.memory_summary || dossier.summary}` + : hasMaterials + ? `当前资料不足,只能确认 ${company.name} 已在目标企业池中,尚需生成最新档案。` + : `当前企业为新加入目标企业,暂无历史资料;请先生成最新档案后再围绕当前进展提问。`; + const fallbackCitationIds = dossier + ? [...new Set(firstJsonArray(dossier.body).flatMap((paragraph) => firstJsonArray(paragraph.citation_ids)))] + .filter((id) => allowedEvidence.some((item) => String(item.id) === String(id))) + .slice(0, 4) + : []; + const fallbackCitations = fallbackCitationIds + .map((id) => allowedEvidence.find((item) => String(item.id) === String(id))) + .filter(Boolean); + return { + id: makeId("qa_assistant"), + role: "assistant", + text: fallbackText, + paragraphs: [{ text: fallbackText, citation_ids: fallbackCitationIds }], + citation_ids: fallbackCitationIds, + citations: fallbackCitations, + insufficient: !dossier, + created_at: nowIso(), + }; + } + + async captureQaSession(company, userMessage, assistantMessage, contexts) { + if (!this.openVikingProvider?.isRunEnabled?.()) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking session capture is not enabled."); + } + return null; + } + const preferredSessionId = this.openVikingSessionId(company); + try { + const result = await this.openVikingProvider.addSessionMessages(preferredSessionId, [ + { role: "user", content: encodeQaSessionMessage(userMessage) }, + { role: "assistant", content: encodeQaSessionMessage(assistantMessage) }, + ]); + const sessionId = result.session_id || preferredSessionId; + if (result.ok && sessionId && company.qa_session_id !== sessionId) { + company.qa_session_id = sessionId; + company.updated_at = nowIso(); + if (this.persistence.enabled && this.repository) { + await this.persist(() => this.repository.persistSalesCompany(company)); + } + } + if (result.ok && contexts?.length) { + await this.openVikingProvider.recordSessionUsed(sessionId, contexts.map((item) => item.uri).filter(Boolean)); + } + if (!result.ok && this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking session capture failed.", { + reason: result.error?.code || "provider_error", + }); + } + return { ...result, session_id: sessionId }; + } catch (error) { + if (this.runtimePolicy.fail_closed) { + if (error instanceof HttpError) throw error; + throw providerUnavailable("openviking", "OpenViking session capture failed.", { + reason: error.message || "provider_error", + }); + } + return { + ok: false, + error: { code: error.message || "provider_error" }, + }; + } + } + + async commitQaMemory(companyId) { + const company = this.requireCompany(companyId); + if (!this.openVikingProvider?.isRunEnabled?.()) { + if (this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking session commit is not enabled."); + } + return { status: "skipped", summary: "OpenViking 写入未启用。" }; + } + const sessionId = this.openVikingSessionId(company); + const job = await this.startJob({ + job_type: "sales_qa_memory_commit", + entity_type: "target_enterprise", + entity_id: company.id, + max_attempts: 1, + request: { session_id: sessionId }, + }); + let run = null; + try { + run = await this.providerRuns.startRun({ + operation: "qa_memory_commit", + entity_type: "target_enterprise", + entity_id: company.id, + job_id: job.id, + }); + const result = await this.trackProviderStep(run.id, { + provider: "openviking", + operation: "commit_session_memory", + input_summary: `提交 ${company.name} 的资料问答会话`, + output_summary: "问答会话已提交至 OpenViking。", + }, () => this.openVikingProvider.commitSession(sessionId)); + if (!result.ok && this.runtimePolicy.fail_closed) { + throw providerUnavailable("openviking", "OpenViking session commit failed.", { + reason: result.error?.code || "provider_error", + }); + } + const record = { + status: result.ok ? "ready" : "failed", + raw_ref: result.raw_ref || null, + summary: result.ok ? "资料问答会话已提交,OpenViking 将异步抽取长期记忆。" : `OpenViking session commit 失败:${result.error?.code || "provider_error"}`, + }; + await this.trackProviderStep(run.id, { + provider: "supabase", + operation: "persist_qa_memory_ref", + input_summary: `保存 ${company.name} 的会话记忆提交状态`, + output_summary: "会话记忆提交状态已保存。", + }, async () => { + await this.persist(() => this.repository.persistSalesOpenVikingRef({ + company_id: company.id, + related_type: "qa_session", + related_id: sessionId, + ref_kind: "session_commit", + uri: record.raw_ref || "", + summary: record.summary, + payload_json: record, + })); + return { ok: true, provider: "supabase", provider_mode: this.persistence.enabled ? "real" : "disabled" }; + }); + await this.providerRuns.completeRun(run.id, { result_ref: `qa-memory:${company.id}:${record.status}` }); + await this.completeJob(job.id, { + result_ref: `qa-memory:${company.id}:${record.status}`, + result: { status: record.status }, + }); + return { + status: record.status, + summary: record.summary, + provider_run_id: run.id, + job_id: job.id, + }; + } catch (error) { + if (run) { + try { + await this.providerRuns.failRun(run.id, { + code: error.code || "qa_memory_commit_failed", + message: error.message || "QA memory commit failed.", + category: error.category || "workflow", + retryable: error.retryable, + }); + } catch (persistenceError) { + if (this.runtimePolicy.fail_closed) throw persistenceError; + } + } + await this.failJob(job.id, error); + throw error; + } + } + + openVikingSessionId(company) { + if (company.qa_session_id) return company.qa_session_id; + if (typeof this.openVikingProvider?.salesSessionId === "function") { + return this.openVikingProvider.salesSessionId({ + workspaceId: this.workspaceId, + companyId: company.id, + }); + } + return company.qa_session_id || `sales-${company.id}`; + } +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/sync/materialSync.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/sync/materialSync.js new file mode 100644 index 00000000..bace8845 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/sync/materialSync.js @@ -0,0 +1,209 @@ +import { createHash } from "node:crypto"; + +const SOURCE_TYPE_ALIASES = new Map([ + ["feishu_doc", "feishu_doc"], + ["feishu_document", "feishu_doc"], + ["飞书云文档", "feishu_doc"], + ["feishu_p2p", "feishu_p2p"], + ["飞书单聊", "feishu_p2p"], + ["feishu_chat", "feishu_chat"], + ["飞书群聊", "feishu_chat"], + ["飞书会话", "feishu_chat"], + ["feishu_search", "feishu_search"], + ["飞书消息搜索", "feishu_search"], + ["manual", "manual"], + ["手工导入", "manual"], +]); +const MATERIAL_SNAPSHOT_PATTERN = //; + +function normalizedText(value) { + return String(value || "") + .normalize("NFKC") + .replace(/\r\n?/g, "\n") + .replace(/[\t ]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function digest(value) { + return createHash("sha256").update(String(value || ""), "utf8").digest("hex"); +} + +function canonicalUrl(value) { + const raw = String(value || "").trim(); + if (!/^https?:\/\//i.test(raw)) return raw; + try { + const url = new URL(raw); + url.hash = ""; + url.search = ""; + return url.toString().replace(/\/$/, ""); + } catch { + return raw; + } +} + +function feishuDocumentToken(value) { + const raw = String(value || "").trim(); + const match = raw.match(/\/(?:wiki|docx)\/([^/?#]+)/i); + return match?.[1] || raw; +} + +function safeSourceConfig(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const result = {}; + for (const [key, item] of Object.entries(value)) { + if (/secret|token|api.?key|authorization|cookie|password|credential/i.test(key)) continue; + if (["string", "number", "boolean"].includes(typeof item) || item === null) result[key] = item; + } + return result; +} + +export function normalizeMaterialSourceType(value) { + const normalized = String(value || "").trim().toLowerCase(); + return SOURCE_TYPE_ALIASES.get(normalized) + || SOURCE_TYPE_ALIASES.get(String(value || "").trim()) + || normalized.replace(/[^a-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "") + || "manual"; +} + +export function normalizeExternalId(sourceType, value) { + const type = normalizeMaterialSourceType(sourceType); + const raw = String(value || "").trim(); + if (type === "feishu_doc") return feishuDocumentToken(raw); + if (/^https?:\/\//i.test(raw)) return canonicalUrl(raw); + return raw; +} + +export function makeSyncSourceId(sourceType, externalId) { + const type = normalizeMaterialSourceType(sourceType); + const external = normalizeExternalId(type, externalId); + if (!external) throw new Error("external_id is required to build a stable sync source id."); + return `sync_${digest(`${type}\n${external}`).slice(0, 32)}`; +} + +export function makeMaterialId(companyId, sourceId) { + const company = String(companyId || "").trim(); + const source = String(sourceId || "").trim(); + if (!company || !source) throw new Error("company_id and source_id are required to build a material id."); + return `mat_${digest(`${company}\n${source}`).slice(0, 32)}`; +} + +export function normalizeSourceItems(items = []) { + return (Array.isArray(items) ? items : []) + .map((item) => { + const content = normalizedText(item?.content || item?.text); + const occurredAt = String(item?.occurred_at || item?.create_time || "").trim(); + const sender = normalizedText(item?.sender || item?.sender_name); + const sourceUrl = canonicalUrl(item?.source_url || item?.message_app_link); + const fallbackIdentity = `${occurredAt}\n${sender}\n${content}\n${sourceUrl}`; + const id = String(item?.id || item?.message_id || `item_${digest(fallbackIdentity).slice(0, 24)}`).trim(); + return { + id, + occurred_at: occurredAt || null, + sender, + content, + source_url: sourceUrl, + deleted: Boolean(item?.deleted), + }; + }) + .filter((item) => item.id && (item.content || item.deleted)); +} + +export function mergeSourceItems(existingItems = [], incomingItems = []) { + const merged = new Map(normalizeSourceItems(existingItems).map((item) => [item.id, item])); + for (const item of normalizeSourceItems(incomingItems)) { + if (item.deleted) merged.delete(item.id); + else merged.set(item.id, item); + } + return [...merged.values()].sort((a, b) => { + const timeOrder = String(a.occurred_at || "").localeCompare(String(b.occurred_at || "")); + return timeOrder || a.id.localeCompare(b.id); + }); +} + +export function renderSourceItems(items = []) { + return normalizeSourceItems(items) + .filter((item) => !item.deleted) + .map((item) => [ + `[${item.occurred_at || "时间未知"}] ${item.sender || "未知发送者"}:${item.content}`, + item.source_url ? `消息链接:${item.source_url}` : "", + ].filter(Boolean).join("\n")) + .join("\n\n"); +} + +export function encodeMaterialSnapshot(input = {}) { + const snapshot = { + title: normalizedText(input.title), + source_type: normalizeMaterialSourceType(input.source_type), + source_url: canonicalUrl(input.source_url), + source_id: String(input.source_id || "").trim(), + source_external_id: String(input.source_external_id || "").trim(), + source_version: String(input.source_version || "").trim(), + summary: normalizedText(input.summary), + text: normalizedText(input.text || input.raw_text || input.content), + source_items: normalizeSourceItems(input.source_items || input.items), + occurred_at: String(input.occurred_at || "").trim() || null, + }; + return ``; +} + +export function decodeMaterialSnapshot(content) { + const text = String(content || ""); + const encoded = text.match(MATERIAL_SNAPSHOT_PATTERN)?.[1]; + if (!encoded) return null; + try { + const parsed = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + return { + title: normalizedText(parsed.title), + source_type: normalizeMaterialSourceType(parsed.source_type), + source_url: canonicalUrl(parsed.source_url), + source_id: String(parsed.source_id || "").trim(), + source_external_id: String(parsed.source_external_id || "").trim(), + source_version: String(parsed.source_version || "").trim(), + summary: normalizedText(parsed.summary), + text: normalizedText(parsed.text), + source_items: normalizeSourceItems(parsed.source_items), + occurred_at: String(parsed.occurred_at || "").trim() || null, + }; + } catch { + return null; + } +} + +export function makeMaterialContentHash(input = {}) { + const canonical = { + title: normalizedText(input.title), + source_url: canonicalUrl(input.source_url), + summary: normalizedText(input.summary), + text: normalizedText(input.text || input.raw_text || input.content), + occurred_at: String(input.occurred_at || "").trim() || null, + source_items: normalizeSourceItems(input.source_items || input.items), + }; + return digest(JSON.stringify(canonical)); +} + +export function buildMaterialSyncIdentity(companyId, body = {}) { + const source = body.source && typeof body.source === "object" ? body.source : {}; + const sourceType = normalizeMaterialSourceType(source.type || body.source_type); + const title = normalizedText(body.title); + const sourceUrl = canonicalUrl(source.url || body.source_url || body.url); + const suppliedExternalId = source.external_id || body.external_id || sourceUrl; + const externalId = normalizeExternalId( + sourceType, + suppliedExternalId || `manual:${digest(title || normalizedText(body.raw_text || body.text)).slice(0, 24)}`, + ); + const sourceId = makeSyncSourceId(sourceType, externalId); + return { + source_id: sourceId, + material_id: makeMaterialId(companyId, sourceId), + source_type: sourceType, + external_id: externalId, + display_name: normalizedText(source.display_name || title || externalId).slice(0, 160), + source_url: sourceUrl, + checkpoint_key: normalizedText(source.checkpoint_key || body.checkpoint_key || "latest").slice(0, 120), + checkpoint_value: normalizedText(source.checkpoint_value || body.checkpoint_value).slice(0, 500), + source_version: normalizedText(source.version || body.source_version || source.checkpoint_value || body.checkpoint_value).slice(0, 200), + config: safeSourceConfig(source.config), + }; +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/utils/http.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/utils/http.js new file mode 100644 index 00000000..251fac77 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/utils/http.js @@ -0,0 +1,132 @@ +import { makeRequestId } from "./ids.js"; + +export class HttpError extends Error { + constructor(status, code, message, details = {}) { + super(message); + this.status = status; + this.code = code; + this.details = details; + } +} + +export function parseAllowedOrigins(value = "") { + return String(value || "") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); +} + +export function isOriginAllowed(req, allowedOrigins = []) { + const origin = String(req?.headers?.origin || "").trim(); + if (!origin) return true; + try { + const originUrl = new URL(origin); + const requestHost = String(req?.headers?.host || "").trim().toLowerCase(); + if (requestHost && originUrl.host.toLowerCase() === requestHost) return true; + } catch { + return false; + } + return allowedOrigins.includes(origin); +} + +export function withCors(req, res, allowedOrigins = []) { + const origin = String(req?.headers?.origin || "").trim(); + if (!origin || !allowedOrigins.includes(origin)) return; + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Vary", "Origin"); + res.setHeader("Access-Control-Allow-Credentials", "true"); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization,X-CSRF-Token"); + res.setHeader("Access-Control-Max-Age", "600"); +} + +export function withSecurityHeaders(res, options = {}) { + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); + res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()"); + res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + res.setHeader("Content-Security-Policy", "default-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"); + if (options.api) res.setHeader("Cache-Control", "no-store"); +} + +export function sendJson(res, status, payload, headers = {}) { + for (const [name, value] of Object.entries(headers)) res.setHeader(name, value); + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(payload)); +} + +export function ok(res, data, meta = {}) { + sendJson(res, 200, { + data, + meta: { + request_id: meta.request_id || makeRequestId(), + ...meta, + }, + }); +} + +export function created(res, data, meta = {}) { + sendJson(res, 201, { + data, + meta: { + request_id: meta.request_id || makeRequestId(), + ...meta, + }, + }); +} + +export function accepted(res, data, meta = {}) { + sendJson(res, 202, { + data, + meta: { + request_id: meta.request_id || makeRequestId(), + ...meta, + }, + }); +} + +export function fail(res, error, requestId = makeRequestId()) { + const status = error instanceof HttpError ? error.status : 500; + const code = error instanceof HttpError ? error.code : "internal_error"; + const message = error instanceof HttpError ? error.message : "Unexpected server error."; + const details = error instanceof HttpError ? error.details : {}; + sendJson(res, status, { + error: { + code, + message, + details, + }, + meta: { + request_id: requestId, + }, + }); +} + +export async function readJson(req, options = {}) { + const maxBytes = Math.max(1024, Number(options.maxBytes) || 1024 * 1024); + const declaredLength = Number(req.headers?.["content-length"] || 0); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new HttpError(413, "payload_too_large", `Request body exceeds the ${maxBytes}-byte limit.`); + } + const chunks = []; + let totalBytes = 0; + for await (const chunk of req) { + totalBytes += chunk.length; + if (totalBytes > maxBytes) { + throw new HttpError(413, "payload_too_large", `Request body exceeds the ${maxBytes}-byte limit.`); + } + chunks.push(chunk); + } + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw.trim()) return {}; + try { + return JSON.parse(raw); + } catch { + throw new HttpError(400, "bad_request", "Request body must be valid JSON."); + } +} + +export function parseUrl(req) { + return new URL(req.url, "http://localhost"); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/utils/ids.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/utils/ids.js new file mode 100644 index 00000000..825afc17 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/utils/ids.js @@ -0,0 +1,12 @@ +let requestCounter = 0; +let entityCounter = 0; + +export function makeRequestId() { + requestCounter += 1; + return `req_${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}_${String(requestCounter).padStart(6, "0")}`; +} + +export function makeId(prefix) { + entityCounter += 1; + return `${prefix}_${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}_${String(entityCounter).padStart(6, "0")}`; +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/utils/time.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/utils/time.js new file mode 100644 index 00000000..a8323663 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/utils/time.js @@ -0,0 +1,14 @@ +export function nowIso() { + return new Date().toISOString(); +} + +export function nowLabel() { + return "刚刚"; +} + +export function isoFromLocal(value) { + if (!value || value === "尚未运行" || value === "刚刚") return null; + const normalized = String(value).replace(" ", "T"); + const date = new Date(`${normalized}:00+08:00`); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/worker.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/worker.js new file mode 100644 index 00000000..8787d2ac --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/worker.js @@ -0,0 +1,24 @@ +import { createRuntimeContext } from "./app.js"; +import { JobWorker } from "./workers/jobWorker.js"; + +const context = createRuntimeContext(); +const enabled = ["1", "true", "yes", "on"].includes( + String(context.env.value("ASYNC_JOBS_ENABLED", "true")).toLowerCase(), +); + +if (!enabled) { + console.log("sales-job-worker disabled by ASYNC_JOBS_ENABLED"); + process.exit(0); +} + +const worker = new JobWorker({ + repository: context.salesRepository, + salesService: context.salesService, + env: context.env, +}); + +const stop = () => worker.stop(); +process.on("SIGTERM", stop); +process.on("SIGINT", stop); + +await worker.run(); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/workers/jobWorker.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/workers/jobWorker.js new file mode 100644 index 00000000..ca292196 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/workers/jobWorker.js @@ -0,0 +1,176 @@ +import os from "node:os"; + +const SUPPORTED_JOB_TYPES = Object.freeze([ + "sales_dossier_generation", + "sales_material_openviking_sync", +]); + +function positiveInteger(value, fallback, minimum = 1) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= minimum ? parsed : fallback; +} + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function safeError(error) { + return { + code: String(error?.code || "worker_execution_failed").slice(0, 120), + message: String(error?.message || "后台任务执行失败。").slice(0, 500), + category: String(error?.category || "workflow").slice(0, 80), + retryable: Boolean(error?.retryable || Number(error?.status || 0) >= 500), + }; +} + +function shouldRetryClaim(error) { + return [ + "paid_workflow_concurrency_exceeded", + "usage_guard_unavailable", + "provider_timeout", + "supabase_unavailable", + ].includes(String(error?.code || "")) || Boolean(error?.retryable); +} + +function retryDelaySeconds(error, attemptCount, random = Math.random) { + if (String(error?.code || "") === "paid_workflow_concurrency_exceeded") return 30; + const attempt = Math.max(1, Number(attemptCount || 1)); + const base = Math.min(60, 5 * (2 ** Math.max(0, attempt - 1))); + const jitter = Math.floor(base * 0.25 * Math.max(0, Math.min(1, Number(random()) || 0))); + return base + jitter; +} + +export class JobWorker { + constructor(options = {}) { + this.repository = options.repository; + this.salesService = options.salesService; + this.env = options.env; + this.workerId = options.workerId + || this.env?.value?.("JOB_WORKER_ID", "") + || `${os.hostname()}:${process.pid}`; + this.pollMs = positiveInteger(this.env?.value?.("JOB_WORKER_POLL_MS", "1000"), 1000, 100); + this.leaseSeconds = positiveInteger(this.env?.value?.("JOB_WORKER_LEASE_SECONDS", "600"), 600, 60); + this.heartbeatMs = Math.max(5_000, Math.min(30_000, Math.floor((this.leaseSeconds * 1000) / 3))); + this.jobTypes = options.jobTypes || SUPPORTED_JOB_TYPES; + this.logger = options.logger || console; + this.random = options.random || Math.random; + this.stopped = false; + } + + async assertReady() { + if (!this.repository || typeof this.repository.claimNextJob !== "function") { + throw new Error("Persistent asynchronous job queue is not configured."); + } + if (!this.salesService || typeof this.salesService.executeQueuedJob !== "function") { + throw new Error("Sales job executor is not configured."); + } + await this.salesService.assertRuntimeReady(); + } + + async runOnce() { + const job = await this.repository.claimNextJob(this.workerId, this.jobTypes, this.leaseSeconds); + if (!job) return { claimed: false }; + + let stage = job.stage || "starting"; + let progress = Number(job.progress || 1); + let heartbeatFailure = null; + let heartbeatBusy = false; + const heartbeat = async (nextStage = stage, nextProgress = progress) => { + if (heartbeatFailure) throw heartbeatFailure; + stage = nextStage; + progress = nextProgress; + const updated = await this.repository.heartbeatJob( + job.id, + this.workerId, + stage, + progress, + this.leaseSeconds, + ); + stage = updated.stage || stage; + progress = Number(updated.progress ?? progress); + return updated; + }; + const saveCheckpoint = async (checkpointPatch = {}, options = {}) => { + if (typeof this.repository.saveJobCheckpoint !== "function") { + throw new Error("Persistent job checkpoints are not configured."); + } + stage = options.stage || stage; + progress = Number(options.progress ?? progress); + const updated = await this.repository.saveJobCheckpoint( + job.id, + this.workerId, + checkpointPatch, + { + stage, + progress, + detail: options.detail || {}, + lease_seconds: this.leaseSeconds, + }, + ); + stage = updated.stage || stage; + progress = Number(updated.progress ?? progress); + job.checkpoint = updated.checkpoint || job.checkpoint || {}; + job.progress_detail = updated.progress_detail || job.progress_detail || {}; + return updated; + }; + const heartbeatTimer = setInterval(() => { + if (heartbeatBusy || heartbeatFailure) return; + heartbeatBusy = true; + heartbeat().catch((error) => { + heartbeatFailure = error; + }).finally(() => { + heartbeatBusy = false; + }); + }, this.heartbeatMs); + heartbeatTimer.unref?.(); + + try { + await heartbeat("starting", 2); + const result = await this.salesService.executeQueuedJob(job, { + worker_id: this.workerId, + report_progress: heartbeat, + save_checkpoint: saveCheckpoint, + }); + if (heartbeatFailure) throw heartbeatFailure; + return { claimed: true, job_id: job.id, status: "succeeded", result }; + } catch (error) { + const latest = await this.repository.getJob(job.id).catch(() => null); + let finalStatus = latest?.status || "failed"; + if (!["succeeded", "failed", "cancelled"].includes(latest?.status)) { + const released = await this.repository.releaseJobClaim(job.id, this.workerId, safeError(error), { + retry: shouldRetryClaim(error), + delay_seconds: retryDelaySeconds(error, latest?.attempt_count || job.attempt_count, this.random), + }); + finalStatus = released?.status || finalStatus; + } + return { + claimed: true, + job_id: job.id, + status: finalStatus, + error: safeError(error), + }; + } finally { + clearInterval(heartbeatTimer); + } + } + + async run() { + await this.assertReady(); + this.logger.info?.(`sales-job-worker ready (${this.workerId})`); + while (!this.stopped) { + try { + const result = await this.runOnce(); + if (!result.claimed) await sleep(this.pollMs); + } catch (error) { + this.logger.error?.(`sales-job-worker poll failed: ${String(error?.code || error?.message || "unknown_error")}`); + await sleep(this.pollMs); + } + } + } + + stop() { + this.stopped = true; + } +} + +export { SUPPORTED_JOB_TYPES }; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/adminStatusService.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/adminStatusService.test.mjs new file mode 100644 index 00000000..2649842b --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/adminStatusService.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { AdminStatusService } from "../src/services/adminStatusService.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +const strictRuntimePolicy = Object.freeze({ + fail_closed: true, + http_auth_enabled: true, +}); + +test("admin status reports only safe deployment, backup and live-doctor metadata", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "sales-admin-status-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const backupDir = path.join(root, "backups"); + const packageDir = path.join(backupDir, "supabase-test"); + await fs.mkdir(packageDir, { recursive: true }); + await fs.writeFile(path.join(packageDir, "manifest.json"), JSON.stringify({ + format_version: 1, + backup_id: "backup-safe-1", + created_at: "2026-07-22T01:00:00.000Z", + row_counts: { companies: 2, dossiers: 3 }, + files: [{ path: "data.json", sha256: "a".repeat(64) }], + })); + const doctorFile = path.join(root, "doctor-live.json"); + await fs.writeFile(doctorFile, JSON.stringify({ + checked_at: new Date().toISOString(), + ok: false, + backend: { + runtime_ready: false, + blockers: ["web search failed"], + checks: { + model: { called: true, ok: true, provider_mode: "real" }, + web_search: { called: true, ok: false, provider_mode: "real", error: { code: "10500", message: "private detail" } }, + }, + }, + })); + + const service = new AdminStatusService({ + env: envReader({ + HOST: "127.0.0.1", + PORT: "8787", + APP_WORKSPACE_SLUG: "default", + APP_WORKSPACE_NAME: "Sales Workbench", + SALES_WORKBENCH_BACKUP_DIR: backupDir, + SALES_WORKBENCH_LIVE_DOCTOR_FILE: doctorFile, + AGENT_PLAN_API_KEY: "must-not-appear", + }), + runtimePolicy: strictRuntimePolicy, + getProviderStatus: () => ({ + repository: { active: "supabase" }, + providers: [{ id: "model", label: "Model", status: "configured", safe_config: { run_enabled: true } }], + }), + }); + + const status = await service.getStatus(); + assert.equal(status.read_only, true); + assert.equal(status.deployment.loopback_only, true); + assert.equal(status.deployment.http_auth_enabled, true); + assert.equal(status.backup.latest.backup_id, "backup-safe-1"); + assert.equal(status.backup.latest.row_count, 5); + assert.equal(status.backup.latest.checksums_declared, true); + assert.equal(status.live_doctor.status, "failed"); + assert.equal(status.live_doctor.checks[1].error_code, "10500"); + assert.doesNotMatch(JSON.stringify(status), /must-not-appear|private detail/); +}); + +test("admin status handles installations without a backup or doctor state path", async () => { + const service = new AdminStatusService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + getProviderStatus: () => ({ providers: [], repository: { active: "memory" } }), + }); + + const status = await service.getStatus(); + assert.equal(status.backup.configured, false); + assert.equal(status.backup.status, "unavailable"); + assert.equal(status.live_doctor.status, "unavailable"); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/agentPlanKey.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/agentPlanKey.test.mjs new file mode 100644 index 00000000..9d3ba8d1 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/agentPlanKey.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { DataProProvider } from "../src/providers/dataProProvider.js"; +import { ModelProvider } from "../src/providers/modelProvider.js"; +import { OpenVikingProvider } from "../src/providers/openVikingProvider.js"; +import { WebSearchProvider } from "../src/providers/webSearchProvider.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + number(name, fallback = 0) { + const value = Number(Object.hasOwn(values, name) ? values[name] : fallback); + return Number.isFinite(value) ? value : fallback; + }, + }; +} + +test("one Agent Plan key configures model, DataPro and web search", () => { + const env = envReader({ + AGENT_PLAN_API_KEY: "shared-agent-plan-key", + OPENVIKING_BASE_URL: "https://openviking.example.test", + OPENVIKING_CLI: "/definitely/not/an/openviking-cli", + }); + + assert.equal(new ModelProvider({ env }).apiKey, "shared-agent-plan-key"); + assert.equal(new DataProProvider({ env }).apiKey, "shared-agent-plan-key"); + assert.equal(new WebSearchProvider({ env }).apiKey, "shared-agent-plan-key"); + + const openViking = new OpenVikingProvider({ env, cliConfig: {} }); + assert.equal(openViking.apiKey, ""); + assert.equal(openViking.isConfigured(), false); +}); + +test("capability-specific keys remain optional overrides", () => { + const env = envReader({ + AGENT_PLAN_API_KEY: "shared-agent-plan-key", + MODEL_API_KEY: "model-override", + DATAPRO_API_KEY: "datapro-override", + WEB_SEARCH_API_KEY: "search-override", + OPENVIKING_API_KEY: "openviking-override", + }); + + assert.equal(new ModelProvider({ env }).apiKey, "model-override"); + assert.equal(new DataProProvider({ env }).apiKey, "datapro-override"); + assert.equal(new WebSearchProvider({ env }).apiKey, "search-override"); + assert.equal( + new OpenVikingProvider({ env, cliConfig: {} }).apiKey, + "openviking-override", + ); +}); + +test("OpenViking does not report a missing CLI command as configured", () => { + const provider = new OpenVikingProvider({ + env: envReader({ OPENVIKING_CLI: "/definitely/not/an/openviking-cli" }), + cliConfig: {}, + }); + + assert.equal(provider.isConfigured(), false); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/asyncJobWorker.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/asyncJobWorker.test.mjs new file mode 100644 index 00000000..e8212ea0 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/asyncJobWorker.test.mjs @@ -0,0 +1,498 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SalesService } from "../src/services/salesService.js"; +import { JobWorker } from "../src/workers/jobWorker.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +function salesState() { + return { + goals: [{ id: "goal-1", name: "测试目标", company_ids: ["company-1"] }], + companies: { + "company-1": { + id: "company-1", + name: "测试科技有限公司", + dossier_ids: [], + material_ids: [], + qa_session_id: "sales-company-1", + }, + }, + dossiers: {}, + materials: {}, + qa_messages: {}, + sync_sources: {}, + sync_checkpoints: {}, + jobs: {}, + }; +} + +const strictRuntimePolicy = Object.freeze({ + fail_closed: true, +}); + +const permissiveTestPolicy = Object.freeze({ + fail_closed: false, +}); + +test("enqueueing a dossier persists a queued job without reserving paid capacity", async () => { + const calls = []; + const repository = { + async getSalesState() { + return salesState(); + }, + async enqueueJob(job) { + calls.push({ operation: "enqueue", job }); + return job; + }, + }; + const paidWorkflowGuard = { + async reserve() { + calls.push({ operation: "reserve" }); + throw new Error("paid capacity must not be reserved while enqueueing"); + }, + }; + const service = new SalesService({ + env: envReader({ ASYNC_JOBS_ENABLED: "true", APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + repository, + paidWorkflowGuard, + }); + + await service.assertRuntimeReady(); + const job = await service.enqueueDossier("company-1", { idempotency_key: "request-1" }, { + created_by: "11111111-1111-4111-8111-111111111111", + }); + + assert.equal(job.status, "queued"); + assert.equal(job.stage_label, "等待执行"); + assert.equal(job.progress, 0); + assert.equal(calls.filter((call) => call.operation === "enqueue").length, 1); + assert.equal(calls.filter((call) => call.operation === "reserve").length, 0); + assert.equal(Object.hasOwn(job, "request"), false); + assert.equal(Object.hasOwn(job, "created_by"), false); + assert.equal(Object.hasOwn(job, "reservation_id"), false); +}); + +test("enqueueing reports a queue failure instead of returning a local-only queued job", async () => { + const repository = { + async getSalesState() { + return salesState(); + }, + async enqueueJob() { + throw new Error("rpc unavailable"); + }, + }; + const service = new SalesService({ + env: envReader({ ASYNC_JOBS_ENABLED: "true", APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + repository, + }); + + await service.assertRuntimeReady(); + await assert.rejects( + service.enqueueDossier("company-1"), + (error) => error?.status === 503 && error?.code === "job_queue_unavailable", + ); + assert.deepEqual(service.data.jobs, {}); +}); + +test("public job progress exposes only a compact user-facing detail", () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: permissiveTestPolicy, + }); + const job = service.publicJob({ + id: "job-progress", + job_type: "sales_dossier_generation", + status: "running", + stage: "collecting_professional", + progress: 34, + progress_detail: { + message: "正在核验专业资料 2/4", + current: 2, + total: 4, + provider: "datapro", + query: "private query", + worker_id: "worker-private", + }, + attempt_count: 1, + max_attempts: 3, + }); + + assert.deepEqual(job.stage_detail, { + message: "正在核验专业资料 2/4", + current: 2, + total: 4, + }); + assert.equal(Object.hasOwn(job.stage_detail, "provider"), false); + assert.equal(Object.hasOwn(job.stage_detail, "query"), false); + assert.equal(Object.hasOwn(job.stage_detail, "worker_id"), false); +}); + +test("API service can refresh dossier data written by a separate worker process", async () => { + let persisted = salesState(); + let reads = 0; + const repository = { + async getSalesState() { + reads += 1; + return structuredClone(persisted); + }, + }; + const service = new SalesService({ + env: envReader({ ASYNC_JOBS_ENABLED: "true", APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + repository, + }); + + await service.assertRuntimeReady(); + assert.deepEqual(service.listDossiers("company-1"), []); + + persisted = salesState(); + persisted.companies["company-1"].dossier_ids = ["dossier-worker-1"]; + persisted.dossiers["dossier-worker-1"] = { + id: "dossier-worker-1", + company_id: "company-1", + title: "测试科技有限公司企业档案", + summary: "后台 Worker 已生成并持久化最新企业档案。", + body: [ + { text: "企业与业务概览:测试科技有限公司面向企业客户提供软件与知识库产品。", citation_ids: ["p1"] }, + { text: "经营与业务动态:专业数据反映该企业持续推进内容检索与协作管理能力。", citation_ids: ["p2"] }, + { text: "近期公开动态:测试科技有限公司于2026年7月发布知识库产品升级公告。", citation_ids: ["w1", "w2"] }, + { text: "风险与关注事项:项目推进需在商务报价前确认数据权限、合同责任和交付排期。", citation_ids: ["p1", "w2"] }, + { text: "销售机会判断:产品升级形成试点窗口,但不代表企业已经形成采购意向。", citation_ids: ["p2", "w1"] }, + { text: "建议行动:1. 联系产品负责人核验范围。\n2. 确认数据权限边界。\n3. 准备试点方案。", citation_ids: ["p1", "w2"] }, + ], + citations: [ + { + id: "p1", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "测试科技有限公司经营企业软件与知识库产品。", + independence_key: "datapro-business", + }, + { + id: "p2", + label: "金融数据库", + source_kind: "专业数据集", + summary: "测试科技有限公司持续推进内容检索与协作管理业务。", + independence_key: "datapro-market", + }, + { + id: "w1", + label: "测试科技有限公司发布知识库产品升级公告", + source_kind: "联网搜索", + summary: "测试科技有限公司于2026年7月发布知识库产品升级公告。", + url: "https://news.test/company-update", + independence_key: "news.test", + }, + { + id: "w2", + label: "测试科技有限公司披露产品交付安排", + source_kind: "联网搜索", + summary: "测试科技有限公司披露知识库产品的分阶段交付安排。", + url: "https://official.test/company-delivery", + independence_key: "official.test", + }, + ], + version_no: 1, + change_status: "initial", + data_as_of: "2026-07-24T00:00:00.000Z", + generated_at: "2026-07-24T06:00:00.000Z", + created_at: "2026-07-24T06:00:00.000Z", + }; + + await service.refreshPersistedState({ force: true }); + + assert.equal(reads, 2); + assert.equal(service.listDossiers("company-1")[0].id, "dossier-worker-1"); + assert.equal(service.dossierDetail("dossier-worker-1").version_no, 1); +}); + +test("worker claims one job, reports progress and executes it once", async () => { + const calls = []; + let claimed = false; + let current = null; + const repository = { + async claimNextJob(workerId, jobTypes, leaseSeconds) { + calls.push({ operation: "claim", workerId, jobTypes, leaseSeconds }); + if (claimed) return null; + claimed = true; + current = { + id: "job-1", + job_type: "sales_dossier_generation", + entity_id: "company-1", + status: "running", + stage: "starting", + progress: 1, + worker_id: workerId, + }; + return current; + }, + async heartbeatJob(jobId, workerId, stage, progress) { + calls.push({ operation: "heartbeat", jobId, workerId, stage, progress }); + current = { ...current, stage, progress }; + return current; + }, + async getJob() { + return current; + }, + async releaseJobClaim() { + calls.push({ operation: "release" }); + }, + }; + const salesService = { + async assertRuntimeReady() {}, + async executeQueuedJob(job, options) { + calls.push({ operation: "execute", job }); + await options.report_progress("generating_dossier", 70); + current = { ...current, status: "succeeded", stage: "succeeded", progress: 100 }; + return { action: "created" }; + }, + }; + const worker = new JobWorker({ + repository, + salesService, + env: envReader({ JOB_WORKER_POLL_MS: "100", JOB_WORKER_LEASE_SECONDS: "600" }), + workerId: "worker-test", + logger: { info() {}, error() {} }, + }); + + await worker.assertReady(); + const result = await worker.runOnce(); + + assert.equal(result.status, "succeeded"); + assert.equal(calls.filter((call) => call.operation === "execute").length, 1); + assert.ok(calls.some((call) => call.operation === "heartbeat" && call.stage === "generating_dossier")); + assert.equal(calls.some((call) => call.operation === "release"), false); +}); + +test("worker requeues an unreserved task after a retryable claim failure", async () => { + const calls = []; + const job = { + id: "job-retry", + job_type: "sales_dossier_generation", + entity_id: "company-1", + status: "running", + stage: "starting", + progress: 1, + worker_id: "worker-test", + }; + const repository = { + async claimNextJob() { + return job; + }, + async heartbeatJob(jobId, workerId, stage, progress) { + return { ...job, id: jobId, worker_id: workerId, stage, progress }; + }, + async getJob() { + return job; + }, + async releaseJobClaim(jobId, workerId, error, options) { + calls.push({ jobId, workerId, error, options }); + return { ...job, status: "queued" }; + }, + }; + const salesService = { + async assertRuntimeReady() {}, + async executeQueuedJob() { + const error = new Error("capacity reached"); + error.code = "paid_workflow_concurrency_exceeded"; + throw error; + }, + }; + const worker = new JobWorker({ + repository, + salesService, + env: envReader(), + workerId: "worker-test", + logger: { info() {}, error() {} }, + }); + + const result = await worker.runOnce(); + assert.equal(result.status, "queued"); + assert.equal(calls.length, 1); + assert.equal(calls[0].options.retry, true); + assert.equal(calls[0].options.delay_seconds, 30); +}); + +test("worker persists a durable checkpoint before requeueing a retryable paid stage", async () => { + const calls = []; + let current = { + id: "job-checkpoint", + job_type: "sales_dossier_generation", + entity_id: "company-1", + status: "running", + stage: "collecting_professional", + progress: 18, + worker_id: "worker-test", + attempt_count: 1, + max_attempts: 3, + is_paid: true, + reservation_id: "reservation-test", + checkpoint: {}, + }; + const repository = { + async claimNextJob() { + return current; + }, + async heartbeatJob(jobId, workerId, stage, progress) { + current = { ...current, id: jobId, worker_id: workerId, stage, progress }; + calls.push({ operation: "heartbeat", stage, progress }); + return current; + }, + async saveJobCheckpoint(jobId, workerId, checkpoint, options) { + current = { + ...current, + id: jobId, + worker_id: workerId, + checkpoint: { ...current.checkpoint, ...checkpoint }, + stage: options.stage, + progress: options.progress, + progress_detail: options.detail, + }; + calls.push({ operation: "checkpoint", checkpoint, options }); + return current; + }, + async getJob() { + return current; + }, + async releaseJobClaim(jobId, workerId, error, options) { + calls.push({ operation: "release", jobId, workerId, error, options }); + current = { + ...current, + status: "queued", + stage: "retry_wait", + scheduled_at: "2026-07-30T12:00:05.000Z", + }; + return current; + }, + }; + const salesService = { + async assertRuntimeReady() {}, + async executeQueuedJob(_job, options) { + await options.save_checkpoint( + { + dossier: { + schema_version: 1, + company_id: "company-1", + evidence_collection: { + completed_query_keys: ["datapro:business"], + }, + }, + }, + { + stage: "collecting_professional", + progress: 24, + detail: { current: 1, total: 2, message: "正在核验专业资料 1/2" }, + }, + ); + const error = new Error("temporary upstream failure"); + error.code = "provider_timeout"; + error.category = "timeout"; + error.retryable = true; + throw error; + }, + }; + const worker = new JobWorker({ + repository, + salesService, + env: envReader(), + workerId: "worker-test", + logger: { info() {}, error() {} }, + random: () => 0, + }); + + const result = await worker.runOnce(); + + assert.equal(result.status, "queued"); + assert.deepEqual(current.checkpoint.dossier.evidence_collection.completed_query_keys, [ + "datapro:business", + ]); + assert.deepEqual(current.progress_detail, { + current: 1, + total: 2, + message: "正在核验专业资料 1/2", + }); + assert.deepEqual( + calls.filter((call) => call.operation === "checkpoint") + .map((call) => call.options.stage), + ["collecting_professional"], + ); + const released = calls.find((call) => call.operation === "release"); + assert.equal(released.options.retry, true); + assert.equal(released.options.delay_seconds, 5); + assert.equal(released.error.category, "timeout"); +}); + +test("running cancellation keeps the lease until the worker reaches a safe checkpoint", async () => { + const calls = []; + let current = { + id: "job-cancel", + job_type: "sales_dossier_generation", + entity_id: "company-1", + status: "running", + stage: "generating_dossier", + progress: 70, + worker_id: "worker-test", + is_paid: true, + reservation_id: "reservation-test", + }; + const initial = salesState(); + initial.jobs[current.id] = current; + const repository = { + async getSalesState() { + return initial; + }, + async getJob() { + return current; + }, + async requestJobCancellation() { + calls.push("request"); + current = { + ...current, + stage: "cancelling", + cancel_requested_at: "2026-07-23T12:00:00.000Z", + }; + return current; + }, + async acknowledgeJobCancellation(jobId, workerId) { + calls.push({ operation: "acknowledge", jobId, workerId }); + current = { + ...current, + status: "cancelled", + stage: "cancelled", + worker_id: null, + lease_expires_at: null, + }; + return current; + }, + }; + const service = new SalesService({ + env: envReader({ ASYNC_JOBS_ENABLED: "true", APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + repository, + }); + + await service.assertRuntimeReady(); + const requested = await service.cancelJob(current.id); + assert.equal(requested.status, "running"); + assert.equal(requested.stage, "cancelling"); + assert.equal(requested.worker_id, "worker-test"); + + await assert.rejects( + () => service.assertJobActive(current.id), + (error) => error.code === "job_cancelled", + ); + assert.equal(current.status, "cancelled"); + assert.deepEqual(calls, [ + "request", + { operation: "acknowledge", jobId: "job-cancel", workerId: "worker-test" }, + ]); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/asyncQueueMigration.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/asyncQueueMigration.test.mjs new file mode 100644 index 00000000..b24739fc --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/asyncQueueMigration.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const backendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const rootDir = path.resolve(backendDir, ".."); +const queueMigration = await fs.readFile( + path.join(rootDir, "supabase", "migrations", "202607230002_async_job_queue.sql"), + "utf8", +); +const cancellationMigration = await fs.readFile( + path.join(rootDir, "supabase", "migrations", "202607230003_safe_job_cancellation.sql"), + "utf8", +); +const terminalRunMigration = await fs.readFile( + path.join(rootDir, "supabase", "migrations", "202607290001_reconcile_terminal_job_provider_runs.sql"), + "utf8", +); +const durableCheckpointMigration = await fs.readFile( + path.join(rootDir, "supabase", "migrations", "202607300001_durable_job_checkpoints.sql"), + "utf8", +); +const smoke = await fs.readFile( + path.join(rootDir, "supabase", "tests", "202607230003_async_job_queue_smoke.sql"), + "utf8", +); + +test("asynchronous queue migration keeps claiming and paid execution atomic", () => { + assert.match(queueMigration, /for update skip locked/); + assert.match(queueMigration, /create or replace function public\.enqueue_sales_job/); + assert.match(queueMigration, /create or replace function public\.claim_sales_job/); + assert.match(queueMigration, /create or replace function public\.release_sales_job_claim/); + assert.match(queueMigration, /and not v_has_reservation/); + assert.match(queueMigration, /where j\.workspace_id = p_workspace_id[\s\S]*?and j\.status = 'running'/); +}); + +test("safe cancellation is delivered as a forward-only migration", () => { + assert.match(cancellationMigration, /values \('202607230003'/); + assert.match(cancellationMigration, /add column if not exists cancel_requested_at/); + assert.match(cancellationMigration, /create or replace function public\.heartbeat_sales_job/); + assert.match(cancellationMigration, /create or replace function public\.request_cancel_sales_job/); + assert.match(cancellationMigration, /create or replace function public\.acknowledge_cancel_sales_job/); + assert.match(cancellationMigration, /stage = 'cancelling'/); + assert.match(cancellationMigration, /create or replace function public\.retry_sales_job/); + assert.match(cancellationMigration, /create or replace function public\.finish_paid_workflow/); + assert.match(cancellationMigration, /to service_role/); + assert.match(cancellationMigration, /revoke all[\s\S]*?from public, anon, authenticated/); +}); + +test("terminal jobs close orphaned provider runs and active steps", () => { + assert.match(terminalRunMigration, /create or replace function public\.reconcile_terminal_job_provider_runs/); + assert.match(terminalRunMigration, /after update of status, error_json on public\.jobs/); + assert.match(terminalRunMigration, /update public\.provider_run_steps/); + assert.match(terminalRunMigration, /update public\.provider_runs/); + assert.match(terminalRunMigration, /and r\.status = 'running'/); + assert.match(terminalRunMigration, /values \('202607290001'/); + assert.match(terminalRunMigration, /Reconcile runs that were orphaned before this trigger was installed/); +}); + +test("durable job checkpoints preserve completed work and allow bounded paid-stage retries", () => { + assert.match(durableCheckpointMigration, /add column if not exists checkpoint_json jsonb/i); + assert.match(durableCheckpointMigration, /add column if not exists progress_detail_json jsonb/i); + assert.match(durableCheckpointMigration, /create or replace function public\.checkpoint_sales_job/i); + assert.match(durableCheckpointMigration, /checkpoint_json = j\.checkpoint_json \|\| v_patch/i); + assert.match(durableCheckpointMigration, /stage = case when v_should_retry then 'retry_wait' else 'failed' end/i); + assert.match(durableCheckpointMigration, /v_job\.attempt_count < v_job\.max_attempts/i); + assert.doesNotMatch( + durableCheckpointMigration, + /v_should_retry[\s\S]{0,120}not v_has_reservation/i, + ); + assert.match(durableCheckpointMigration, /release_reason'[\s\S]{0,180}'retryable_worker_failure'/i); + assert.match(durableCheckpointMigration, /values \('202607300001'/); +}); + +test("queue smoke covers safe retry, heartbeat, reservation and rollback", () => { + assert.match(smoke, /^begin;/m); + assert.match(smoke, /enqueue_sales_job/); + assert.match(smoke, /claim_sales_job/); + assert.match(smoke, /heartbeat_sales_job/); + assert.match(smoke, /release_sales_job_claim/); + assert.match(smoke, /request_cancel_sales_job/); + assert.match(smoke, /acknowledge_cancel_sales_job/); + assert.match(smoke, /reserve_paid_workflow/); + assert.match(smoke, /finish_paid_workflow/); + assert.match(smoke, /^rollback;/m); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/authService.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/authService.test.mjs new file mode 100644 index 00000000..6c839202 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/authService.test.mjs @@ -0,0 +1,254 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { AuthService } from "../src/security/authService.js"; + +const workspaceId = "54768bef-53aa-47d0-a9e3-bbca4593cf58"; +const userId = "11111111-2222-4333-8444-555555555555"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + number(name, fallback) { + const value = Number(this.value(name, fallback)); + return Number.isFinite(value) ? value : fallback; + }, + }; +} + +function responseRecorder() { + return { + headers: {}, + setHeader(name, value) { + this.headers[String(name).toLowerCase()] = value; + }, + }; +} + +function dataProviderFixture(role = null) { + const state = { + profiles: role ? [{ id: userId, display_name: "测试用户" }] : [], + members: role ? [{ workspace_id: workspaceId, user_id: userId, role }] : [], + workspaceUpdates: [], + }; + return { + state, + isConfigured: () => true, + async select(table) { + if (table === "app_workspace_members") return structuredClone(state.members); + if (table === "app_users") return structuredClone(state.profiles); + return []; + }, + async upsert(table, rows) { + if (table === "app_users") state.profiles = structuredClone(rows); + if (table === "app_workspace_members") state.members = structuredClone(rows); + return structuredClone(rows); + }, + async update(table, values, filters) { + state.workspaceUpdates.push({ table, values, filters }); + return []; + }, + }; +} + +function authFetchFixture() { + const calls = []; + return { + calls, + async fetch(url, options) { + const parsed = new URL(url); + calls.push({ pathname: parsed.pathname, search: parsed.search, method: options.method, body: options.body }); + if (parsed.pathname.endsWith("/admin/users") && options.method === "POST") { + return new Response(JSON.stringify({ id: userId, email: "owner@example.com" }), { status: 200 }); + } + if (parsed.pathname.endsWith(`/admin/users/${userId}`) && options.method === "GET") { + return new Response(JSON.stringify({ id: userId, email: "owner@example.com" }), { status: 200 }); + } + if (parsed.pathname.endsWith(`/admin/users/${userId}`) && options.method === "PUT") { + return new Response(JSON.stringify({ id: userId, email: "owner@example.com" }), { status: 200 }); + } + if (parsed.pathname.endsWith("/token") && parsed.searchParams.get("grant_type") === "password") { + return new Response(JSON.stringify({ + access_token: "access-token", + refresh_token: "refresh-token", + expires_in: 3600, + }), { status: 200 }); + } + if (parsed.pathname.endsWith("/token") && parsed.searchParams.get("grant_type") === "refresh_token") { + return new Response(JSON.stringify({ + access_token: "refreshed-access-token", + refresh_token: "rotated-refresh-token", + expires_in: 7200, + }), { status: 200 }); + } + if (parsed.pathname.endsWith("/user")) { + return new Response(JSON.stringify({ + id: userId, + email: "owner@example.com", + user_metadata: { display_name: "测试用户" }, + }), { status: 200 }); + } + return new Response(JSON.stringify({ message: "unexpected" }), { status: 500 }); + }, + }; +} + +function createService(provider, fetchFixture) { + return new AuthService({ + env: envReader({ + SUPABASE_API_URL: "https://supabase.example.test/rest/v1", + SUPABASE_SERVICE_ROLE_KEY: "service-role-secret", + APP_WORKSPACE_ID: workspaceId, + HTTP_AUTH_ENABLED: "true", + AUTH_BOOTSTRAP_ENABLED: "true", + }), + dataProvider: provider, + fetchImpl: fetchFixture.fetch, + }); +} + +test("first-run setup creates one confirmed local administrator without exposing email", async () => { + const provider = dataProviderFixture(); + const authFetch = authFetchFixture(); + const service = createService(provider, authFetch); + const response = responseRecorder(); + + const result = await service.bootstrap({ + username: "测试用户", + password: "a-secure-password", + }, response); + + assert.equal(result.authenticated, true); + assert.equal(result.user.username, "测试用户"); + assert.equal(Object.hasOwn(result.user, "email"), false); + assert.equal(Object.hasOwn(result.user, "role"), false); + assert.deepEqual(provider.state.members, [{ workspace_id: workspaceId, user_id: userId, role: "owner" }]); + assert.equal(provider.state.workspaceUpdates[0].values.created_by, userId); + const createBody = JSON.parse(authFetch.calls.find((call) => call.pathname.endsWith("/admin/users"))?.body || "{}"); + assert.equal(createBody.email_confirm, true); + assert.match(createBody.email, /^owner-[a-f0-9]{24}@sales-workbench\.invalid$/); + assert.equal(createBody.user_metadata.username, "测试用户"); + assert.equal(response.headers["set-cookie"].length, 3); + assert.match(response.headers["set-cookie"][0], /siw_access=.*HttpOnly.*SameSite=Strict/); + assert.match(response.headers["set-cookie"][1], /siw_refresh=.*Max-Age=31536000.*HttpOnly.*SameSite=Strict/); + assert.doesNotMatch(response.headers["set-cookie"].join(" | "), /service-role-secret/); +}); + +test("a valid long-lived cookie restores login after the short-lived access cookie expires", async () => { + const provider = dataProviderFixture("owner"); + const authFetch = authFetchFixture(); + const service = createService(provider, authFetch); + const response = responseRecorder(); + + const auth = await service.authenticateRequest({ + headers: { + cookie: "siw_refresh=refresh-token; siw_csrf=csrf-token", + }, + }, response); + + assert.equal(auth.source, "cookie"); + assert.equal(auth.principal.username, "测试用户"); + assert.ok(authFetch.calls.some( + (call) => call.pathname.endsWith("/token") + && call.search.includes("grant_type=refresh_token"), + )); + assert.match(response.headers["set-cookie"][0], /siw_access=refreshed-access-token/); + assert.match(response.headers["set-cookie"][1], /siw_refresh=rotated-refresh-token.*Max-Age=31536000/); +}); + +test("an expired Supabase JWT is reported as an expired session so clients can refresh", async () => { + const provider = dataProviderFixture("owner"); + const service = createService(provider, { + async fetch() { + return new Response(JSON.stringify({ + error_code: "bad_jwt", + msg: "invalid JWT: token is expired", + }), { status: 403 }); + }, + }); + + await assert.rejects( + () => service.authenticateRequest({ + headers: { authorization: "Bearer expired-access-token" }, + }, responseRecorder()), + (error) => error.status === 401 && error.code === "invalid_credentials", + ); +}); + +test("username login keeps authorization internal and supports the existing account binding", async () => { + const provider = dataProviderFixture("viewer"); + const authFetch = authFetchFixture(); + const service = createService(provider, authFetch); + const result = await service.login({ + username: "测试用户", + password: "a-secure-password", + }, responseRecorder()); + + assert.equal(result.user.username, "测试用户"); + assert.equal(Object.hasOwn(result.user, "role"), false); + const session = await service.passwordSession("测试用户", "a-secure-password"); + service.requireRole({ principal: session.principal }, "viewer"); + assert.throws( + () => service.requireRole({ principal: session.principal }, "member"), + (error) => error.status === 403 && error.code === "insufficient_role", + ); +}); + +test("legacy email credentials remain compatible without exposing email in the public session", async () => { + const provider = dataProviderFixture("owner"); + const authFetch = authFetchFixture(); + const service = createService(provider, authFetch); + const result = await service.login({ + email: "owner@example.com", + password: "a-secure-password", + }, responseRecorder()); + + assert.equal(result.authenticated, true); + assert.equal(result.user.username, "测试用户"); + assert.equal(Object.hasOwn(result.user, "email"), false); + assert.equal(Object.hasOwn(result.user, "role"), false); + assert.equal( + authFetch.calls.some((call) => call.pathname.endsWith(`/admin/users/${userId}`) && call.method === "GET"), + false, + ); +}); + +test("cookie-authenticated mutations require a matching CSRF token", () => { + const provider = dataProviderFixture("member"); + const service = createService(provider, authFetchFixture()); + const auth = { source: "cookie", principal: { id: userId, role: "member" } }; + + assert.throws( + () => service.assertCsrf({ headers: { cookie: "siw_csrf=expected", "x-csrf-token": "wrong" } }, auth), + (error) => error.status === 403 && error.code === "csrf_failed", + ); + assert.doesNotThrow(() => service.assertCsrf({ + headers: { cookie: "siw_csrf=expected", "x-csrf-token": "expected" }, + }, auth)); + assert.doesNotThrow(() => service.assertCsrf({ headers: {} }, { ...auth, source: "bearer" })); +}); + +test("CLI login and refresh return only user-scoped bearer sessions", async () => { + const provider = dataProviderFixture("member"); + const authFetch = authFetchFixture(); + const service = createService(provider, authFetch); + + const loggedIn = await service.cliLogin({ + username: "测试用户", + password: "a-secure-password", + }); + assert.equal(loggedIn.token_type, "bearer"); + assert.equal(loggedIn.access_token, "access-token"); + assert.equal(loggedIn.refresh_token, "refresh-token"); + assert.equal(loggedIn.user.username, "测试用户"); + assert.equal(Object.hasOwn(loggedIn.user, "role"), false); + assert.equal(Object.hasOwn(loggedIn.user, "email"), false); + assert.equal(Object.hasOwn(loggedIn, "service_role_key"), false); + + const refreshed = await service.cliRefresh({ refresh_token: loggedIn.refresh_token }); + assert.equal(refreshed.access_token, "refreshed-access-token"); + assert.equal(refreshed.refresh_token, "rotated-refresh-token"); + assert.equal(refreshed.expires_in, 7200); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/businessChainVerifier.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/businessChainVerifier.test.mjs new file mode 100644 index 00000000..cc293e5c --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/businessChainVerifier.test.mjs @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + assertDossierPersistenceBoundary, + assertProviderRun, + collectPrivatePaths, + parseArgs, + pollJob, + selectCandidate, + usageSummary, + validateDossier, + validateQa, +} from "../scripts/verify-business-chain.mjs"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); + +test("legacy real-chain script cannot be mistaken for real runtime evidence", () => { + const source = fs.readFileSync(path.join(testDir, "..", "scripts", "real-chain-check.mjs"), "utf8"); + assert.doesNotMatch(source, /createMockProviders|MemoryRepository|DemoService/); + assert.match(source, /旧脚本已停用/); +}); + +test("business verifier requires explicit live confirmation, enterprise identity, and a QA question", () => { + assert.throws( + () => parseArgs(["--enterprise-id", "company-1", "--question", "当前重点?"]), + /--confirm-live/, + ); + assert.throws( + () => parseArgs(["--enterprise-id", "company-1", "--confirm-live"]), + /--question/, + ); + const parsed = parseArgs([ + "--enterprise-id", "company-1", + "--question", "当前重点?", + "--confirm-live", + ]); + assert.equal(parsed.enterpriseId, "company-1"); + assert.equal(parsed.confirmLive, true); +}); + +test("company selection never picks an ambiguous first result", () => { + const candidates = [ + { id: "one", name: "示例科技有限公司", identity_status: "verified" }, + { id: "two", name: "示例科技(北京)有限公司", identity_status: "verified" }, + ]; + assert.equal( + selectCandidate(candidates, { companyQuery: "示例科技有限公司", candidateId: "" }).id, + "one", + ); + assert.throws( + () => selectCandidate(candidates, { companyQuery: "示例科技", candidateId: "" }), + /无法唯一确定企业主体/, + ); + assert.throws( + () => selectCandidate([{ id: "draft", name: "待核验", identity_status: "unverified" }], { + companyQuery: "待核验", + candidateId: "", + }), + /未通过专业数据集主体核验/, + ); +}); + +test("dossier and QA acceptance require scoped citations and reject internal fields", () => { + const dossier = { + id: "dossier-1", + company_id: "company-1", + citations: [ + { id: "1", source_kind: "专业数据集", label: "企业工商数据库" }, + { id: "2", source_kind: "联网搜索", label: "企业官网公告" }, + ], + body: [ + { text: "企业情况:已核验。", citation_ids: ["1"] }, + { text: "近期动态:有公开公告。", citation_ids: ["2"] }, + ], + }; + const dossierChecks = validateDossier(dossier, "company-1"); + assert.equal(dossierChecks.citationCount, 2); + assert.throws( + () => validateDossier({ ...dossier, raw_ref: "internal" }, "company-1"), + /暴露了内部字段/, + ); + assert.throws( + () => validateDossier({ ...dossier, body: [{ text: "没有引用", citation_ids: [] }] }, "company-1"), + /缺少引用/, + ); + + const qaChecks = validateQa({ + message: { + id: "qa-1", + role: "assistant", + insufficient: false, + citations: [{ id: "1", label: "最近档案" }], + paragraphs: [{ text: "可核验回答。", citation_ids: ["1"] }], + }, + }); + assert.equal(qaChecks.citationCount, 1); +}); + +test("provider evidence requires each expected real provider to succeed", () => { + const run = { + id: "run-1", + status: "succeeded", + steps: [ + { provider: "datapro", status: "succeeded" }, + { provider: "web_search", status: "succeeded" }, + ], + }; + assert.doesNotThrow(() => assertProviderRun(run, ["datapro", "web_search"], "企业搜索")); + assert.throws( + () => assertProviderRun({ + ...run, + status: "succeeded_with_issues", + steps: [{ provider: "datapro", status: "succeeded" }, { provider: "web_search", status: "failed" }], + }, ["datapro", "web_search"], "企业搜索"), + /未成功:web_search/, + ); +}); + +test("dossier acceptance enforces Supabase persistence without duplicating the report in OpenViking", () => { + assert.doesNotThrow(() => assertDossierPersistenceBoundary({ + steps: [{ + provider: "openviking", + operation: "store_dossier_memory", + status: "skipped", + output_summary: "档案属于结构化业务记录,由 Supabase 保存,不重复写入 OpenViking。", + }], + })); + assert.throws( + () => assertDossierPersistenceBoundary({ + steps: [{ + provider: "openviking", + operation: "store_dossier_memory", + status: "succeeded", + output_summary: "已重复保存。", + }], + }), + /未遵守 Supabase 持久化/, + ); +}); + +test("job polling returns a succeeded job and usage aggregation uses recorded attempts", async () => { + const jobs = [ + { id: "job-1", status: "running", stage: "generating", progress: 50 }, + { id: "job-1", status: "succeeded", stage: "succeeded", progress: 100, result: { dossier_id: "d-1" } }, + ]; + const job = await pollJob({ timeoutMs: 1000, pollMs: 250 }, "job-1", async () => jobs.shift()); + assert.equal(job.result.dossier_id, "d-1"); + + const usage = usageSummary([{ + steps: [ + { provider: "model", attempts: 1, usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 } }, + { provider: "web_search", attempts: 2, usage: null }, + ], + }]); + assert.deepEqual(usage.model, { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 }); + assert.equal(usage.provider_attempts.web_search, 2); +}); + +test("recursive public response scan catches nested secret-bearing keys", () => { + assert.deepEqual(collectPrivatePaths({ safe: { access_token: "hidden" } }), ["$.safe.access_token"]); + assert.deepEqual(collectPrivatePaths({ safe: [{ label: "ok" }] }), []); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/claimGrounding.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/claimGrounding.test.mjs new file mode 100644 index 00000000..a8a4ad36 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/claimGrounding.test.mjs @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + deriveEvidenceDataAsOf, + evidenceSpanErrors, + extractGroundingOrganizations, + groundedTextErrors, +} from "../src/evidence/claimGrounding.js"; + +const procurementSummary = [ + "大模型提示词攻击防护软件产品采购结果信息公开。", + "入选供应商:北京火山引擎科技有限公司。", + "采购价格(元):630,088。", + "财务会计部采购部 2026年7月15日。", +].join(" "); + +test("claim grounding accepts dates and amounts that appear in the cited evidence", () => { + assert.deepEqual(groundedTextErrors({ + text: "2026年7月15日,北京火山引擎科技有限公司入选提示词攻击防护软件采购项目,采购价格为630,088元。", + evidenceTexts: [procurementSummary], + path: "近期公开动态第 1 段", + requireEventFamily: true, + }), []); + assert.deepEqual(groundedTextErrors({ + text: "测试科技有限公司成立于2020年5月11日。", + evidenceTexts: ["公司名称:测试科技有限公司;成立日期:2020-05-11T08:00:00。"], + path: "企业与业务概览第 1 条", + }), []); +}); + +test("claim grounding rejects a different dated event and an unsupported named entity", () => { + const errors = groundedTextErrors({ + text: "2026年7月16日,华夏银行发布AIBOX项目成交候选公示。", + evidenceTexts: [procurementSummary], + path: "近期公开动态第 1 段", + requireEventFamily: true, + }); + + assert.ok(errors.some((item) => item.includes("日期 2026-07-16"))); + assert.ok(errors.some((item) => item.includes("实体 AIBOX"))); + assert.ok(errors.some((item) => item.includes("机构名称“华夏银行”"))); +}); + +test("claim grounding names the unsupported event wording so a revision can repair it", () => { + const errors = groundedTextErrors({ + text: "北京火山引擎科技有限公司已完成该软件项目交付。", + evidenceTexts: [procurementSummary], + path: "近期公开动态第 1 条", + requireEventFamily: true, + }); + + assert.ok(errors.some((item) => item.includes("事件表述“交付”"))); +}); + +test("claim grounding does not treat words inside the verified legal name as a new event", () => { + assert.deepEqual(groundedTextErrors({ + text: "博世(中国)投资有限公司在中国开展汽车技术相关业务。", + evidenceTexts: ["该企业在中国开展汽车技术相关业务。"], + path: "企业与业务概览第 1 条", + requireEventFamily: true, + ignoredEntityNames: ["博世(中国)投资有限公司"], + }), []); +}); + +test("organization grounding ignores predicate fragments before a group suffix", () => { + assert.deepEqual( + extractGroundingOrganizations("相关业务可能受集团统一政策影响。"), + [], + ); + assert.deepEqual(groundedTextErrors({ + text: "相关业务可能受集团统一政策影响。", + evidenceTexts: ["相关业务受到统一政策影响。"], + path: "风险与关注事项第 1 条", + }), []); + assert.deepEqual( + extractGroundingOrganizations("博世集团持续推进相关业务。"), + ["博世集团"], + ); +}); + +test("evidence spans must be continuous verbatim excerpts from the selected citation", () => { + assert.deepEqual(evidenceSpanErrors({ + citation_id: "source_1", + quote: "采购价格(元):630,088。", + }, { + id: "source_1", + summary: procurementSummary, + }), []); + assert.ok(evidenceSpanErrors({ + citation_id: "source_1", + quote: "华夏银行发布成交候选公示。", + }, { + id: "source_1", + summary: procurementSummary, + }).some((item) => item.includes("连续原文"))); +}); + +test("data-as-of uses cited public event dates when provider metadata is stale", () => { + const value = deriveEvidenceDataAsOf([{ + source_kind: "联网搜索", + published_at: "2026-06-10T16:00:00.000Z", + summary: procurementSummary, + }], "2026-07-29T10:00:00.000Z"); + + assert.equal(value, "2026-07-15T00:00:00.000Z"); +}); + +test("grounding ignores company identifiers unless the report changes them", () => { + const evidence = "统一社会信用代码:913100007109203974;注册地址:上海市长宁区福泉北路333号1幢6楼。"; + assert.deepEqual(groundedTextErrors({ + text: "该公司的统一社会信用代码为913100007109203974,注册地址为上海市长宁区福泉北路333号1幢6楼。", + evidenceTexts: [evidence], + path: "企业与业务概览第 1 段", + }), []); + assert.ok(groundedTextErrors({ + text: "该公司的统一社会信用代码为913100007109203975。", + evidenceTexts: [evidence], + path: "企业与业务概览第 1 段", + }).some((item) => item.includes("数值"))); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dataProQueryPlanner.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dataProQueryPlanner.test.mjs new file mode 100644 index 00000000..40dfb800 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dataProQueryPlanner.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { DataProProvider } from "../src/providers/dataProProvider.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + number(name, fallback = 0) { + const value = Object.hasOwn(values, name) ? Number(values[name]) : fallback; + return Number.isFinite(value) ? value : fallback; + }, + }; +} + +test("DataPro allows slower professional queries while preserving an explicit override", () => { + assert.equal(new DataProProvider({ env: envReader() }).timeoutMs, 45_000); + assert.equal( + new DataProProvider({ env: envReader({ DATAPRO_TIMEOUT_MS: "30000" }) }).timeoutMs, + 30_000, + ); +}); + +test("dossier query planner selects business, risk, and industry datasets through the same MCP", () => { + const provider = new DataProProvider({ env: envReader({ DATAPRO_MAX_SOURCES: "4" }) }); + const queries = provider.planDossierQueries({ + name: "示例汽车股份有限公司", + industry: "新能源汽车整车制造", + unified_social_credit_code: "91110000123456789X", + business_scope: "新能源汽车研发、生产与销售", + registered_capital: "10000万元", + }); + + assert.deepEqual(queries.map((item) => item.label), [ + "企业工商数据库", + "企业风险数据库", + "汽车销量数据库", + "金融数据库", + ]); + assert.equal(queries.every((item) => item.query.includes("示例汽车股份有限公司")), true); +}); + +test("dossier query planner prioritizes business identity when it has not been verified", () => { + const provider = new DataProProvider({ env: envReader({ DATAPRO_MAX_SOURCES: "2" }) }); + const queries = provider.planDossierQueries({ + name: "示例科技有限公司", + industry: "企业软件", + }); + + assert.deepEqual(queries.map((item) => item.label), [ + "企业工商数据库", + "企业风险数据库", + ]); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dossierAgent.realFailureRegression.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dossierAgent.realFailureRegression.test.mjs new file mode 100644 index 00000000..9370ce68 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dossierAgent.realFailureRegression.test.mjs @@ -0,0 +1,360 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DossierAgent } from "../src/agents/dossierAgent.js"; +import { + evidenceSpanErrors, + groundedTextErrors, +} from "../src/evidence/claimGrounding.js"; + +const SECTION_KEYS = [ + "company_overview", + "business_dynamics", + "recent_public_updates", + "risk_attention", + "sales_opportunity", + "recommended_actions", +]; + +const PROCUREMENT_SUMMARY = [ + "虚构软件产品采购结果信息公开。", + "入选供应商:云穹矩阵科技有限公司。", + "采购价格(元):630,088。", + "采购部 2026年7月15日。", +].join(" "); + +function fixture() { + const citations = SECTION_KEYS.map((key, index) => ({ + id: `citation_${key}`, + source_kind: key === "recent_public_updates" ? "联网搜索" : "专业数据集", + summary: index === 0 + ? PROCUREMENT_SUMMARY + : `云穹矩阵科技有限公司为${key}提供可引用的完整业务事实。`, + quality_tier: 1, + independence_key: `source:${key}`, + })); + const evidenceAtoms = SECTION_KEYS.map((key, index) => ({ + id: `E_${String(index + 1).padStart(20, "0")}`, + citation_id: citations[index].id, + quote: citations[index].summary, + section_candidates: [key], + entity_match: "verified", + score: 80, + source_kind: key === "recent_public_updates" ? "public" : "professional", + source_type: key === "recent_public_updates" ? "web" : "datapro", + title: `${key} evidence`, + reliability: "professional", + conflict_fields: [], + })); + return { + company: { + name: "云穹矩阵科技有限公司", + legal_name: "云穹矩阵科技有限公司", + industry: "企业软件", + location: "北京", + }, + citations, + evidenceAtoms, + evidenceCoverage: Object.fromEntries(evidenceAtoms.map((atom, index) => [ + SECTION_KEYS[index], + { status: "supported", atom_ids: [atom.id], reasons: [] }, + ])), + evidencePolicy: { fail_closed: true }, + evidenceConflicts: [], + sourceSelectionPolicy: {}, + instructions: ["只使用输入 Evidence Atom。"], + }; +} + +function validResponse(request) { + return { + sections: Object.fromEntries( + request.parameters.properties.sections.required.map((key) => [ + key, + { + text: key === "company_overview" + ? "云穹矩阵科技有限公司入选虚构软件产品采购项目。" + : `云穹矩阵科技有限公司为${key}提供可引用的完整业务事实。`, + evidence_ids: [request.payload.evidence_by_section[key].allowed_evidence[0].id], + }, + ]), + ), + }; +} + +function createAgent(factory) { + return new DossierAgent({ + maxCalls: 2, + callModel: factory, + validate: (answer) => ({ body: answer.body, errors: [] }), + }); +} + +test("regression 1a: paraphrased or fabricated quote remains invalid", () => { + assert.ok(evidenceSpanErrors({ + citation_id: "source_1", + quote: "采购价格630,088元", + }, { + id: "source_1", + summary: PROCUREMENT_SUMMARY, + }).some((error) => error.includes("连续原文"))); + assert.deepEqual(evidenceSpanErrors({ + citation_id: "source_1", + quote: "采购价格(元):630,088。", + }, { + id: "source_1", + summary: PROCUREMENT_SUMMARY, + }), []); +}); + +test("regression 1b: model-supplied quote fields cannot change the server-derived quote", async () => { + const input = fixture(); + const agent = createAgent(async (request) => { + const parsed = validResponse(request); + parsed.sections.company_overview.quote = "采购价格630,088元"; + parsed.sections.company_overview.citation_id = "fabricated"; + return { ok: true, parsed, raw_ref: "model:ignored-extra-fields" }; + }); + + const result = await agent.run(input); + const atom = input.evidenceAtoms[0]; + + assert.equal(result.ok, true); + assert.deepEqual(result.approved_plan.sections.company_overview.evidence_spans, [{ + evidence_id: atom.id, + citation_id: atom.citation_id, + quote: atom.quote, + }]); + assert.doesNotMatch( + JSON.stringify(result.approved_plan.sections.company_overview), + /采购价格630,088元|fabricated/, + ); +}); + +test("regression 2a: unsupported organization names remain rejected", () => { + const errors = groundedTextErrors({ + text: "远川样例银行与云穹矩阵科技有限公司存在未披露的关联安排。", + evidenceTexts: [PROCUREMENT_SUMMARY], + path: "风险与关注事项第 1 条", + requireEventFamily: true, + }); + assert.ok(errors.some((error) => error.includes("机构名称"))); +}); + +test("regression 2b: the Agent fails closed on an unsupported organization", async () => { + let calls = 0; + const agent = createAgent(async (request) => { + calls += 1; + const parsed = validResponse(request); + parsed.sections.risk_attention.text = "远川样例银行与云穹矩阵科技有限公司存在未披露的关联风险。"; + return { ok: true, parsed, raw_ref: `model:${calls}` }; + }); + + const result = await agent.run(fixture()); + + assert.equal(result.ok, false); + assert.equal(result.stage, "planning"); + assert.equal(calls, 2); + assert.ok(result.validation_errors.some((error) => error.includes("机构名称"))); +}); + +test("regression 3a: unsupported numbers in action text remain rejected", () => { + const errors = groundedTextErrors({ + text: "建议针对5000万元预算联系产品负责人。", + evidenceTexts: [PROCUREMENT_SUMMARY], + path: "建议行动第 1 条", + }); + assert.ok(errors.some((error) => error.includes("5000"))); +}); + +test("regression 3b: the Agent fails closed when an action fabricates numbers", async () => { + let calls = 0; + const agent = createAgent(async (request) => { + calls += 1; + const parsed = validResponse(request); + parsed.sections.recommended_actions.text = "销售人员应按5000万元预算准备交付方案。"; + return { ok: true, parsed, raw_ref: `model:${calls}` }; + }); + + const result = await agent.run(fixture()); + + assert.equal(result.ok, false); + assert.equal(result.stage, "planning"); + assert.equal(calls, 2); + assert.ok(result.validation_errors.some((error) => error.includes("5000"))); + assert.equal(result.submission, undefined); +}); + +test("regression 3c: a second localized repair removes an unsupported number without weakening validation", async () => { + const calls = []; + const agent = new DossierAgent({ + maxCalls: 3, + callModel: async (request) => { + calls.push(structuredClone(request)); + const parsed = validResponse(request); + if (calls.length < 3) { + parsed.sections.company_overview.text = "云穹矩阵科技有限公司入选1309项虚构软件产品采购项目。"; + } + return { ok: true, parsed, raw_ref: `model:${calls.length}` }; + }, + validate: (answer) => ({ body: answer.body, errors: [] }), + }); + + const result = await agent.run(fixture()); + + assert.equal(result.ok, true); + assert.equal(calls.length, 3); + assert.equal(calls[1].operation, "sales_dossier_agent_replan"); + assert.equal(calls[2].operation, "sales_dossier_agent_replan"); + assert.deepEqual(calls[1].payload.repair_section_keys, ["company_overview"]); + assert.deepEqual(calls[2].payload.repair_section_keys, ["company_overview"]); + assert.deepEqual(calls[1].payload.repair_directives[0].unsupported_numbers, ["1309"]); + assert.deepEqual(calls[2].payload.repair_directives[0].unsupported_numbers, ["1309"]); + assert.deepEqual(calls[1].payload.forbidden_grounding_values, ["1309"]); + assert.deepEqual(calls[2].payload.forbidden_grounding_values, ["1309"]); + assert.deepEqual(calls[1].payload.previous_plan.sections.company_overview, { + text: "", + evidence_ids: [], + }); + assert.deepEqual(calls[2].payload.previous_plan.sections.company_overview, { + text: "", + evidence_ids: [], + }); + assert.doesNotMatch(result.submission.body[0].text, /1309/u); +}); + +test("regression 3d: the server deterministically selects the supporting same-section Atom", async () => { + const input = fixture(); + const supportingCitation = { + id: "citation_company_overview_supporting", + source_kind: "专业数据集", + summary: "云穹矩阵科技有限公司产品包括矩阵知识库,并与客户开展合作。", + quality_tier: 1, + independence_key: "source:company-overview-supporting", + }; + const supportingAtom = { + id: "E_00000000000000000099", + citation_id: supportingCitation.id, + quote: supportingCitation.summary, + section_candidates: ["company_overview"], + entity_match: "verified", + score: 70, + source_kind: "professional", + source_type: "datapro", + title: "company overview supporting evidence", + reliability: "professional", + conflict_fields: [], + }; + input.citations.push(supportingCitation); + input.evidenceAtoms.push(supportingAtom); + input.evidenceCoverage.company_overview.atom_ids.push(supportingAtom.id); + const calls = []; + const agent = new DossierAgent({ + maxCalls: 1, + callModel: async (request) => { + calls.push(structuredClone(request)); + const parsed = validResponse(request); + parsed.sections.company_overview = { + text: supportingCitation.summary, + evidence_ids: [input.evidenceAtoms[0].id], + }; + return { ok: true, parsed, raw_ref: "model:wrong-evidence-id" }; + }, + validate: (answer) => ({ body: answer.body, errors: [] }), + }); + + const result = await agent.run(input); + + assert.equal(result.ok, true); + assert.equal(calls.length, 1); + assert.deepEqual( + result.approved_plan.sections.company_overview.evidence_ids, + [supportingAtom.id], + ); + assert.deepEqual( + result.approved_plan.sections.company_overview.citation_ids, + [supportingCitation.id], + ); +}); + +test("regression 3e: analytical risk checklists do not treat generic cooperation wording as an asserted event", async () => { + const calls = []; + const agent = new DossierAgent({ + maxCalls: 1, + callModel: async (request) => { + calls.push(structuredClone(request)); + const parsed = validResponse(request); + parsed.sections.risk_attention.text = "销售合作前应核验项目边界和责任范围。"; + return { ok: true, parsed, raw_ref: "model:analytical-event" }; + }, + validate: (answer) => ({ body: answer.body, errors: [] }), + }); + + const result = await agent.run(fixture()); + + assert.equal(result.ok, true); + assert.equal(calls.length, 1); + assert.match(result.submission.body[3].text, /销售合作前应核验项目边界和责任范围/u); +}); + +test("generic supplier roles in a risk checklist do not masquerade as unsupported procurement events", async () => { + const agent = createAgent(async (request) => { + const parsed = validResponse(request); + parsed.sections.risk_attention.text = "销售对接前应核验供应商准入要求、数据合规边界和交付责任。"; + return { ok: true, parsed, raw_ref: "model:generic-supplier-role" }; + }); + + const result = await agent.run(fixture()); + + assert.equal(result.ok, true); + assert.match(result.submission.body[3].text, /供应商准入要求/u); +}); + +test("factual risk statements still reject an unsupported completed event", async () => { + const agent = createAgent(async (request) => { + const parsed = validResponse(request); + parsed.sections.risk_attention.text = "云穹矩阵科技有限公司已完成该项目交付。"; + return { ok: true, parsed, raw_ref: "model:unsupported-risk-event" }; + }); + + const result = await agent.run(fixture()); + + assert.equal(result.ok, false); + assert.ok(result.validation_errors.some((error) => error.includes("事件表述“交付”"))); +}); + +test("detailed complete sections are not rejected by the former 260-character limit", async () => { + const detailedAction = "销售人员应联系产品负责人,依次确认知识库覆盖范围、数据权限边界、部署方式、接口责任、试点排期、验收标准、运维安排、采购主体、预算审批路径、合同责任、业务牵头部门、技术评审角色、信息安全要求、扩容触发条件、服务响应边界、故障升级路径、需求变更方式、交付依赖条件和最终决策链,再准备与已确认范围一致的试点方案。书面确认记录还应覆盖沟通节奏、双方负责人、需求变更规则、交付依赖条件、上线回退方案、故障升级路径和最终验收责任。最终复盘清单需要明确记录已经核验的事实、仍待确认的问题、下一次沟通的负责人、对应截止时间、预期交付物和书面确认方式。"; + assert.ok(detailedAction.length > 260); + const agent = createAgent(async (request) => { + const parsed = validResponse(request); + parsed.sections.recommended_actions.text = detailedAction; + return { ok: true, parsed, raw_ref: "model:detailed-action" }; + }); + + const result = await agent.run(fixture()); + + assert.equal(result.ok, true); + assert.match(result.submission.body[5].text, /最终验收责任/u); +}); + +test("regression combined: invalid IDs, unsupported organizations and numbers all remain visible", async () => { + let calls = 0; + const agent = createAgent(async (request) => { + calls += 1; + const parsed = validResponse(request); + parsed.sections.company_overview.evidence_ids = ["E_invalid"]; + parsed.sections.risk_attention.text = "远川样例银行与云穹矩阵科技有限公司存在关联风险。"; + parsed.sections.recommended_actions.text = "销售人员应按5000万元预算准备方案。"; + return { ok: true, parsed, raw_ref: `model:${calls}` }; + }); + + const result = await agent.run(fixture()); + const errors = result.validation_errors.join("\n"); + + assert.equal(result.ok, false); + assert.equal(calls, 2); + assert.match(errors, /无效 Evidence ID/); + assert.match(errors, /机构名称/); + assert.match(errors, /5000/); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dossierAgent.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dossierAgent.test.mjs new file mode 100644 index 00000000..df73d2f3 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dossierAgent.test.mjs @@ -0,0 +1,491 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildDossierAgentContext, + buildDossierSourceUsageRequirements, + compileDossierFromPlan, + DossierAgent, + dossierSourceUsageErrors, +} from "../src/agents/dossierAgent.js"; + +const SECTION_KEYS = [ + "company_overview", + "business_dynamics", + "recent_public_updates", + "risk_attention", + "sales_opportunity", + "recommended_actions", +]; + +function atomInput() { + const citations = SECTION_KEYS.map((key, index) => ({ + id: `citation_${key}`, + source_kind: key === "recent_public_updates" ? "联网搜索" : "专业数据集", + summary: `测试科技有限公司为${key}提供可引用的完整业务事实。`, + quality_tier: 1, + independence_key: `source:${key}`, + entity_match: "verified", + })); + const evidenceAtoms = SECTION_KEYS.map((key, index) => ({ + id: `E_${String(index + 1).padStart(20, "0")}`, + citation_id: citations[index].id, + quote: citations[index].summary, + section_candidates: [key], + entity_match: "verified", + score: 80, + source_kind: key === "recent_public_updates" ? "public" : "professional", + source_type: key === "recent_public_updates" ? "web" : "datapro", + title: `${key} evidence`, + reliability: "professional", + conflict_fields: [], + })); + const evidenceCoverage = Object.fromEntries(evidenceAtoms.map((atom, index) => [ + SECTION_KEYS[index], + { status: "supported", atom_ids: [atom.id], reasons: [] }, + ])); + return { + company: { + name: "测试科技有限公司", + legal_name: "测试科技有限公司", + industry: "企业软件", + location: "北京", + }, + citations, + evidenceAtoms, + evidenceCoverage, + evidencePolicy: { fail_closed: true }, + evidenceConflicts: [], + sourceSelectionPolicy: {}, + instructions: ["只使用输入 Evidence Atom。"], + }; +} + +function responseFor(request, suffix = "") { + return { + sections: Object.fromEntries( + request.parameters.properties.sections.required.map((key) => [ + key, + { + text: `测试科技有限公司为${key}提供可引用的完整业务事实${suffix}。`, + evidence_ids: [request.payload.evidence_by_section[key].allowed_evidence[0].id], + }, + ]), + ), + }; +} + +test("dossier Agent context keeps citation and Atom projections bounded by selected sources", () => { + const citations = [ + ...Array.from({ length: 10 }, (_, index) => ({ + id: `professional_${index}`, + source_kind: "专业数据集", + label: index === 0 ? "企业工商数据库" : "金融数据库", + summary: `专业证据 ${index} ${"业务事实".repeat(500)}`, + quality_tier: index < 3 ? 1 : 2, + freshness: "current", + })), + ...Array.from({ length: 10 }, (_, index) => ({ + id: `public_${index}`, + source_kind: "联网搜索", + label: `公开来源 ${index}`, + summary: `公开事件 ${index} ${"项目进展".repeat(500)}`, + published_at: `2026-07-${String(20 - index).padStart(2, "0")}T00:00:00.000Z`, + quality_tier: 2, + freshness: "current", + })), + ]; + const evidenceAtoms = citations.map((citation, index) => ({ + id: `E_${String(index + 1).padStart(20, "0")}`, + citation_id: citation.id, + quote: citation.summary.slice(0, 80), + section_candidates: [SECTION_KEYS[index % SECTION_KEYS.length]], + entity_match: "verified", + score: 100 - index, + })); + const context = buildDossierAgentContext({ + citations, + evidenceAtoms, + sourceSelectionPolicy: { + business_database_ids: ["professional_0"], + professional_dataset_ids: citations.slice(0, 10).map((item) => item.id), + web_search_ids: citations.slice(10).map((item) => item.id), + }, + }); + + assert.equal(context.citations.length, 10); + assert.ok(context.metrics.professional_count >= 1); + assert.ok(context.metrics.public_count >= 1); + assert.equal( + context.metrics.professional_count + context.metrics.public_count, + context.citations.length, + ); + assert.ok(context.metrics.serialized_chars < 10_000); + assert.ok(context.metrics.selected_atom_count <= 10); + assert.ok(Object.values(context.evidenceBySection).every((items) => items.length <= 6)); +}); + +test("context cap preserves low-ranked sources that are indispensable to a section", () => { + const citations = [ + ...Array.from({ length: 10 }, (_, index) => ({ + id: `general_${index}`, + source_kind: "专业数据集", + label: "企业工商数据库", + summary: `测试科技有限公司经营企业软件业务,记录序号 ${100 + index}。`, + quality_tier: 1, + freshness: "current", + })), + { + id: "risk_low_rank", + source_kind: "专业数据集", + label: "企业风险数据库", + summary: "测试科技有限公司披露项目交付周期延长,需要核验实施排期。", + quality_tier: 4, + }, + { + id: "recent_low_rank", + source_kind: "联网搜索", + label: "产品升级公告", + summary: "2026年7月30日,测试科技有限公司披露产品升级进展。", + published_at: "2026-07-30T00:00:00.000Z", + quality_tier: 4, + }, + ]; + const evidenceAtoms = citations.map((citation, index) => ({ + id: `E_cap_${String(index + 1).padStart(14, "0")}`, + citation_id: citation.id, + quote: citation.summary, + section_candidates: citation.id === "risk_low_rank" + ? ["risk_attention"] + : citation.id === "recent_low_rank" + ? ["recent_public_updates"] + : ["company_overview", "business_dynamics", "sales_opportunity", "recommended_actions"], + entity_match: "verified", + score: citation.id.startsWith("general_") ? 100 - index : 10, + source_kind: citation.source_kind === "联网搜索" ? "public" : "professional", + })); + + const context = buildDossierAgentContext({ citations, evidenceAtoms }); + + assert.equal(context.citations.length, 10); + assert.ok(context.citations.some((citation) => citation.id === "risk_low_rank")); + assert.ok(context.citations.some((citation) => citation.id === "recent_low_rank")); + assert.equal(context.evidenceBySection.risk_attention[0].citation_id, "risk_low_rank"); + assert.equal(context.evidenceBySection.recent_public_updates[0].citation_id, "recent_low_rank"); +}); + +test("chapter candidates are restricted to the same qualified source policy used by final validation", () => { + const citations = [ + { + id: "business_verified", + source_kind: "专业数据集", + label: "企业工商数据库", + summary: "测试科技有限公司成立于2020年5月11日。", + quality_tier: 1, + entity_match: "verified", + }, + { + id: "risk_verified", + source_kind: "专业数据集", + label: "企业风险数据库", + summary: "测试科技有限公司披露一条需核验的诉讼记录。", + quality_tier: 1, + entity_match: "verified", + }, + { + id: "recent_verified", + source_kind: "联网搜索", + label: "官方项目公告", + summary: "2026年7月30日,测试科技有限公司公告中标人信息。", + quality_tier: 1, + published_at: "2026-07-30T00:00:00.000Z", + entity_match: "verified", + }, + { + id: "recent_marketing", + source_kind: "联网搜索", + label: "品牌营销页", + summary: "测试科技有限公司提供领先的全栈解决方案。", + quality_tier: 2, + entity_match: "verified", + }, + ]; + const evidenceAtoms = citations.map((citation, index) => ({ + id: `E_policy_${String(index + 1).padStart(12, "0")}`, + citation_id: citation.id, + quote: citation.summary, + section_candidates: citation.id.startsWith("recent_") + ? ["recent_public_updates"] + : citation.id === "risk_verified" + ? ["risk_attention"] + : ["company_overview"], + entity_match: "verified", + score: citation.id === "recent_marketing" ? 100 : 80, + source_kind: citation.source_kind === "联网搜索" ? "public" : "professional", + })); + const evidenceCoverage = { + company_overview: { status: "supported", atom_ids: [evidenceAtoms[0].id], reasons: [] }, + recent_public_updates: { + status: "supported", + atom_ids: [evidenceAtoms[2].id, evidenceAtoms[3].id], + reasons: [], + }, + risk_attention: { status: "supported", atom_ids: [evidenceAtoms[1].id], reasons: [] }, + }; + + const context = buildDossierAgentContext({ + citations, + evidenceAtoms, + evidenceCoverage, + sourceSelectionPolicy: { + business_database_ids: ["business_verified"], + risk_database_ids: ["risk_verified"], + web_search_ids: ["recent_verified"], + }, + }); + + assert.deepEqual( + context.evidenceBySection.company_overview.map((atom) => atom.citation_id), + ["business_verified"], + ); + assert.deepEqual( + context.evidenceBySection.risk_attention.map((atom) => atom.citation_id), + ["risk_verified"], + ); + assert.deepEqual( + context.evidenceBySection.recent_public_updates.map((atom) => atom.citation_id), + ["recent_verified"], + ); +}); + +test("single-source critical financial figures are excluded before planning", () => { + const citations = [ + { + id: "recent_single_profit", + source_kind: "联网搜索", + label: "公开网页", + summary: "2026年7月30日,测试科技有限公司公布净利润680亿元。", + quality_tier: 3, + entity_match: "verified", + independence_key: "public:single-profit", + }, + { + id: "recent_regular_event", + source_kind: "联网搜索", + label: "项目公告", + summary: "2026年7月29日,测试科技有限公司公告产品升级进展。", + quality_tier: 2, + entity_match: "verified", + independence_key: "public:regular-event", + }, + ]; + const evidenceAtoms = citations.map((citation, index) => ({ + id: `E_critical_${String(index + 1).padStart(10, "0")}`, + citation_id: citation.id, + quote: citation.summary, + section_candidates: ["recent_public_updates"], + entity_match: "verified", + score: index === 0 ? 100 : 80, + source_kind: "public", + })); + + const context = buildDossierAgentContext({ + citations, + evidenceAtoms, + evidenceCoverage: { + recent_public_updates: { + status: "supported", + atom_ids: evidenceAtoms.map((atom) => atom.id), + reasons: [], + }, + }, + sourceSelectionPolicy: { + web_search_ids: citations.map((citation) => citation.id), + }, + }); + + assert.deepEqual( + context.evidenceBySection.recent_public_updates.map((atom) => atom.citation_id), + ["recent_regular_event"], + ); + assert.equal(context.metrics.excluded_unsupported_critical_atom_count, 1); +}); + +test("single-source dated penalties are excluded from report and action candidates", () => { + const citations = [{ + id: "single_penalty", + source_kind: "联网搜索", + label: "企业信息聚合页", + summary: "2025-02-17行政处罚所涉工程施工安全管理要求需要核实。", + quality_tier: 3, + entity_match: "verified", + independence_key: "public:single-penalty", + }, { + id: "ordinary_scope", + source_kind: "专业数据集", + label: "企业工商数据库", + summary: "公司名称:测试科技有限公司;经营范围:工程安装和企业软件开发。", + quality_tier: 1, + entity_match: "verified", + independence_key: "professional:scope", + }]; + const evidenceAtoms = citations.map((citation, index) => ({ + id: `E_risk_${String(index + 1).padStart(14, "0")}`, + citation_id: citation.id, + quote: citation.summary, + section_candidates: ["risk_attention", "recommended_actions"], + entity_match: "verified", + score: 100 - index, + source_kind: citation.source_kind === "联网搜索" ? "public" : "professional", + })); + + const context = buildDossierAgentContext({ citations, evidenceAtoms }); + + assert.ok(Object.values(context.evidenceBySection).every((atoms) => ( + atoms.every((atom) => atom.citation_id !== "single_penalty") + ))); + assert.equal(context.metrics.excluded_unsupported_critical_atom_count, 1); +}); + +test("business records for a different legal entity are excluded from every chapter", () => { + const citations = [ + { + id: "target_business", + source_kind: "专业数据集", + label: "企业工商数据库", + summary: "公司名称:测试科技有限公司;经营范围:软件开发。", + quality_tier: 1, + entity_match: "verified", + }, + { + id: "similar_name_business", + source_kind: "专业数据集", + label: "企业工商数据库", + summary: "公司名称:山西测试科技有限公司;经营范围:网络建设。", + quality_tier: 1, + entity_match: "verified", + }, + ]; + const evidenceAtoms = citations.map((citation, index) => ({ + id: `E_entity_${String(index + 1).padStart(12, "0")}`, + citation_id: citation.id, + quote: citation.summary, + section_candidates: ["company_overview", "business_dynamics", "recommended_actions"], + entity_match: "verified", + score: 90 - index, + source_kind: "professional", + })); + + const context = buildDossierAgentContext({ + citations, + evidenceAtoms, + sourceSelectionPolicy: { + business_database_ids: ["target_business"], + excluded_entity_citation_ids: ["similar_name_business"], + }, + }); + + assert.ok(Object.values(context.evidenceBySection).every((atoms) => ( + atoms.every((atom) => atom.citation_id !== "similar_name_business") + ))); + assert.equal(context.metrics.excluded_unrelated_entity_citation_count, 1); +}); + +test("dossier source usage remains diagnostic with no global citation-count floor", () => { + const citations = [ + { id: "professional", source_kind: "专业数据集", independence_key: "datapro:business" }, + { id: "public_1", source_kind: "联网搜索", independence_key: "official.example" }, + { id: "public_2", source_kind: "联网搜索", independence_key: "media.example" }, + ]; + const requirements = buildDossierSourceUsageRequirements(citations); + + assert.equal(requirements.required_distinct_source_count, 0); + assert.deepEqual( + dossierSourceUsageErrors(["professional"], citations, requirements), + [], + ); +}); + +test("deterministic compiler keeps the fixed six-section order and derived citations", () => { + const plan = { + sections: Object.fromEntries(SECTION_KEYS.map((key, index) => [ + key, + { + id: `${key}_1`, + text: `测试科技有限公司为${key}提供可引用的完整业务事实。`, + evidence_ids: [`E_${String(index + 1).padStart(20, "0")}`], + citation_ids: [`citation_${key}`], + evidence_spans: [{ + evidence_id: `E_${String(index + 1).padStart(20, "0")}`, + citation_id: `citation_${key}`, + quote: `测试科技有限公司为${key}提供可引用的完整业务事实。`, + }], + }, + ])), + }; + const compiled = compileDossierFromPlan(plan); + + assert.deepEqual(compiled.errors, []); + assert.equal(compiled.submission.body.length, 6); + assert.deepEqual( + compiled.submission.body.map((section) => section.citation_ids[0]), + SECTION_KEYS.map((key) => `citation_${key}`), + ); +}); + +test("dossier Agent retries one incomplete response and never adds a fallback call", async () => { + let calls = 0; + const input = atomInput(); + const agent = new DossierAgent({ + maxCalls: 2, + callModel: async (request) => { + calls += 1; + if (calls === 1) { + return { + ok: false, + error: { code: "incomplete_response", retryable: true }, + raw_ref: "model:incomplete", + }; + } + return { + ok: true, + parsed: responseFor(request), + raw_ref: "model:complete", + }; + }, + validate: (answer) => ({ body: answer.body, errors: [] }), + }); + + const result = await agent.run(input); + + assert.equal(result.ok, true); + assert.equal(calls, 2); + assert.deepEqual( + result.submission.body.map((section) => section.citation_ids.length), + [1, 1, 1, 1, 1, 1], + ); +}); + +test("dossier Agent fails closed after two rejected complete submissions", async () => { + let calls = 0; + const input = atomInput(); + const agent = new DossierAgent({ + maxCalls: 2, + callModel: async (request) => { + calls += 1; + return { + ok: true, + parsed: responseFor(request), + raw_ref: `model:${calls}`, + }; + }, + validate: (answer) => ({ body: answer.body, errors: ["引用覆盖不足"] }), + }); + + const result = await agent.run(input); + + assert.equal(result.ok, false); + assert.equal(result.stage, "validation"); + assert.equal(calls, 2); + assert.equal(result.submission, undefined); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dossierAgentEvidenceAtoms.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dossierAgentEvidenceAtoms.test.mjs new file mode 100644 index 00000000..506f7eae --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dossierAgentEvidenceAtoms.test.mjs @@ -0,0 +1,419 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildDossierPlanSchema, + DossierAgent, +} from "../src/agents/dossierAgent.js"; + +const SECTION_KEYS = [ + "company_overview", + "business_dynamics", + "recent_public_updates", + "risk_attention", + "sales_opportunity", + "recommended_actions", +]; + +const SECTION_TITLES = [ + "企业与业务概览", + "经营与业务动态", + "近期公开动态", + "风险与关注事项", + "销售机会判断", + "建议行动", +]; + +const SECTION_QUOTES = { + company_overview: "云穹矩阵科技有限公司经营企业软件与知识库产品。", + business_dynamics: "云穹矩阵科技有限公司发布知识库产品升级公告。", + recent_public_updates: "2026年7月30日,云穹矩阵科技有限公司披露产品升级进展。", + risk_attention: "云穹矩阵科技有限公司披露项目交付周期延长,需要核验实施排期。", + sales_opportunity: "知识库产品升级为企业协作检索场景形成销售沟通窗口。", + recommended_actions: "知识库产品升级范围和实施排期仍需由产品负责人核验。", +}; + +const SECTION_TEXT = { + company_overview: "云穹矩阵科技有限公司经营企业软件与知识库产品。", + business_dynamics: "云穹矩阵科技有限公司已发布知识库产品升级公告。", + recent_public_updates: "2026年7月30日,云穹矩阵科技有限公司披露产品升级进展。", + risk_attention: "云穹矩阵科技有限公司披露项目交付周期延长,需要核验实施排期。", + sales_opportunity: "知识库产品升级形成销售沟通窗口,但不代表企业已有采购意向。", + recommended_actions: "销售人员应联系产品负责人核验知识库产品升级范围和实施排期。", +}; + +function evidenceFixture() { + const citations = []; + const evidenceAtoms = []; + const evidenceCoverage = {}; + SECTION_KEYS.forEach((key, index) => { + const citationId = `citation_${key}`; + const atomId = `E_${String(index + 1).padStart(20, "0")}`; + const sourceKind = key === "recent_public_updates" ? "联网搜索" : "专业数据集"; + citations.push({ + id: citationId, + source_kind: sourceKind, + summary: SECTION_QUOTES[key], + quality_tier: 1, + independence_key: `independent:${key}`, + entity_match: "verified", + }); + evidenceAtoms.push({ + id: atomId, + citation_id: citationId, + source_hash: `${index + 1}`.repeat(64).slice(0, 64), + independence_hash: `${index + 7}`.repeat(64).slice(0, 64), + source_kind: sourceKind === "联网搜索" ? "public" : "professional", + source_type: sourceKind === "联网搜索" ? "web" : "datapro", + title: `${SECTION_TITLES[index]}证据`, + url: sourceKind === "联网搜索" ? `https://example.com/${key}` : null, + published_at: key === "recent_public_updates" + ? "2026-07-30T00:00:00.000Z" + : null, + source_updated_at: null, + source_text_field: "summary", + quote: SECTION_QUOTES[key], + quote_start: 0, + quote_end: SECTION_QUOTES[key].length, + normalized_text: SECTION_QUOTES[key], + entity_match: "verified", + entity_anchors: ["云穹矩阵科技有限公司"], + section_candidates: [key], + dates: key === "recent_public_updates" ? ["2026-07-30"] : [], + numbers: [], + organizations: ["云穹矩阵科技有限公司"], + event_families: [], + conflict_fields: [], + reliability: "professional", + score: 80, + }); + evidenceCoverage[key] = { + status: "supported", + atom_ids: [atomId], + reasons: [], + }; + }); + return { citations, evidenceAtoms, evidenceCoverage }; +} + +function parsedPlan(request, overrides = {}) { + return { + sections: Object.fromEntries( + request.parameters.properties.sections.required.map((key) => [ + key, + { + text: overrides[key]?.text || SECTION_TEXT[key], + evidence_ids: overrides[key]?.evidence_ids + || [request.payload.evidence_by_section[key].allowed_evidence[0].id], + }, + ]), + ), + }; +} + +function agentInput(overrides = {}) { + const fixture = evidenceFixture(); + return { + company: { + name: "云穹矩阵科技有限公司", + legal_name: "云穹矩阵科技有限公司", + industry: "企业软件", + location: "北京", + }, + citations: fixture.citations, + evidenceAtoms: fixture.evidenceAtoms, + evidenceCoverage: fixture.evidenceCoverage, + evidencePolicy: { fail_closed: true }, + evidenceConflicts: [], + sourceSelectionPolicy: {}, + instructions: ["只使用输入 Evidence Atom。"], + ...overrides, + }; +} + +function createAgent(callModel, validate = (answer) => ({ + body: answer.body, + errors: [], +})) { + return new DossierAgent({ + callModel, + validate, + maxCalls: 2, + }); +} + +test("dossier schema exposes only text and chapter-scoped evidence_ids", () => { + const allowed = Object.fromEntries(SECTION_KEYS.map((key, index) => [ + key, + [`E_${String(index + 1).padStart(20, "0")}`], + ])); + const schema = buildDossierPlanSchema(allowed); + + assert.deepEqual(schema.properties.sections.required, SECTION_KEYS); + for (const key of SECTION_KEYS) { + const section = schema.properties.sections.properties[key]; + assert.deepEqual(Object.keys(section.properties), ["text", "evidence_ids"]); + assert.deepEqual(section.required, ["text", "evidence_ids"]); + assert.deepEqual(section.properties.evidence_ids.items.enum, allowed[key]); + assert.equal(section.properties.quote, undefined); + assert.equal(section.properties.citation_id, undefined); + assert.equal(section.properties.evidence_spans, undefined); + } +}); + +test("server derives verbatim quotes citations and segments from evidence ids", async () => { + const calls = []; + const input = agentInput(); + const agent = createAgent(async (request) => { + calls.push(request); + return { + ok: true, + parsed: parsedPlan(request), + raw_ref: "model:atom-plan", + }; + }); + + const result = await agent.run(input); + + assert.equal(result.ok, true); + assert.equal(calls.length, 1); + assert.equal(result.submission.body.length, 6); + SECTION_KEYS.forEach((key, index) => { + const atom = input.evidenceAtoms.find((item) => item.section_candidates.includes(key)); + const section = result.approved_plan.sections[key]; + assert.deepEqual(section.evidence_ids, [atom.id]); + assert.deepEqual(section.citation_ids, [atom.citation_id]); + assert.deepEqual(section.evidence_spans, [{ + evidence_id: atom.id, + citation_id: atom.citation_id, + quote: atom.quote, + }]); + assert.deepEqual(result.submission.body[index].citation_ids, [atom.citation_id]); + assert.deepEqual( + result.submission.body[index].segments[0].citation_ids, + [atom.citation_id], + ); + }); + assert.doesNotMatch(JSON.stringify(calls[0].parameters), /quote|citation_id|url/iu); +}); + +test("alias-scoped factual evidence receives a deterministic public-information boundary", async () => { + const input = agentInput(); + const recentAtom = input.evidenceAtoms.find((atom) => ( + atom.section_candidates.includes("recent_public_updates") + )); + const recentCitation = input.citations.find((citation) => citation.id === recentAtom.citation_id); + recentAtom.entity_match = "alias_scoped"; + recentCitation.entity_match = "alias_scoped"; + const agent = createAgent(async (request) => ({ + ok: true, + parsed: parsedPlan(request), + raw_ref: "model:alias-boundary", + })); + + const result = await agent.run(input); + + assert.equal(result.ok, true); + assert.match( + result.approved_plan.sections.recent_public_updates.text, + /^公开信息显示,/u, + ); + assert.match(result.submission.body[2].text, /近期公开动态:公开信息显示,/u); +}); + +test("registered scope wording is deterministically neutralized in the overview", async () => { + const agent = createAgent(async (request) => ({ + ok: true, + parsed: parsedPlan(request, { + company_overview: { + text: "公司经营企业软件,并延伸至知识库产品。", + }, + }), + raw_ref: "model:neutral-scope", + })); + + const result = await agent.run(agentInput()); + + assert.equal(result.ok, true); + assert.equal( + result.approved_plan.sections.company_overview.text, + "公司经营企业软件,并包括知识库产品。", + ); +}); + +test("invalid evidence ids are rejected without deriving citations", async () => { + let calls = 0; + const agent = createAgent(async (request) => { + calls += 1; + const parsed = parsedPlan(request); + parsed.sections.company_overview.evidence_ids = ["E_not_allowed"]; + return { ok: true, parsed, raw_ref: `model:${calls}` }; + }); + + const result = await agent.run(agentInput()); + + assert.equal(result.ok, false); + assert.equal(result.stage, "planning"); + assert.equal(calls, 2); + assert.ok(result.validation_errors.some((error) => ( + error.includes("企业与业务概览") && error.includes("无效 Evidence ID") + ))); +}); + +test("an evidence id allowed for another chapter is rejected", async () => { + let calls = 0; + const input = agentInput(); + const businessAtom = input.evidenceAtoms.find((item) => ( + item.section_candidates.includes("business_dynamics") + )); + const agent = createAgent(async (request) => { + calls += 1; + const parsed = parsedPlan(request); + parsed.sections.risk_attention.evidence_ids = [businessAtom.id]; + return { ok: true, parsed, raw_ref: `model:${calls}` }; + }); + + const result = await agent.run(input); + + assert.equal(result.ok, false); + assert.equal(calls, 2); + assert.ok(result.validation_errors.some((error) => ( + error.includes("风险与关注事项") && error.includes("不属于本章节") + ))); +}); + +test("missing chapter-specific coverage uses grounded cross-section evidence", async () => { + let calls = 0; + const input = agentInput(); + input.evidenceCoverage.risk_attention = { + status: "missing", + atom_ids: [], + reasons: ["no_relevant_atoms"], + }; + input.evidenceAtoms = input.evidenceAtoms.filter((atom) => ( + !atom.section_candidates.includes("risk_attention") + )); + const agent = createAgent(async (request) => { + calls += 1; + const parsed = parsedPlan(request, { + risk_attention: { + text: "云穹矩阵科技有限公司已发布知识库产品升级公告,商务推进应核验实施范围。", + }, + }); + return { ok: true, parsed, raw_ref: "model:cross-section-grounding" }; + }); + + const result = await agent.run(input); + + assert.equal(result.ok, true); + assert.equal(result.stage, "complete"); + assert.equal(calls, 1); + const riskInput = result.approved_plan.sections.risk_attention; + assert.equal(riskInput.evidence_ids.length, 1); + assert.equal( + riskInput.evidence_ids[0], + input.evidenceAtoms.find((atom) => ( + atom.section_candidates.includes("business_dynamics") + )).id, + ); +}); + +test("the bounded repair call returns only the failed chapter", async () => { + const calls = []; + const agent = createAgent(async (request) => { + calls.push(request); + if (calls.length === 1) { + return { + ok: true, + parsed: parsedPlan(request, { + recent_public_updates: { + text: "2026年7月31日,云穹矩阵科技有限公司披露产品升级进展。", + }, + }), + raw_ref: "model:first", + }; + } + return { + ok: true, + parsed: parsedPlan(request), + raw_ref: "model:repair", + }; + }); + + const result = await agent.run(agentInput()); + + assert.equal(result.ok, true); + assert.equal(calls.length, 2); + assert.equal(calls[1].operation, "sales_dossier_agent_replan"); + assert.deepEqual( + calls[1].parameters.properties.sections.required, + ["recent_public_updates"], + ); + assert.deepEqual( + Object.keys(calls[1].parameters.properties.sections.properties), + ["recent_public_updates"], + ); + assert.equal( + result.approved_plan.sections.company_overview.text, + SECTION_TEXT.company_overview, + ); + assert.equal( + result.approved_plan.sections.recent_public_updates.text, + SECTION_TEXT.recent_public_updates, + ); +}); + +test("indexed final-validation errors are mapped back to the exact failed chapter", async () => { + const calls = []; + let validations = 0; + const agent = createAgent(async (request) => { + calls.push(request); + return { + ok: true, + parsed: parsedPlan(request), + raw_ref: `model:indexed-validation-${calls.length}`, + }; + }, (answer) => ({ + body: answer.body, + errors: validations++ === 0 + ? ["body[2].segments[0] 的净利润“680亿元”未获得双来源一致支持"] + : [], + })); + + const result = await agent.run(agentInput()); + + assert.equal(result.ok, true); + assert.equal(calls.length, 2); + assert.deepEqual( + calls[1].parameters.properties.sections.required, + ["recent_public_updates"], + ); + assert.match( + calls[1].payload.planning_errors[0], + /^近期公开动态第 1 条/u, + ); +}); + +test("two rejected semantic plans fail closed with six chapters and no fallback", async () => { + let calls = 0; + const agent = createAgent(async (request) => { + calls += 1; + return { + ok: true, + parsed: parsedPlan(request, { + recommended_actions: { + text: "销售人员应按5000万元预算准备交付方案。", + }, + }), + raw_ref: `model:${calls}`, + }; + }); + + const result = await agent.run(agentInput()); + + assert.equal(result.ok, false); + assert.equal(result.stage, "planning"); + assert.equal(calls, 2); + assert.equal(result.submission, undefined); + assert.ok(result.validation_errors.some((error) => error.includes("5000"))); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dossierEvidenceCompiler.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dossierEvidenceCompiler.test.mjs new file mode 100644 index 00000000..1eb90ad6 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/dossierEvidenceCompiler.test.mjs @@ -0,0 +1,623 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + compileDossierEvidenceAtoms, +} from "../src/evidence/dossierEvidenceCompiler.js"; + +const ENTITY = Object.freeze({ + id: "company_fictional_matrix", + canonical_name: "云岚矩阵科技有限公司", + normalized_name: "云岚矩阵科技有限公司", + aliases: ["云岚矩阵科技有限公司", "云岚矩阵", "云岚"], + strict_aliases: ["云岚矩阵科技有限公司", "云岚矩阵"], + contextual_aliases: ["云岚"], + identifiers: { + unified_social_credit_code: "91110000MA0FAKE001", + }, +}); + +const unixPath = (...parts) => ["", ...parts].join("/"); +const macosPrivatePath = unixPath("Users", "example", "private.env"); +const macosProviderPath = unixPath("Users", "example", "private", "provider.json"); +const macosIndependencePath = unixPath("Users", "fictional", "private-record.json"); +const linuxPrivatePath = unixPath("home", "example", "private.json"); +const windowsPrivatePath = ["C:", "Users", "example", "secret.env"].join("\\"); + +function source(overrides = {}) { + const id = overrides.id || "evidence_professional_business"; + return { + id, + source_key: overrides.source_key || `source:${id}`, + source_kind: "professional", + source_kind_label: "专业数据集", + label: "虚构企业工商记录", + summary: [ + "公司名称:云岚矩阵科技有限公司;", + "统一社会信用代码:91110000MA0FAKE001;", + "经营范围:企业软件技术服务。", + ].join(""), + excerpt: "", + url: "", + published_at: null, + source_updated_at: "2026-07-30T08:00:00.000Z", + entity_match: "verified", + source_quality: "professional", + quality_tier: 1, + official: true, + freshness: "current", + independence_key: `independence:${id}`, + conflict_fields: [], + provider: "datapro", + raw_ref: "", + ...overrides, + }; +} + +function evidencePack(items, overrides = {}) { + return { + entity: structuredClone(ENTITY), + items, + rejected: [], + conflicts: [], + policy: {}, + ...overrides, + }; +} + +function compile(items, overrides = {}) { + return compileDossierEvidenceAtoms({ + evidencePack: evidencePack(items, overrides), + }); +} + +function atomSourceText(atom, pack) { + const item = pack.items.find((candidate) => String(candidate.id) === atom.citation_id); + return String(item?.[atom.source_text_field] || ""); +} + +test("compiler is deterministic for repeated identical input", () => { + const pack = evidencePack([ + source(), + source({ + id: "evidence_public_update", + source_kind: "public", + source_kind_label: "联网搜索", + label: "云岚产品更新公告", + summary: "2026年7月28日,云岚矩阵科技有限公司发布企业软件产品更新。", + url: "https://news.example.com/matrix-update", + published_at: "2026-07-28T08:00:00.000Z", + source_quality: "traceable", + quality_tier: 2, + official: false, + }), + ]); + + assert.deepEqual( + compileDossierEvidenceAtoms({ evidencePack: pack }), + compileDossierEvidenceAtoms({ evidencePack: structuredClone(pack) }), + ); +}); + +test("source order does not change atom ids or stable output ordering", () => { + const firstSource = source(); + const secondSource = source({ + id: "evidence_public_procurement", + source_kind: "public", + source_kind_label: "联网搜索", + label: "采购结果公告", + summary: "2026年7月29日,云岚矩阵科技有限公司入选虚构软件采购项目。", + url: "https://notice.example.com/procurement", + published_at: "2026-07-29T08:00:00.000Z", + source_quality: "official", + quality_tier: 1, + official: true, + }); + + const forward = compile([firstSource, secondSource]); + const reversed = compile([secondSource, firstSource]); + + assert.deepEqual(forward, reversed); + assert.deepEqual( + forward.atoms.map((atom) => atom.id), + reversed.atoms.map((atom) => atom.id), + ); +}); + +test("every quote can be sliced verbatim from its recorded source field", () => { + const pack = evidencePack([source()]); + const result = compileDossierEvidenceAtoms({ evidencePack: pack }); + + assert.ok(result.atoms.length >= 3); + for (const atom of result.atoms) { + const sourceText = atomSourceText(atom, pack); + assert.equal( + sourceText.slice(atom.quote_start, atom.quote_end), + atom.quote, + atom.id, + ); + } +}); + +test("Chinese and English sentence punctuation creates natural atom boundaries", () => { + const item = source({ + summary: [ + "云岚矩阵科技有限公司完成软件版本更新。", + "客户是否进入下一轮验证?", + "项目团队确认测试通过!", + "后续将核验采购范围;", + "The fictional release remains traceable;", + ].join(""), + }); + const result = compile([item]); + const quotes = result.atoms.map((atom) => atom.quote); + + assert.ok(quotes.includes("云岚矩阵科技有限公司完成软件版本更新。")); + assert.ok(quotes.includes("客户是否进入下一轮验证?")); + assert.ok(quotes.includes("项目团队确认测试通过!")); + assert.ok(quotes.includes("后续将核验采购范围;")); + assert.ok(quotes.includes("The fictional release remains traceable;")); +}); + +test("DataPro structured fields remain separate verbatim records", () => { + const item = source(); + const result = compile([item]); + const quotes = result.atoms.map((atom) => atom.quote); + + assert.ok(quotes.includes("公司名称:云岚矩阵科技有限公司;")); + assert.ok(quotes.includes("统一社会信用代码:91110000MA0FAKE001;")); + assert.ok(quotes.includes("经营范围:企业软件技术服务。")); + assert.ok(result.atoms.every((atom) => ( + !atom.quote.includes(";统一社会信用代码") + ))); +}); + +test("numbered and bullet list items compile into complete atoms", () => { + const item = source({ + summary: [ + "1. 云岚矩阵科技有限公司负责虚构平台研发", + "2、项目团队计划验证数据权限", + "- 采购团队将核验交付边界", + ].join("\n"), + }); + const result = compile([item]); + const quotes = result.atoms.map((atom) => atom.quote); + + assert.ok(quotes.some((quote) => quote.startsWith("1. "))); + assert.ok(quotes.some((quote) => quote.startsWith("2、"))); + assert.ok(quotes.some((quote) => quote.startsWith("- "))); + assert.ok(quotes.every((quote) => !quote.includes("\n"))); +}); + +test("bounded long-sentence splitting preserves dates amounts and legal names", () => { + const legalName = "云岚矩阵科技有限公司"; + const date = "2026年7月30日"; + const amount = "人民币320万元"; + const item = source({ + summary: `${"虚构技术背景说明,".repeat(30)}${date}${legalName}记录项目金额${amount}` + + `${"并继续描述测试范围,".repeat(30)}本段结束`, + }); + const result = compile([item]); + + assert.ok(result.atoms.length > 1); + assert.ok(result.atoms.some((atom) => atom.quote.includes(legalName))); + assert.ok(result.atoms.some((atom) => atom.quote.includes(date))); + assert.ok(result.atoms.some((atom) => atom.quote.includes(amount))); + assert.ok(result.atoms.every((atom) => atom.quote.length <= 360)); +}); + +test("date amount ratio and quantity metadata are retained", () => { + const item = source({ + summary: "2026年7月30日,云岚矩阵科技有限公司记录金额320万元、比例18.5%和设备12台。", + }); + const result = compile([item]); + const atom = result.atoms[0]; + + assert.deepEqual(atom.dates, ["2026-07-30"]); + assert.ok(atom.numbers.includes("320")); + assert.ok(atom.numbers.includes("18.5")); + assert.ok(atom.numbers.includes("12")); +}); + +test("legal name and unified social credit code remain strong entity anchors", () => { + const result = compile([source()]); + const legalNameAtom = result.atoms.find((atom) => atom.quote.includes(ENTITY.canonical_name)); + const creditCodeAtom = result.atoms.find((atom) => ( + atom.quote.includes(ENTITY.identifiers.unified_social_credit_code) + )); + + assert.equal(legalNameAtom.entity_match, "verified"); + assert.ok(legalNameAtom.entity_anchors.includes(ENTITY.canonical_name)); + assert.equal(creditCodeAtom.entity_match, "verified"); + assert.ok(creditCodeAtom.entity_anchors.includes( + ENTITY.identifiers.unified_social_credit_code, + )); +}); + +test("brand aliases remain alias_scoped and are not upgraded to legal-entity anchors", () => { + const item = source({ + id: "evidence_alias_news", + source_kind: "public", + source_kind_label: "联网搜索", + label: "云岚发布虚构产品动态", + summary: "云岚发布虚构产品动态并介绍测试计划。", + entity_match: "alias_scoped", + source_quality: "traceable", + quality_tier: 2, + official: false, + url: "https://news.example.com/alias-update", + }); + const result = compile([item]); + + assert.equal(result.atoms.length, 1); + assert.equal(result.atoms[0].entity_match, "alias_scoped"); + assert.deepEqual(result.atoms[0].entity_anchors, ["云岚"]); +}); + +test("another company's risk fact is never marked as a strong target-company match", () => { + const item = source({ + id: "evidence_mixed_risk", + summary: "云岚矩阵科技有限公司关注远川样例科技有限公司受到行政处罚的公开信息。", + entity_match: "verified", + }); + const result = compile([item]); + const riskAtom = result.atoms.find((atom) => atom.quote.includes("行政处罚")); + + assert.ok(riskAtom); + assert.equal(riskAtom.entity_match, "unverified"); + assert.ok(riskAtom.event_families.includes("risk")); + assert.ok(result.diagnostics.some((item) => ( + item.code === "risk_subject_not_strongly_anchored" + && item.atom_id === riskAtom.id + ))); +}); + +test("parent and subsidiary risk facts do not inherit the target company's identity", () => { + const result = compile([ + source({ + id: "parent_company_risk", + summary: "云岚矩阵科技有限公司关注远川控股有限公司受到监管处罚的公开信息。", + entity_match: "verified", + }), + source({ + id: "subsidiary_company_risk", + summary: "云岚矩阵科技有限公司关注云岚样例子公司有限公司涉及诉讼的公开信息。", + entity_match: "verified", + }), + ]); + const riskAtoms = result.atoms.filter((atom) => atom.event_families.includes("risk")); + + assert.equal(riskAtoms.length, 2); + assert.ok(riskAtoms.every((atom) => atom.entity_match === "unverified")); + assert.ok(result.diagnostics.filter((item) => ( + item.code === "risk_subject_not_strongly_anchored" + )).length >= 2); +}); + +test("identical and republished content is deduplicated deterministically", () => { + const shared = "2026年7月29日,云岚矩阵科技有限公司发布虚构软件更新。"; + const sharedIndependenceKey = "official.example.com/update"; + const official = source({ + id: "evidence_official_reprint", + source_kind: "public", + source_kind_label: "联网搜索", + label: "官方更新", + summary: shared, + url: "https://official.example.com/update", + source_quality: "official", + quality_tier: 1, + official: true, + independence_key: sharedIndependenceKey, + }); + const reprint = source({ + id: "evidence_media_reprint", + source_kind: "public", + source_kind_label: "联网搜索", + label: "转载更新", + summary: ` ${shared} `, + url: "https://media.example.com/reprint", + source_quality: "traceable", + quality_tier: 2, + official: false, + independence_key: sharedIndependenceKey, + }); + const forward = compile([reprint, official]); + const reversed = compile([official, reprint]); + + assert.deepEqual(forward, reversed); + assert.equal(forward.atoms.filter((atom) => atom.normalized_text.includes("虚构软件更新")).length, 1); + assert.ok(forward.rejected.some((item) => item.reason === "duplicate_content")); + assert.equal( + forward.atoms.find((atom) => atom.normalized_text.includes("虚构软件更新")).citation_id, + official.id, + ); +}); + +test("identical content from independent professional and official sources is retained", () => { + const shared = "云岚矩阵科技有限公司完成虚构软件项目验收。"; + const professional = source({ + id: "independent_professional", + summary: shared, + independence_key: "datapro:fictional-business-record", + }); + const official = source({ + id: "independent_official", + source_kind: "public", + source_kind_label: "联网搜索", + summary: shared, + url: "https://official.example.com/independent-verification", + source_quality: "official", + quality_tier: 1, + official: true, + independence_key: "official.example.com:independent-verification", + }); + + const forward = compile([professional, official]); + const reversed = compile([official, professional]); + const matching = forward.atoms.filter((atom) => atom.normalized_text === shared); + + assert.deepEqual(forward, reversed); + assert.equal(matching.length, 2); + assert.deepEqual( + new Set(matching.map((atom) => atom.citation_id)), + new Set([professional.id, official.id]), + ); + assert.equal(new Set(matching.map((atom) => atom.independence_hash)).size, 2); +}); + +test("missing independence keys fall back to distinct stable source identities", () => { + const shared = "云岚矩阵科技有限公司记录虚构产品交付进展。"; + const first = source({ + id: "fallback_domain_one", + source_kind: "public", + source_kind_label: "联网搜索", + summary: shared, + url: "https://one.example.com/update", + independence_key: "", + }); + const second = source({ + id: "fallback_domain_two", + source_kind: "public", + source_kind_label: "联网搜索", + summary: shared, + url: "https://two.example.com/update", + independence_key: "", + }); + const result = compile([first, second]); + const matching = result.atoms.filter((atom) => atom.normalized_text === shared); + + assert.equal(matching.length, 2); + assert.equal(new Set(matching.map((atom) => atom.independence_hash)).size, 2); +}); + +test("raw independence keys never enter atom rejected or diagnostic output", () => { + const privateIndependenceKey = `local-source:${macosIndependencePath}`; + const result = compile([source({ + id: "private_source_identity", + independence_key: privateIndependenceKey, + })]); + const serialized = JSON.stringify(result); + + assert.ok(result.atoms.every((atom) => /^[a-f0-9]{64}$/u.test(atom.independence_hash))); + assert.doesNotMatch(serialized, /local-source|\/Users\/fictional|independence_key/); +}); + +test("empty navigation search-status and title-fragment content is rejected with reasons", () => { + const result = compile([ + source({ id: "empty", summary: "", excerpt: "" }), + source({ id: "navigation", summary: "首页 > 产品中心 > 点击查看详情" }), + source({ id: "search_status", summary: "正在搜索相关结果,请稍候加载更多" }), + source({ id: "title_fragment", summary: "云岚矩阵公司最新消息" }), + ]); + const reasons = new Set(result.rejected.map((item) => item.reason)); + + assert.equal(result.atoms.length, 0); + assert.ok(reasons.has("missing_source_text")); + assert.ok(reasons.has("navigation_or_search_status")); + assert.ok(reasons.has("non_substantive_fragment")); +}); + +test("summary and excerpt origins keep their own exact offsets", () => { + const summaryItem = source({ + id: "from_summary", + summary: "云岚矩阵科技有限公司完成虚构产品测试。", + excerpt: "不应优先使用的摘录。", + }); + const excerptItem = source({ + id: "from_excerpt", + summary: "", + excerpt: "云岚矩阵科技有限公司记录虚构项目进度。", + }); + const pack = evidencePack([summaryItem, excerptItem]); + const result = compileDossierEvidenceAtoms({ evidencePack: pack }); + const summaryAtom = result.atoms.find((atom) => atom.citation_id === summaryItem.id); + const excerptAtom = result.atoms.find((atom) => atom.citation_id === excerptItem.id); + + assert.equal(summaryAtom.source_text_field, "summary"); + assert.equal(excerptAtom.source_text_field, "excerpt"); + assert.equal( + summaryItem.summary.slice(summaryAtom.quote_start, summaryAtom.quote_end), + summaryAtom.quote, + ); + assert.equal( + excerptItem.excerpt.slice(excerptAtom.quote_start, excerptAtom.quote_end), + excerptAtom.quote, + ); +}); + +test("sparse evidence returns partial and missing coverage without global failure", () => { + const result = compile([source({ + summary: "公司名称:云岚矩阵科技有限公司;经营范围:企业软件技术服务。", + })]); + + assert.ok(result.atoms.length > 0); + assert.equal(result.coverage.company_overview.status, "supported"); + assert.ok(["partial", "missing"].includes(result.coverage.recent_public_updates.status)); + assert.ok(["partial", "missing"].includes(result.coverage.risk_attention.status)); + assert.deepEqual(Object.keys(result.coverage), [ + "company_overview", + "business_dynamics", + "recent_public_updates", + "risk_attention", + "sales_opportunity", + "recommended_actions", + ]); + assert.doesNotMatch(JSON.stringify(result), /risks_and_attention/); +}); + +test("missing URLs remain null and are never fabricated", () => { + const result = compile([source({ url: "" })]); + + assert.ok(result.atoms.length > 0); + assert.ok(result.atoms.every((atom) => atom.url === null)); +}); + +test("source hashes and atoms exclude credentials paths raw refs and runtime randomness", () => { + const item = source({ + url: "https://evidence.example.com/item?api_key=fake-url-secret&utm_source=test&view=1#token", + raw_ref: `Bearer fake-secret-value ${macosProviderPath}`, + provider_response: { token: "fake-provider-token" }, + pid: 12345, + runtime_log: "private runtime output", + }); + const first = compile([item]); + const second = compile([structuredClone(item)]); + const serialized = JSON.stringify(first); + + assert.deepEqual(first, second); + assert.doesNotMatch(serialized, /fake-secret|fake-provider|\/Users\/|12345|runtime output/); + assert.ok(first.atoms.every((atom) => ( + atom.url === "https://evidence.example.com/item?view=1" + ))); + assert.ok(first.atoms.every((atom) => /^[a-f0-9]{64}$/u.test(atom.source_hash))); + assert.ok(first.atoms.every((atom) => /^E_[a-f0-9]{20}$/u.test(atom.id))); +}); + +test("sensitive source text is rejected without echoing the secret or machine path", () => { + const item = source({ + id: "sensitive_summary", + summary: `API_KEY=fake-secret-value-1234567890,配置位于${macosPrivatePath}。`, + }); + const result = compile([item]); + const serialized = JSON.stringify(result); + + assert.equal(result.atoms.length, 0); + assert.ok(result.rejected.some((entry) => entry.reason === "sensitive_content")); + assert.doesNotMatch(serialized, /fake-secret-value|\/Users\/example/); +}); + +test("local absolute paths adjacent to Chinese text are rejected without being echoed", () => { + const result = compile([ + source({ + id: "local_users_path", + summary: `配置位于${macosPrivatePath}。`, + }), + source({ + id: "local_home_path", + summary: `文件保存在${linuxPrivatePath}。`, + }), + source({ + id: "local_windows_path", + summary: `路径为${windowsPrivatePath}。`, + }), + source({ + id: "public_url", + source_kind: "public", + source_kind_label: "联网搜索", + summary: "云岚矩阵科技有限公司发布公开资料,访问https://docs.example.com/home/public/info。", + url: "https://docs.example.com/home/public/info", + }), + ]); + const serialized = JSON.stringify(result); + const sensitiveRejections = result.rejected.filter((entry) => ( + entry.reason === "sensitive_content" + )); + + assert.equal(sensitiveRejections.length, 3); + assert.doesNotMatch(serialized, /\/Users\/example|\/home\/example|C:\\\\Users\\\\example/); + assert.ok(sensitiveRejections.every((entry) => ( + entry.reason === "sensitive_content" + && !Object.hasOwn(entry, "quote") + ))); + assert.ok(result.atoms.some((atom) => ( + atom.citation_id === "public_url" + && atom.url === "https://docs.example.com/home/public/info" + ))); +}); + +test("compiler never mutates the input evidence pack", () => { + const pack = evidencePack([source()]); + const before = structuredClone(pack); + + compileDossierEvidenceAtoms({ evidencePack: pack }); + + assert.deepEqual(pack, before); +}); + +test("organization and event-family metadata reuse grounding semantics", () => { + const item = source({ + summary: "2026年7月30日,云海样例银行公示云岚矩阵科技有限公司入选软件采购项目。", + }); + const result = compile([item]); + const atom = result.atoms[0]; + + assert.ok(atom.organizations.includes("云海样例银行")); + assert.ok(atom.organizations.includes("云岚矩阵科技有限公司")); + assert.ok(atom.event_families.includes("procurement")); +}); + +test("conflict fields are retained as diagnostics without deleting original evidence", () => { + const item = source({ + summary: "云岚矩阵科技有限公司注册资本为1000万元。", + conflict_fields: ["registered_capital"], + }); + const result = compile([item], { + conflicts: [{ + field: "registered_capital", + field_label: "注册资本", + values: [], + }], + }); + + assert.equal(result.atoms.length, 1); + assert.deepEqual(result.atoms[0].conflict_fields, ["registered_capital"]); + assert.ok(result.diagnostics.some((entry) => entry.code === "source_conflict")); +}); + +test("section candidates are deterministic suggestions and may include multiple chapters", () => { + const item = source({ + id: "multi_section", + source_kind: "public", + source_kind_label: "联网搜索", + summary: "2026年7月30日,云岚矩阵科技有限公司发布软件产品并启动采购项目。", + source_quality: "official", + quality_tier: 1, + official: true, + url: "https://official.example.com/multi-section", + }); + const result = compile([item]); + const atom = result.atoms[0]; + + assert.ok(atom.section_candidates.includes("business_dynamics")); + assert.ok(atom.section_candidates.includes("recent_public_updates")); + assert.ok(atom.section_candidates.includes("sales_opportunity")); + assert.ok(atom.section_candidates.includes("recommended_actions")); + assert.ok(atom.section_candidates.length > 1); +}); + +test("coverage does not impose source-count or distinct-source hard floors", () => { + const result = compile([source({ + summary: [ + "公司名称:云岚矩阵科技有限公司;", + "经营范围:企业软件技术服务;", + "2026年7月30日发布虚构产品更新;", + "项目团队将核验采购范围。", + ].join(""), + })]); + + assert.ok(result.atoms.length >= 3); + assert.ok(Object.values(result.coverage).every((entry) => ( + ["supported", "partial", "missing"].includes(entry.status) + ))); + assert.ok(result.diagnostics.every((entry) => entry.code !== "insufficient_source_count")); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/feishuImportScript.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/feishuImportScript.test.mjs new file mode 100644 index 00000000..bf68e82c --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/feishuImportScript.test.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + backendFetch, + extractDocUrl, + messageMaterial, + parseArgs, + retryable, + withRetry, +} from "../scripts/import-feishu-cli.mjs"; + +test("CLI import accepts a private auth-session path without putting tokens in arguments", () => { + const parsed = parseArgs([ + "--company-id", "company_1", + "--doc", "doxcnExampleToken", + "--auth-session", "/private/state/cli-session.json", + ]); + assert.equal(parsed.authSession, "/private/state/cli-session.json"); +}); + +test("backend requests refresh an expired bearer session and rotate the private file", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "siw-auth-session-")); + const sessionFile = path.join(directory, "cli-session.json"); + fs.writeFileSync(sessionFile, JSON.stringify({ + access_token: "expired-access", + refresh_token: "refresh-token", + }), { mode: 0o600 }); + const originalFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (url, options = {}) => { + calls.push({ url: String(url), authorization: new Headers(options.headers).get("authorization") }); + if (String(url).endsWith("/api/auth/cli-refresh")) { + return new Response(JSON.stringify({ + data: { + access_token: "fresh-access", + refresh_token: "rotated-refresh", + expires_in: 3600, + user: { id: "user_1", role: "member" }, + }, + }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (calls.filter((call) => call.url.endsWith("/api/resource")).length === 1) { + return new Response(JSON.stringify({ error: { code: "authentication_required" } }), { status: 401 }); + } + return new Response(JSON.stringify({ data: { ok: true } }), { status: 200 }); + }; + + try { + const response = await backendFetch("http://127.0.0.1:8787/api/resource", {}, { + apiUrl: "http://127.0.0.1:8787", + authSession: sessionFile, + }); + assert.equal(response.status, 200); + assert.equal(calls[0].authorization, "Bearer expired-access"); + assert.equal(calls[2].authorization, "Bearer fresh-access"); + const rotated = JSON.parse(fs.readFileSync(sessionFile, "utf8")); + assert.equal(rotated.access_token, "fresh-access"); + assert.equal(rotated.refresh_token, "rotated-refresh"); + assert.equal(fs.statSync(sessionFile).mode & 0o077, 0); + } finally { + globalThis.fetch = originalFetch; + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test("a bare Feishu document token does not invent a tenant URL", () => { + assert.equal(extractDocUrl("doxcnExampleToken"), ""); + assert.equal( + extractDocUrl("https://example.feishu.cn/docx/doxcnExampleToken"), + "https://example.feishu.cn/docx/doxcnExampleToken", + ); +}); + +test("message material sorts unordered results before advancing its checkpoint", () => { + const source = { + type: "feishu_search", + external_id: "新能源汽车", + }; + const material = messageMaterial({ + title: "飞书消息搜索:新能源汽车", + source, + messages: [ + { message_id: "m3", create_time: "2026-07-21T03:00:00.000Z", content: "third" }, + { message_id: "m1", create_time: "2026-07-21T01:00:00.000Z", content: "first" }, + { message_id: "m2", create_time: "2026-07-21T02:00:00.000Z", content: "second" }, + ], + targetUser: null, + options: { titlePrefix: "", resumeSource: false }, + }); + + assert.equal(material.occurred_at, "2026-07-21T01:00:00.000Z"); + assert.equal(material.source.checkpoint_value, "2026-07-21T03:00:00.000Z"); + assert.equal(material.source.version, "m3"); + assert.deepEqual(material.source_items.map((item) => item.id), ["m1", "m2", "m3"]); +}); + +test("a non-retryable Feishu error reports the one attempt actually made", async () => { + await assert.rejects( + withRetry( + async () => { + throw new Error("permission denied"); + }, + { maxAttempts: 3, retryDelayMs: 0 }, + ), + (error) => error.message === "permission denied" && error.attempts === 1, + ); +}); + +test("a Feishu user id does not masquerade as a 5xx response", () => { + assert.equal( + retryable("need_user_authorization (user: ou_fixture_user_001)"), + false, + ); + assert.equal(retryable("Backend sync-state failed (503)"), true); + assert.equal(retryable("HTTP 429 too many requests"), true); +}); + +test("a transient Feishu error retries and reports the successful attempt", async () => { + let calls = 0; + const result = await withRetry( + async () => { + calls += 1; + if (calls < 3) throw new Error("temporary network timeout"); + return "ok"; + }, + { maxAttempts: 3, retryDelayMs: 0 }, + ); + + assert.equal(result.value, "ok"); + assert.equal(result.attempts, 3); + assert.equal(calls, 3); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/feishuImportTaskService.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/feishuImportTaskService.test.mjs new file mode 100644 index 00000000..0541290b --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/feishuImportTaskService.test.mjs @@ -0,0 +1,198 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { FeishuImportTaskService } from "../src/services/feishuImportTaskService.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +function salesServiceFake() { + return { + imports: [], + requireCompany(companyId) { + if (companyId !== "company_1") throw new Error("unknown company"); + return { id: companyId }; + }, + getMaterialSyncState(companyId, input) { + return { + company_id: companyId, + source_id: input.source.external_id, + source: { status: "active" }, + checkpoint: null, + }; + }, + async importMaterial(companyId, material) { + this.imports.push({ companyId, material }); + return { + action: "created", + material: { id: "material_1", openviking_status: "ready" }, + source: { id: "source_1" }, + openviking_record: { + status: "ready", + raw_ref: "viking://private/resource", + }, + }; + }, + }; +} + +async function waitForTask(service, companyId, taskId) { + for (let index = 0; index < 20; index += 1) { + const task = service.get(companyId, taskId); + if (!["queued", "running"].includes(task.status)) return task; + await new Promise((resolve) => setTimeout(resolve, 1)); + } + throw new Error("Task did not complete."); +} + +test("controlled Feishu import runs through local adapters and exposes only public task fields", async () => { + const salesService = salesServiceFake(); + let receivedOptions = null; + const service = new FeishuImportTaskService({ + env: envReader({ FEISHU_CLI_IMPORT_ENABLED: "true" }), + salesService, + async runner(options) { + receivedOptions = options; + const state = await options.syncStateLoader({ + type: "feishu_doc", + external_id: "doc-token-123", + display_name: "客户方案", + }); + assert.equal(state.company_id, "company_1"); + const imported = await options.materialImporter({ + title: "飞书云文档:客户方案", + source: { type: "feishu_doc", external_id: "doc-token-123" }, + raw_text: "客户希望先完成小范围验证。", + }); + return { + ok: true, + summary: { created: 1, updated: 0, unchanged: 0, failed: 0 }, + imports: [{ + source_type: "feishu_doc", + title: "飞书云文档:客户方案", + action: imported.action, + status: imported.openviking_record.status, + imported_material_id: imported.material.id, + openviking_ref: imported.openviking_record.raw_ref, + provider_run_id: "provider-run-private", + duration_ms: 12, + }], + }; + }, + }); + + const started = await service.start("company_1", { + source_kind: "document", + target: "https://example.feishu.cn/wiki/doc-token-123", + }); + const completed = await waitForTask(service, "company_1", started.id); + + assert.equal(completed.status, "succeeded"); + assert.deepEqual(receivedOptions.docs, ["https://example.feishu.cn/wiki/doc-token-123"]); + assert.equal(receivedOptions.materialImporter instanceof Function, true); + assert.equal(salesService.imports.length, 1); + assert.equal(completed.result.imports[0].material_id, "material_1"); + assert.doesNotMatch(JSON.stringify(completed), /viking:\/\//i); + assert.doesNotMatch(JSON.stringify(completed), /provider-run-private/i); +}); + +test("conversation targets are bounded and only one import can run per company", async () => { + let release; + const runnerWait = new Promise((resolve) => { + release = resolve; + }); + const service = new FeishuImportTaskService({ + env: envReader({ FEISHU_CLI_IMPORT_ENABLED: "true" }), + salesService: salesServiceFake(), + async runner(options) { + await runnerWait; + return { + ok: true, + summary: { created: 0, updated: 0, unchanged: 1, failed: 0 }, + imports: [{ + source_type: options.chatId ? "feishu_chat" : "feishu_p2p", + action: "unchanged", + status: "skipped", + }], + }; + }, + }); + + const first = await service.start("company_1", { + source_kind: "conversation", + target: "oc_91c21c3c611da52e7555c92866e63a04", + }); + await assert.rejects( + () => service.start("company_1", { + source_kind: "conversation", + target: "联系人姓名", + }), + (error) => error.status === 409 && error.code === "feishu_import_in_progress", + ); + release(); + const completed = await waitForTask(service, "company_1", first.id); + assert.equal(completed.status, "succeeded"); +}); + +test("product import accepts names and chat IDs but rejects Open ID and bare document tokens", () => { + const service = new FeishuImportTaskService({ + env: envReader({ FEISHU_CLI_IMPORT_ENABLED: "true" }), + salesService: salesServiceFake(), + }); + + assert.equal( + service.normalizeRequest("company_1", { + source_kind: "conversation", + target: "客户联系人姓名", + }).target, + "客户联系人姓名", + ); + assert.equal( + service.normalizeRequest("company_1", { + source_kind: "conversation", + target: "oc_91c21c3c611da52e7555c92866e63a04", + }).target, + "oc_91c21c3c611da52e7555c92866e63a04", + ); + assert.throws( + () => service.normalizeRequest("company_1", { + source_kind: "conversation", + target: "ou_91c21c3c611da52e7555c92866e63a04", + }), + /不支持 Open ID/, + ); + assert.throws( + () => service.normalizeRequest("company_1", { + source_kind: "document", + target: "CmTHwndaGi6Uask3bvRcyDYInhf", + }), + /完整的 https:\/\//, + ); + assert.equal( + service.normalizeRequest("company_1", { + source_kind: "document", + target: "https://example.feishu.cn/wiki/CmTHwndaGi6Uask3bvRcyDYInhf", + }).target, + "https://example.feishu.cn/wiki/CmTHwndaGi6Uask3bvRcyDYInhf", + ); +}); + +test("legacy Feishu sync configuration keeps the controlled import available after upgrade", () => { + const service = new FeishuImportTaskService({ + env: envReader({ FEISHU_SYNC_ENABLED: "true" }), + salesService: { + requireCompany() { + return { id: "company-1" }; + }, + }, + }); + + assert.deepEqual(service.status(), { + available: true, + supported_sources: ["conversation", "document"], + }); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/frontendRuntimeContract.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/frontendRuntimeContract.test.mjs new file mode 100644 index 00000000..b62d0fe4 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/frontendRuntimeContract.test.mjs @@ -0,0 +1,212 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const backendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const rootDir = path.resolve(backendDir, ".."); +const appSource = await fs.readFile(path.join(rootDir, "frontend", "app.js"), "utf8"); +const textFormatSource = await fs.readFile(path.join(rootDir, "frontend", "text-format.js"), "utf8"); +const htmlSource = await fs.readFile(path.join(rootDir, "frontend", "index.html"), "utf8"); +const styleSource = await fs.readFile(path.join(rootDir, "frontend", "styles.css"), "utf8"); + +test("formal frontend starts empty and has no user-selectable fixture mode", () => { + assert.match(appSource, /let goals = \[\];\s+let companies = \{\};/); + assert.match(appSource, /function resetConnectedState\(\) \{[\s\S]*?goals = \[\];[\s\S]*?companies = \{\};/); + assert.doesNotMatch(appSource, /DEMO_MODE|SALES_WORKBENCH_MODE|safe-demo|applySafeRecordingData/); + assert.doesNotMatch(appSource, /yutong04|南区销售工作台|星澜新能源|曜驰智能/); +}); + +test("formal frontend keeps the sales workspace free of backend operations content", () => { + assert.match(appSource, /销售智能工作台<\/strong>/); + assert.doesNotMatch(appSource, /api\("\/providers\/status"\)/); + assert.doesNotMatch(appSource, /api\("\/admin\/status"\)/); + assert.match(appSource, /\/jobs\?job_type=sales_dossier_generation&entity_id=/); + assert.match(appSource, /api\(`\/jobs\/\$\{encodeURIComponent\(job\.id\)\}`\)/); + assert.doesNotMatch(appSource, /\/provider-runs\?entity_id=/); + assert.doesNotMatch(appSource, /配置诊断|运维状态|运行与资料管理|真实后端已配置|模型 Token/); + assert.doesNotMatch(appSource, /data-source-action|data-job-action/); + assert.doesNotMatch(appSource, /providers\/model\/probe/); + assert.doesNotMatch(appSource, /ARK_API_KEY|SUPABASE_SERVICE_ROLE_KEY|OPENVIKING_API_KEY/); +}); + +test("formal frontend still exposes the complete sales workflow", () => { + assert.match(appSource, /销售目标/); + assert.match(appSource, /查找企业/); + assert.match(appSource, /目标企业池/); + assert.match(appSource, /获取最新档案/); + assert.match(appSource, /历史资料/); + assert.match(appSource, /MATERIAL_FILTERS = \["全部", "档案", "飞书会话", "云文档"\]/); + assert.doesNotMatch(appSource, /MATERIAL_FILTERS = [^\n]*"会议纪要"/); + assert.match(appSource, /id="openFeishuImport"/); + assert.match(appSource, /联系人姓名或会话 ID/); + assert.match(appSource, /完整的飞书云文档或知识库链接/); + assert.doesNotMatch(appSource, /姓名、Open ID 或会话 ID|飞书云文档链接或 Token/); + assert.doesNotMatch(appSource, /导入企业资料/); + assert.match(appSource, /class="library-tools"[\s\S]*?class="secondary-button library-import-button" id="openFeishuImport"[\s\S]*?>导入飞书资料历史资料<\/button>/); + assert.match(appSource, /data-support-view="qa"[\s\S]*?role="tab"[\s\S]*?>资料问答<\/button>/); + assert.match(appSource, /id="supportLibraryPanel"[\s\S]*?role="tabpanel"/); + assert.match(appSource, /id="supportQaPanel"[\s\S]*?role="tabpanel"/); + assert.match(appSource, /state\.supportView = nextView;/); + assert.match(appSource, /仅根据当前企业档案和用户导入的飞书资料回答/); + assert.match(appSource, /paragraphs: \(message\.paragraphs \|\| \[\]\)/); + assert.match(appSource, /class="qa-answer-body"/); + assert.match(appSource, /function renderQaAnswerParagraph/); + assert.match(appSource, /collapseRepeatedCitationRuns\(qaAnswerParagraphs\(message\)\)/); + assert.match(appSource, /dedupeCitationEntries\(rawCitationEntries\)/); + assert.match(appSource, /citationGroup/); + assert.match(appSource, /class="qa-citation-anchor"/); + assert.doesNotMatch(appSource, /
/); + assert.match(appSource, /window\.SalesTextFormat/); + assert.match(appSource, /function splitDisplayParagraphs/); + assert.match(textFormatSource, /function splitReadableBlocks/); + assert.match(textFormatSource, /function collapseRepeatedCitationRuns/); + assert.match(textFormatSource, /function dedupeCitationEntries/); + assert.doesNotMatch(appSource, /\(\[\^\\n\]\)\(\?=\(\?:\\d\+\[\.\)、\]/); + assert.match(appSource, /class="chat-message assistant is-pending"/); + assert.match(appSource, /const pendingMessages = \[\s+\.\.\.qaMessagesForCompany\(current\),\s+\{ role: "user", text: question \},\s+\];/); + assert.match(appSource, /rememberCompanyQa\(current\.id, pendingMessages\);\s+state\.busy = "qa";/); + assert.match(appSource, /function scrollQaToBottom/); + assert.match(appSource, /\/target-enterprises\/\$\{encodeURIComponent\(current\.id\)\}\/dossiers/); + assert.match(appSource, /\/target-enterprises\/\$\{encodeURIComponent\(current\.id\)\}\/qa/); + assert.match(appSource, /data-cancel-dossier-job/); + assert.match(appSource, /data-retry-dossier-job/); + assert.match(appSource, /function compactDossierStageLabel\(job\)/); + assert.match(appSource, /job\?\.stage_detail\?\.message/); + assert.match(appSource, /正在等待自动重试/); + assert.match(appSource, /正在核验专业资料/); + assert.match(appSource, /正在检索公开资料/); + assert.match(appSource, /正在查找资料/); + assert.match(appSource, /正在核验资料/); + assert.match(appSource, /正在整理档案/); + assert.match(appSource, /正在保存结果/); + assert.match(appSource, /class="dossier-job-spinner"/); + assert.match(appSource, /class="dossier-job-flow"/); + assert.doesNotMatch(appSource, /job\.progress/); + assert.match(styleSource, /@keyframes dossier-job-flow/); + assert.match(styleSource, /animation: dossier-job-flow/); + assert.match(appSource, /window\.sessionStorage\.getItem\(storageKey\)/); + assert.match(appSource, /clearDossierRequestIdempotencyKey\(current\.id\)/); + assert.match(appSource, /job\.stage !== "cancelling"/); + assert.match(appSource, /正在等待当前步骤安全结束后取消/); +}); + +test("formal frontend refreshes the active goal count after adding a company", () => { + assert.match(appSource, /if \(!goal\.pool\.includes\(id\)\) goal\.pool\.push\(id\);\s+goal\.stats = goalStats\(goal\.pool\.length\);/); +}); + +test("dossier versions and citations use API evidence only in formal mode", () => { + assert.match(appSource, /previousDossierId: item\.previous_dossier_id \|\| null/); + assert.doesNotMatch(appSource, /providerRunId|provider_run_id/); + assert.match(appSource, /data-dossier="\$\{escapeHtml\(update\.id\)\}"/); + assert.doesNotMatch(appSource, /与上一版比较|data-compare-dossier|version-comparison|\/compare\//); + assert.match(appSource, /if \(update\.citations\?\.length\) return update\.citations;\s+return \[\];/); + assert.match(appSource, /segments: \(paragraph\.segments \|\| \[\]\)\.map/); + assert.match(appSource, /paragraph\.segments\?\.length/); + assert.match(appSource, /renderTextWithCitations\(segment\.text, segment\.citationIds\)/); + assert.match(appSource, /暂无可验证的引用来源/); + assert.match(appSource, /专业数据集(DataPro)/); + assert.match(appSource, /联网搜索/); + assert.match(appSource, /查看数据明细/); + assert.match(appSource, /未标注发布时间/); + assert.match(appSource, /target="_blank"/); + assert.doesNotMatch(appSource, /source\.qualityLabel/); + assert.doesNotMatch(appSource, /source\.freshnessLabel/); + assert.doesNotMatch(appSource, /source\.verificationLabel/); + assert.doesNotMatch(appSource, /source\.conflictLabel|source\.conflict_label/); + assert.doesNotMatch(appSource, /关键字段存在来源差异/); + assert.doesNotMatch(appSource, /source\.entityMatch/); + assert.match(appSource, /source\.summary \|\| source\.excerpt/); + assert.match(appSource, /function professionalSourceDetails/); + assert.match(appSource, /function renderCitationGroups/); + assert.match(appSource, /function sourceSiteName/); + assert.match(appSource, /function sourcePublishLabel/); + assert.doesNotMatch(appSource, /source\.provider/); + assert.match(appSource, /档案正文暂未加载/); + assert.match(appSource, /function isPlaceholderUrl\(value\)/); + assert.match(appSource, /example\\\.\(com\|test\)/); + assert.match(appSource, /async function loadDossierDetail\(record, attempts = 3\)/); + assert.match(appSource, /系统不会用摘要冒充正文/); + assert.doesNotMatch(appSource, /detailLoadMessage|isPlaceholderUrl is not defined/); + assert.doesNotMatch(appSource, /body: item\.summary \|\| ""/); + assert.doesNotMatch(appSource, /\.catch\(\(\) => mapDossierFromApi\(record\)\)/); +}); + +test("formal frontend retries connections without exposing backend error details", () => { + assert.match(appSource, /function apiErrorMessage\(_error, fallback\) \{\s*return fallback \|\| "操作没有完成,请稍后重试。";/); + assert.match( + appSource, + /catch \(error\) \{\s*state\.showNewGoal = false;\s*state\.sidebarNotice = apiErrorMessage\(error, "暂时没能创建销售目标,请稍后再试。"\)/, + ); + assert.match(appSource, /id="retryBoot"/); + assert.match(appSource, /\$\("#retryBoot"\)\?\.addEventListener\("click"/); + assert.match(appSource, /工作台加载时间较长,请稍后重试/); + assert.doesNotMatch(appSource, /无法读取后端业务数据|后端响应超时|请求 \$\{error\.requestId\}|task\.error\?\.message/); +}); + +test("formal frontend authenticates before loading business data and protects mutations with CSRF", () => { + assert.match(appSource, /api\("\/auth\/status", \{ skipAuthRedirect: true \}\)/); + assert.match(appSource, /bootstrap \? "\/auth\/bootstrap" : "\/auth\/login"/); + assert.match(appSource, /name="username" autocomplete="username"/); + assert.doesNotMatch(appSource, /name="email"|type="email"/); + assert.match(appSource, /credentials: "same-origin"/); + assert.match(appSource, /cookieValue\("siw_csrf"\)/); + assert.match(appSource, /headers\["X-CSRF-Token"\] = csrfToken/); + assert.match(appSource, /id="logoutButton"/); + assert.match(appSource, /api\("\/auth\/logout", \{ method: "POST", skipAuthRedirect: true \}\)/); + assert.doesNotMatch(appSource, /AGENT_PLAN_API_KEY|SUPABASE_API_URL|service-role-secret|siw_access/); +}); + +test("formal frontend exposes one local administrator and no email or member flows", () => { + assert.doesNotMatch(appSource, /openMemberAdmin|memberInviteForm|\/admin\/members/); + assert.match(appSource, /设置本机管理员/); + assert.doesNotMatch(appSource, /reset-password|忘记密码|找回密码|重置密码/); + assert.doesNotMatch(appSource, /passwordRecoveryForm|\/auth\/password\/recover|\/auth\/password\/update/); + assert.doesNotMatch(appSource, /工作区成员|成员管理|成员邀请|找回密码|重置邮件/); + assert.doesNotMatch(appSource, /SUPABASE_SERVICE_ROLE_KEY|service_role/); +}); + +test("formal frontend assets carry the current cache key and responsive runtime styles", () => { + assert.match(htmlSource, /销售智能工作台<\/title>/); + assert.match(htmlSource, /20260730-source-list/); + assert.match(htmlSource, /text-format\.js/); + assert.match(styleSource, /\.version-tabs/); + assert.match(styleSource, /\.citation-group/); + assert.match(styleSource, /\.citation-source-row/); + assert.match(styleSource, /\.professional-source-details/); + assert.match(styleSource, /\.qa-answer-body/); + assert.match(styleSource, /\.qa-answer-paragraph/); + assert.match(styleSource, /\.qa-citation-anchor/); + assert.match(styleSource, /vertical-align: super/); + assert.match(styleSource, /\.chat-message\.is-pending/); + assert.match(styleSource, /@keyframes qa-pending-pulse/); + assert.match(styleSource, /\.dossier-report-section/); + assert.match(styleSource, /\.dossier-report-content/); + assert.match(styleSource, /\.library-dossier-link/); + assert.match(styleSource, /\.connection-retry/); + assert.match(styleSource, /\.auth-panel/); + assert.match(styleSource, /\.dialog-modal/); + assert.doesNotMatch(styleSource, /\.member-modal/); + assert.match(styleSource, /\.feishu-import-modal/); + assert.match(styleSource, /\.library-import-button/); + assert.match(styleSource, /@media \(max-width: 780px\)/); + assert.match(styleSource, /\.sales-layout\.is-mobile-navigation-open \.sales-sidebar/); + assert.match(appSource, /id="mobileNavigationToggle"/); +}); + +test("HTTP-hosted frontend uses the same-origin API by default", () => { + assert.match(appSource, /window\.location\.origin/); + assert.match(appSource, /`\$\{window\.location\.origin\}\/api`/); + assert.match(appSource, /\["http:", "https:"\]\.includes\(window\.location\.protocol\)/); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/frontendTextFormat.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/frontendTextFormat.test.mjs new file mode 100644 index 00000000..f723b399 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/frontendTextFormat.test.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import vm from "node:vm"; +import { fileURLToPath } from "node:url"; + +const backendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const source = await fs.readFile( + path.resolve(backendDir, "..", "frontend", "text-format.js"), + "utf8", +); +const sandbox = {}; +sandbox.globalThis = sandbox; +vm.runInNewContext(source, sandbox); + +const { + collapseRepeatedCitationRuns, + dedupeCitationEntries, + normalizeChineseTypography, + splitReadableBlocks, +} = sandbox.SalesTextFormat; + +test("Chinese typography keeps decimals and business numbers intact", () => { + const sourceText = "公开报道分别给出2769.17亿元与9.17亿元,需进一步交叉核验。"; + const normalized = normalizeChineseTypography(sourceText); + assert.match(normalized, /2769\.17亿元与9\.17亿元,/); + assert.doesNotMatch(normalized, /\n/); +}); + +test("readable blocks never treat years or decimals as inline list markers", () => { + const paragraphs = Array.from(splitReadableBlocks( + "2026年7月,公司披露业务进展。风险数据需交叉核验,公开来源给出2769.17亿元与9.17亿元两个口径。", + 180, + )); + assert.equal(paragraphs.length, 1); + assert.match(paragraphs[0], /2026年7月/); + assert.match(paragraphs[0], /2769\.17亿元与9\.17亿元/); +}); + +test("readable blocks repair model line breaks inside percentages and amounts", () => { + const paragraphs = Array.from(splitReadableBlocks( + "产能利用率约\n\n9\n\n4.86%,两篇报道分别给出\n\n2\n\n7\n\n6\n\n9.17亿元与2769亿元。", + 180, + )); + assert.equal(paragraphs.length, 1); + assert.match(paragraphs[0], /产能利用率约94\.86%/); + assert.match(paragraphs[0], /分别给出2769\.17亿元与2769亿元/); +}); + +test("readable blocks preserve real numbered list lines", () => { + const paragraphs = Array.from(splitReadableBlocks( + "建议行动:\n1. 核验法定主体与公开事项归属\n2. 确认采购部门和预算窗口", + 180, + )); + assert.deepEqual(paragraphs, [ + "建议行动:", + "1. 核验法定主体与公开事项归属", + "2. 确认采购部门和预算窗口", + ]); +}); + +test("readable blocks split inline Arabic numbered actions into separate lines", () => { + const paragraphs = Array.from(splitReadableBlocks( + "建议行动:1)核验主体。2)联系采购部门。3)确认预算窗口。", + 180, + )); + assert.deepEqual(paragraphs, [ + "建议行动:", + "1)核验主体。", + "2)联系采购部门。", + "3)确认预算窗口。", + ]); +}); + +test("readable blocks split dot-numbered actions and preserve years and decimals", () => { + const paragraphs = Array.from(splitReadableBlocks( + "建议行动:1. 联系采购部门。2. 核验2026年项目窗口。3. 确认9.17亿元口径。", + 180, + )); + assert.deepEqual(paragraphs, [ + "建议行动:", + "1. 联系采购部门。", + "2. 核验2026年项目窗口。", + "3. 确认9.17亿元口径。", + ]); +}); + +test("readable blocks also split compact dot-numbered actions", () => { + const paragraphs = Array.from(splitReadableBlocks( + "建议行动:1.联系采购部门。2.确认预算窗口。3.准备合规材料。", + 180, + )); + assert.deepEqual(paragraphs, [ + "建议行动:", + "1.联系采购部门。", + "2.确认预算窗口。", + "3.准备合规材料。", + ]); +}); + +test("readable blocks split Chinese ordinal points into separate lines", () => { + const paragraphs = Array.from(splitReadableBlocks( + "销售机会判断:第一,确认业务场景。第二,核验采购时机。第三,准备合规材料。", + 180, + )); + assert.deepEqual(paragraphs, [ + "销售机会判断:", + "第一,确认业务场景。", + "第二,核验采购时机。", + "第三,准备合规材料。", + ]); +}); + +test("readable blocks split 一是 style points without breaking years or decimals", () => { + const paragraphs = Array.from(splitReadableBlocks( + "判断如下:一是关注2026年项目。二是核验9.17亿元口径。三是确认责任部门。", + 180, + )); + assert.deepEqual(paragraphs, [ + "判断如下:", + "一是关注2026年项目。", + "二是核验9.17亿元口径。", + "三是确认责任部门。", + ]); +}); + +test("consecutive answer blocks with the same evidence show one citation at the end of the run", () => { + const paragraphs = Array.from(collapseRepeatedCitationRuns([ + { text: "第一点。", citationIds: ["source-1"] }, + { text: "第二点。", citationIds: ["source-1"] }, + { text: "第三点。", citationIds: ["source-1"] }, + { text: "补充事实。", citationIds: ["source-2"] }, + ])); + assert.deepEqual( + paragraphs.map((item) => Array.from(item.displayCitationIds)), + [[], [], ["source-1"], ["source-2"]], + ); +}); + +test("citation runs keep separate markers when the supporting source set changes", () => { + const paragraphs = Array.from(collapseRepeatedCitationRuns([ + { text: "第一组。", citationIds: ["source-1", "source-2"] }, + { text: "第二组。", citationIds: ["source-2", "source-1"] }, + { text: "第三组。", citationIds: ["source-1"] }, + ])); + assert.deepEqual( + paragraphs.map((item) => Array.from(item.displayCitationIds)), + [[], ["source-2", "source-1"], ["source-1"]], + ); +}); + +test("citation runs do not merge across original answer paragraphs", () => { + const paragraphs = Array.from(collapseRepeatedCitationRuns([ + { text: "第一段第一点。", citationIds: ["source-1"], citationGroup: 0 }, + { text: "第一段第二点。", citationIds: ["source-1"], citationGroup: 0 }, + { text: "第二段。", citationIds: ["source-1"], citationGroup: 1 }, + ])); + assert.deepEqual( + paragraphs.map((item) => Array.from(item.displayCitationIds)), + [[], ["source-1"], ["source-1"]], + ); +}); + +test("duplicate source labels share one visible source number without losing citation ids", () => { + const result = dedupeCitationEntries([ + { id: "chunk-1", label: "飞书云文档:客户需求确认会" }, + { id: "chunk-2", label: "飞书云文档:客户需求确认会" }, + { id: "dossier-1", label: "最近档案 V2" }, + ]); + assert.deepEqual( + Array.from(result.entries, (item) => item.label), + ["飞书云文档:客户需求确认会", "最近档案 V2"], + ); + assert.deepEqual( + JSON.parse(JSON.stringify(result.citationNumbers)), + { "chunk-1": 1, "chunk-2": 1, "dossier-1": 2 }, + ); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/httpSecurity.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/httpSecurity.test.mjs new file mode 100644 index 00000000..7a07bbb3 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/httpSecurity.test.mjs @@ -0,0 +1,386 @@ +import assert from "node:assert/strict"; +import { Readable } from "node:stream"; +import test from "node:test"; + +import { createRouter } from "../src/routes/index.js"; +import { HttpError } from "../src/utils/http.js"; +import { createRateLimiters } from "../src/security/rateLimiter.js"; + +const roles = Object.freeze({ viewer: 0, member: 1, admin: 2, owner: 3 }); + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + number(name, fallback) { + const value = Number(this.value(name, fallback)); + return Number.isFinite(value) ? value : fallback; + }, + }; +} + +function authServiceStub() { + const auditEvents = []; + return { + auditEvents, + async sessionStatus() { + return { enabled: true, authenticated: false, bootstrap_required: true, user: null }; + }, + async bootstrap() { + return { authenticated: true, user: { role: "owner" } }; + }, + async login() { + return { authenticated: true, user: { role: "member" } }; + }, + async refresh() { + return { authenticated: true, user: { role: "member" } }; + }, + async logout() { + return { authenticated: false }; + }, + async recordAudit(auth, event) { + auditEvents.push({ actor_user_id: auth?.principal?.id || null, ...structuredClone(event) }); + return true; + }, + async listAuditEvents() { + return structuredClone(auditEvents); + }, + async authenticateRequest(req) { + const bearer = String(req.headers.authorization || "").match(/^Bearer\s+(.+)$/i)?.[1]; + if (bearer && Object.hasOwn(roles, bearer)) { + return { source: "bearer", principal: { id: `${bearer}-id`, role: bearer } }; + } + if (String(req.headers.cookie || "").includes("session=member")) { + return { source: "cookie", principal: { id: "cookie-member", role: "member" } }; + } + return null; + }, + requireRole(auth, required) { + if (!auth) throw new HttpError(401, "authentication_required", "请先登录。"); + if (roles[auth.principal.role] < roles[required]) { + throw new HttpError(403, "insufficient_role", "权限不足。"); + } + }, + assertCsrf(req, auth) { + if (auth?.source === "cookie" && req.headers["x-csrf-token"] !== "csrf-ok") { + throw new HttpError(403, "csrf_failed", "CSRF failed."); + } + }, + }; +} + +function requestRouter(router, pathname, options = {}) { + const body = options.body || ""; + const headers = Object.fromEntries( + Object.entries(options.headers || {}).map(([name, value]) => [name.toLowerCase(), value]), + ); + if (body && !headers["content-length"]) headers["content-length"] = String(Buffer.byteLength(body)); + const req = Readable.from(body ? [Buffer.from(body)] : []); + req.method = options.method || "GET"; + req.url = pathname; + req.headers = headers; + req.socket = { remoteAddress: "127.0.0.1" }; + return new Promise((resolve, reject) => { + const responseHeaders = {}; + const res = { + statusCode: null, + setHeader(name, value) { + responseHeaders[String(name).toLowerCase()] = value; + }, + writeHead(statusCode, extraHeaders = {}) { + this.statusCode = statusCode; + for (const [name, value] of Object.entries(extraHeaders)) this.setHeader(name, value); + }, + end(responseBody = "") { + const text = Buffer.isBuffer(responseBody) ? responseBody.toString("utf8") : String(responseBody || ""); + resolve({ + status: this.statusCode, + headers: responseHeaders, + text, + json: () => JSON.parse(text || "{}"), + }); + }, + }; + Promise.resolve(router(req, res)).catch(reject); + }); +} + +async function withRouter(run, options = {}) { + const env = envReader({ + API_MAX_BODY_BYTES: "1024", + ALLOWED_ORIGINS: "https://allowed.example", + API_RATE_LIMIT_PER_MIN: "1000", + API_WRITE_RATE_LIMIT_PER_MIN: "1000", + API_PAID_RATE_LIMIT_PER_MIN: "1000", + AUTH_RATE_LIMIT_PER_15_MIN: "1000", + }); + const salesService = { + assertRuntimeReady: async () => {}, + listGoals: () => [{ id: "goal-1", name: "测试目标" }], + createGoal: async (body) => ({ id: "goal-created", name: body.name }), + exportWorkspaceData: () => ({ format: "sales-intelligence-workbench-workspace-export" }), + ...(options.salesService || {}), + }; + const service = { getProviderStatus: () => ({ providers: [] }) }; + const router = createRouter(service, { + env, + salesService, + authService: options.authService || authServiceStub(), + rateLimiters: createRateLimiters(env), + runtimePolicy: options.runtimePolicy || { + ready: true, + fail_closed: false, + blockers: [], + }, + }); + await run((pathname, options) => requestRouter(router, pathname, options)); +} + +test("dossier detail reuses freshly loaded sales state instead of forcing a full Supabase refresh", async () => { + const refreshOptions = []; + await withRouter(async (request) => { + const response = await request("/api/dossiers/dossier-1", { + headers: { authorization: "Bearer viewer" }, + }); + assert.equal(response.status, 200); + assert.equal(response.json().data.id, "dossier-1"); + }, { + salesService: { + refreshPersistedState: async (options) => { + refreshOptions.push(options); + }, + dossierDetail: () => ({ id: "dossier-1", body: [], citations: [] }), + }, + }); + assert.deepEqual(refreshOptions, [{ minIntervalMs: 5_000 }]); +}); + +test("asynchronous dossier routes return 202 and expose only safe task progress", async () => { + const calls = []; + const publicJob = { + id: "job-public-1", + job_type: "sales_dossier_generation", + status: "queued", + stage: "queued", + stage_label: "等待执行", + progress: 0, + entity_type: "target_enterprise", + entity_id: "company-1", + attempt_count: 0, + max_attempts: 3, + retryable: false, + error: null, + result: null, + }; + const internalJob = { + ...publicJob, + request: { hidden_prompt: "private" }, + worker_id: "worker-private", + reservation_id: "reservation-private", + created_by: "member-id", + }; + const toPublicJob = (job) => Object.fromEntries( + Object.entries(job).filter(([key]) => !["request", "worker_id", "reservation_id", "created_by"].includes(key)), + ); + + await withRouter(async (request) => { + const created = await request("/api/target-enterprises/company-1/dossiers", { + method: "POST", + headers: { Authorization: "Bearer member", "Content-Type": "application/json" }, + body: JSON.stringify({ idempotency_key: "request-1" }), + }); + assert.equal(created.status, 202); + assert.equal(created.json().data.id, publicJob.id); + assert.equal(calls[0].body.idempotency_key, "request-1"); + assert.equal(calls[0].options.created_by, "member-id"); + + const listed = await request("/api/jobs?job_type=sales_dossier_generation&entity_id=company-1&limit=1", { + headers: { Authorization: "Bearer viewer" }, + }); + assert.equal(listed.status, 200); + assert.deepEqual(listed.json().data, [publicJob]); + assert.doesNotMatch(listed.text, /hidden_prompt|worker-private|reservation-private/); + + const detail = await request(`/api/jobs/${publicJob.id}`, { + headers: { Authorization: "Bearer viewer" }, + }); + assert.equal(detail.status, 200); + assert.doesNotMatch(detail.text, /hidden_prompt|worker-private|reservation-private/); + + assert.equal((await request(`/api/jobs/${publicJob.id}/cancel`, { + method: "POST", + headers: { Authorization: "Bearer viewer" }, + })).status, 403); + assert.equal((await request(`/api/jobs/${publicJob.id}/cancel`, { + method: "POST", + headers: { Authorization: "Bearer member" }, + })).status, 200); + assert.equal((await request(`/api/jobs/${publicJob.id}/retry`, { + method: "POST", + headers: { Authorization: "Bearer member" }, + })).status, 200); + }, { + runtimePolicy: { + ready: true, + fail_closed: true, + blockers: [], + }, + salesService: { + asyncJobsEnabled: true, + async enqueueDossier(companyId, body, options) { + calls.push({ companyId, body, options }); + return publicJob; + }, + async listPublicJobs() { + return [publicJob]; + }, + async getPublicJob() { + return publicJob; + }, + async cancelJob() { + return { ...internalJob, status: "cancelled", stage: "cancelled" }; + }, + publicJob: toPublicJob, + async retryJob() { + return publicJob; + }, + }, + }); +}); + +test("health stays public while sales and provider APIs enforce role boundaries", async () => { + await withRouter(async (request) => { + assert.equal((await request("/api/health")).status, 200); + assert.equal((await request("/api/sales-goals")).status, 401); + assert.equal((await request("/api/sales-goals", { + headers: { Authorization: "Bearer viewer" }, + })).status, 200); + assert.equal((await request("/api/sales-goals", { + method: "POST", + headers: { Authorization: "Bearer viewer", "Content-Type": "application/json" }, + body: JSON.stringify({ name: "目标" }), + })).status, 403); + assert.equal((await request("/api/sales-goals", { + method: "POST", + headers: { Authorization: "Bearer member", "Content-Type": "application/json" }, + body: JSON.stringify({ name: "目标" }), + })).status, 201); + assert.equal((await request("/api/providers/status", { + headers: { Authorization: "Bearer viewer" }, + })).status, 403); + assert.equal((await request("/api/providers/status", { + headers: { Authorization: "Bearer admin" }, + })).status, 200); + }); +}); + +test("email recovery and multi-user administration are not exposed", async () => { + await withRouter(async (request) => { + assert.equal((await request("/api/auth/password/recover", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "user@example.com" }), + })).status, 404); + assert.equal((await request("/api/auth/password/update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: "a-secure-password" }), + })).status, 404); + assert.equal((await request("/api/admin/members", { + headers: { Authorization: "Bearer admin" }, + })).status, 404); + assert.equal((await request("/api/admin/members", { + method: "POST", + headers: { Authorization: "Bearer admin", "Content-Type": "application/json" }, + body: JSON.stringify({ email: "new@example.com", role: "member" }), + })).status, 404); + }); +}); + +test("workspace business export is owner-only and never cacheable", async () => { + await withRouter(async (request) => { + assert.equal((await request("/api/admin/workspace-export")).status, 401); + assert.equal((await request("/api/admin/workspace-export", { + headers: { Authorization: "Bearer admin" }, + })).status, 403); + const exported = await request("/api/admin/workspace-export", { + headers: { Authorization: "Bearer owner" }, + }); + assert.equal(exported.status, 200); + assert.equal(exported.headers["cache-control"], "no-store"); + assert.equal(exported.json().data.format, "sales-intelligence-workbench-workspace-export"); + }); +}); + +test("business mutations write metadata-only audit events and admins can list them", async () => { + const authService = authServiceStub(); + await withRouter(async (request) => { + const created = await request("/api/sales-goals", { + method: "POST", + headers: { Authorization: "Bearer member", "Content-Type": "application/json" }, + body: JSON.stringify({ name: "华东重点客户", password: "must-not-enter-audit" }), + }); + assert.equal(created.status, 201); + assert.equal(authService.auditEvents.length, 1); + assert.equal(authService.auditEvents[0].action, "sales_goal.created"); + assert.equal(authService.auditEvents[0].entity_type, "sales_goal"); + assert.equal(authService.auditEvents[0].entity_id, "goal-created"); + assert.equal(JSON.stringify(authService.auditEvents[0]).includes("must-not-enter-audit"), false); + + const denied = await request("/api/admin/audit-events", { + headers: { Authorization: "Bearer member" }, + }); + assert.equal(denied.status, 403); + + const listed = await request("/api/admin/audit-events", { + headers: { Authorization: "Bearer admin" }, + }); + assert.equal(listed.status, 200); + assert.equal(listed.json().data[0].action, "sales_goal.created"); + }, { authService }); +}); + +test("cookie mutations require CSRF and oversized JSON is rejected", async () => { + await withRouter(async (request) => { + assert.equal((await request("/api/sales-goals", { + method: "POST", + headers: { Cookie: "session=member", "Content-Type": "application/json" }, + body: JSON.stringify({ name: "目标" }), + })).status, 403); + assert.equal((await request("/api/sales-goals", { + method: "POST", + headers: { Cookie: "session=member", "X-CSRF-Token": "csrf-ok", "Content-Type": "application/json" }, + body: JSON.stringify({ name: "目标" }), + })).status, 201); + const oversized = await request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "local-admin", password: "x".repeat(1500) }), + }); + assert.equal(oversized.status, 413); + assert.equal(oversized.json().error.code, "payload_too_large"); + }); +}); + +test("CORS reflects only explicitly allowed origins and never uses a wildcard", async () => { + await withRouter(async (request) => { + const sameOrigin = await request("/api/health", { + headers: { + Origin: "http://127.0.0.1:8877", + Host: "127.0.0.1:8877", + }, + }); + assert.equal(sameOrigin.status, 200); + assert.equal(sameOrigin.headers["access-control-allow-origin"], undefined); + + const allowed = await request("/api/health", { headers: { Origin: "https://allowed.example" } }); + assert.equal(allowed.status, 200); + assert.equal(allowed.headers["access-control-allow-origin"], "https://allowed.example"); + assert.equal(allowed.headers["access-control-allow-credentials"], "true"); + const rejected = await request("/api/health", { headers: { Origin: "https://evil.example" } }); + assert.equal(rejected.status, 403); + assert.equal(rejected.headers["access-control-allow-origin"], undefined); + assert.notEqual(allowed.headers["content-security-policy"], undefined); + }); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/materialImport.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/materialImport.test.mjs new file mode 100644 index 00000000..7f79b1d6 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/materialImport.test.mjs @@ -0,0 +1,471 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SalesService } from "../src/services/salesService.js"; + +const permissiveTestPolicy = Object.freeze({ + fail_closed: false, +}); + +const strictRuntimePolicy = Object.freeze({ + fail_closed: true, +}); + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +function seed() { + return { + goals: [], + companies: { + company_a: { + id: "company_a", + name: "企业 A", + industry: "测试行业", + material_ids: [], + dossier_ids: [], + }, + company_b: { + id: "company_b", + name: "企业 B", + industry: "测试行业", + material_ids: [], + dossier_ids: [], + }, + }, + dossiers: {}, + materials: {}, + qa_messages: {}, + sync_sources: {}, + sync_checkpoints: {}, + }; +} + +function openVikingFake() { + const writes = []; + const finds = []; + const resources = new Map(); + return { + writes, + finds, + resources, + reads: [], + removals: [], + sessionMessages: [], + sessionUses: [], + sessionCommits: [], + isConfigured: () => true, + isRunEnabled: () => true, + salesCompanyUri: ({ workspaceId, companyId }) => `viking://sales/${workspaceId}/${companyId}`, + salesMaterialUri: ({ workspaceId, companyId, sourceId }) => `viking://sales/${workspaceId}/${companyId}/${sourceId}.md`, + salesDossierUri: ({ workspaceId, companyId, dossierId }) => `viking://sales/${workspaceId}/${companyId}/dossiers/${dossierId}.md`, + salesSessionId: ({ workspaceId, companyId }) => `sales-${workspaceId}-${companyId}`, + async upsertTextResource(input) { + writes.push(input); + resources.set(input.uri, input.content); + return { + ok: true, + uri: input.uri, + raw_ref: input.uri, + summary: "stored", + }; + }, + async readTextResource(uri) { + this.reads.push(uri); + if (!resources.has(uri)) { + return { + ok: false, + http_status: 404, + error: { code: "not_found", message: "Resource not found" }, + }; + } + return { + ok: true, + uri, + content: resources.get(uri), + raw_ref: uri, + }; + }, + async findMemories(query, options) { + finds.push({ query, options }); + return { ok: true, result: { resources: [] } }; + }, + async removeResource(uri) { + this.removals.push(uri); + return { ok: true, uri, raw_ref: uri }; + }, + async addSessionMessages(sessionId, messages) { + this.sessionMessages.push({ sessionId, messages }); + return { ok: true, raw_ref: `openviking:session:${sessionId}:messages` }; + }, + async recordSessionUsed(sessionId, contexts) { + this.sessionUses.push({ sessionId, contexts }); + return { ok: true, raw_ref: `openviking:session:${sessionId}:used` }; + }, + async commitSession(sessionId) { + this.sessionCommits.push(sessionId); + return { ok: true, raw_ref: `openviking:session:${sessionId}:commit` }; + }, + }; +} + +function createService(provider = openVikingFake()) { + return { + provider, + service: new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + openVikingProvider: provider, + }), + }; +} + +test("same source and content is skipped while changed content updates one material", async () => { + const { service, provider } = createService(); + const source = { + type: "feishu_doc", + external_id: "doc-token-1", + checkpoint_key: "revision_id", + checkpoint_value: "1", + config: { document_id: "doc-1", access_token: "must-not-persist" }, + }; + + const created = await service.importMaterial("company_a", { + title: "客户方案", + source, + source_url: "https://example.feishu.cn/wiki/doc-token-1", + raw_text: "第一版内容", + }); + const unchanged = await service.importMaterial("company_a", { + title: "客户方案", + source, + source_url: "https://example.feishu.cn/wiki/doc-token-1", + raw_text: "第一版内容", + }); + const updated = await service.importMaterial("company_a", { + title: "客户方案", + source: { ...source, checkpoint_value: "2" }, + source_url: "https://example.feishu.cn/wiki/doc-token-1", + raw_text: "第二版内容", + }); + + assert.equal(created.action, "created"); + assert.equal(unchanged.action, "unchanged"); + assert.equal(updated.action, "updated"); + assert.equal(created.material.id, unchanged.material.id); + assert.equal(created.material.id, updated.material.id); + assert.equal(provider.writes.length, 2); + assert.equal(provider.writes[0].mode, "create"); + assert.equal(provider.writes[1].mode, "replace"); + assert.equal(updated.checkpoint.checkpoint_value, "2"); + assert.equal(Object.hasOwn(updated.source.config, "access_token"), false); + assert.ok(created.provider_run_id); + assert.equal(service.listMaterials("company_a").length, 1); +}); + +test("incremental Feishu messages merge by id instead of replacing history", async () => { + const { service, provider } = createService(); + const source = { + type: "feishu_p2p", + external_id: "oc_p2p_1", + checkpoint_key: "last_message", + }; + + const first = await service.importMaterial("company_a", { + title: "客户沟通", + source: { ...source, checkpoint_value: "2026-07-20T10:00:00Z" }, + source_items: [ + { id: "om_1", occurred_at: "2026-07-20T10:00:00Z", sender: "客户", content: "需要私有化部署" }, + ], + }); + const second = await service.importMaterial("company_a", { + title: "客户沟通", + source: { ...source, checkpoint_value: "2026-07-20T11:00:00Z" }, + source_items: [ + { id: "om_1", occurred_at: "2026-07-20T10:00:00Z", sender: "客户", content: "需要私有化部署" }, + { id: "om_2", occurred_at: "2026-07-20T11:00:00Z", sender: "销售", content: "已安排方案评审" }, + ], + }); + + const stored = service.data.materials[first.material.id]; + assert.equal(second.action, "updated"); + assert.deepEqual(stored.source_items.map((item) => item.id), ["om_1", "om_2"]); + assert.match(stored.text, /需要私有化部署/); + assert.match(stored.text, /已安排方案评审/); + assert.equal(provider.writes.length, 2); +}); + +test("incremental Feishu import restores prior content from OpenViking after a process restart", async () => { + const provider = openVikingFake(); + const firstService = createService(provider).service; + const source = { + type: "feishu_p2p", + external_id: "oc_restart_1", + checkpoint_key: "last_message", + }; + + const first = await firstService.importMaterial("company_a", { + title: "重启恢复沟通", + source: { ...source, checkpoint_value: "2026-07-20T10:00:00Z" }, + source_items: [ + { id: "om_restart_1", occurred_at: "2026-07-20T10:00:00Z", sender: "客户", content: "需要私有化部署" }, + ], + }); + + const persistedSeed = structuredClone(firstService.data); + persistedSeed.materials[first.material.id].summary = ""; + persistedSeed.materials[first.material.id].text = ""; + persistedSeed.materials[first.material.id].source_items = []; + const restartedService = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: persistedSeed, + openVikingProvider: provider, + }); + + const second = await restartedService.importMaterial("company_a", { + title: "重启恢复沟通", + source: { ...source, checkpoint_value: "2026-07-20T11:00:00Z" }, + source_items: [ + { id: "om_restart_2", occurred_at: "2026-07-20T11:00:00Z", sender: "销售", content: "已安排方案评审" }, + ], + }); + + const stored = restartedService.data.materials[first.material.id]; + assert.equal(second.action, "updated"); + assert.deepEqual(stored.source_items.map((item) => item.id), ["om_restart_1", "om_restart_2"]); + assert.match(stored.text, /需要私有化部署/); + assert.match(stored.text, /已安排方案评审/); + assert.deepEqual(provider.reads, [stored.openviking_uri]); +}); + +test("OpenViking retrieval is restricted to the selected company's Feishu materials subtree", async () => { + const { service, provider } = createService(); + + await service.searchOpenViking(service.data.companies.company_a, "预算情况"); + await service.searchOpenViking(service.data.companies.company_b, "预算情况"); + + assert.equal(provider.finds[0].options.uri, "viking://sales/workspace-test/company_a/materials"); + assert.equal(provider.finds[1].options.uri, "viking://sales/workspace-test/company_b/materials"); + assert.notEqual(provider.finds[0].options.uri, provider.finds[1].options.uri); +}); + +test("runtime does not disguise an empty OpenViking retrieval with local materials", async () => { + const provider = openVikingFake(); + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + openVikingProvider: provider, + }); + service.data.materials.material_1 = { + id: "material_1", + company_id: "company_a", + title: "客户沟通纪要", + source_type: "飞书会议纪要", + text: "客户预算为 100 万元。", + summary: "客户预算为 100 万元。", + openviking_uri: "viking://sales/workspace-test/company_a/material_1.md", + }; + service.data.companies.company_a.material_ids.push("material_1"); + + const contexts = await service.searchOpenViking( + service.data.companies.company_a, + "客户预算是多少?", + ); + + assert.deepEqual(contexts, []); + assert.equal(provider.finds.length, 1); +}); + +test("test policy does not disguise an empty OpenViking retrieval with local material content", async () => { + const { service } = createService(); + service.data.materials.material_1 = { + id: "material_1", + company_id: "company_a", + title: "客户沟通纪要", + source_type: "飞书会议纪要", + text: "客户预算为 100 万元。", + summary: "客户预算为 100 万元。", + openviking_uri: "viking://sales/workspace-test/company_a/material_1.md", + }; + service.data.companies.company_a.material_ids.push("material_1"); + + const contexts = await service.searchOpenViking( + service.data.companies.company_a, + "客户预算是多少?", + ); + + assert.deepEqual(contexts, []); +}); + +test("QA material fallback excludes local records that were not imported from Feishu", async () => { + const { service } = createService(); + service.data.materials.material_1 = { + id: "material_1", + company_id: "company_a", + title: "手工备注", + source_type: "manual", + text: "这条内容不是用户导入的飞书资料。", + summary: "这条内容不是用户导入的飞书资料。", + }; + service.data.companies.company_a.material_ids.push("material_1"); + + const contexts = await service.searchOpenViking( + service.data.companies.company_a, + "客户预算是多少?", + ); + + assert.deepEqual(contexts, []); +}); + +test("QA writes use a workspace-and-company-scoped OpenViking session", async () => { + const { service, provider } = createService(); + const company = service.data.companies.company_a; + + await service.captureQaSession( + company, + { text: "预算是多少?" }, + { text: "当前资料未提供明确预算。" }, + [{ uri: "viking://sales/workspace-test/company_a/materials/source.md" }], + ); + const committed = await service.commitQaMemory("company_a"); + + assert.equal(provider.writes.length, 0); + assert.equal(provider.sessionMessages[0].sessionId, "sales-workspace-test-company_a"); + assert.equal(provider.sessionUses[0].sessionId, "sales-workspace-test-company_a"); + assert.deepEqual(provider.sessionCommits, ["sales-workspace-test-company_a"]); + assert.equal(committed.status, "ready"); + assert.ok(committed.job_id); + assert.ok(committed.provider_run_id); + assert.equal((await service.getJob(committed.job_id)).status, "succeeded"); + assert.equal((await service.getProviderRun(committed.provider_run_id)).job_id, committed.job_id); + assert.equal(Object.hasOwn(committed, "raw_ref"), false); +}); + +test("QA capture keeps the actual OpenViking session id for later persistence and commit", async () => { + const provider = openVikingFake(); + provider.addSessionMessages = async function addSessionMessages(sessionId, messages) { + this.sessionMessages.push({ sessionId, messages }); + return { + ok: true, + session_id: "server-session-company-a", + raw_ref: "openviking:session:server-session-company-a:messages", + }; + }; + const { service } = createService(provider); + const company = service.data.companies.company_a; + + const captured = await service.captureQaSession( + company, + { text: "客户关心什么?" }, + { text: "客户关心数据权限边界。" }, + [{ uri: "viking://sales/workspace-test/company_a/materials/source.md" }], + ); + await service.commitQaMemory("company_a"); + + assert.equal(captured.session_id, "server-session-company-a"); + assert.equal(company.qa_session_id, "server-session-company-a"); + assert.equal(provider.sessionUses[0].sessionId, "server-session-company-a"); + assert.deepEqual(provider.sessionCommits, ["server-session-company-a"]); +}); + +test("paused sources require an explicit resume before importing", async () => { + const { service } = createService(); + const body = { + title: "暂停资料", + source: { type: "feishu_doc", external_id: "paused-doc" }, + raw_text: "内容", + }; + const identity = service.getMaterialSyncState("company_a", body); + service.data.sync_sources[identity.source_id] = { + id: identity.source_id, + status: "paused", + }; + + await assert.rejects( + () => service.importMaterial("company_a", body), + (error) => error.status === 409 && error.code === "sync_source_paused", + ); + const resumed = await service.importMaterial("company_a", { ...body, resume_source: true }); + assert.equal(resumed.action, "created"); +}); + +test("source lifecycle supports pause, resume and deletion from Supabase/OpenViking state", async () => { + const { service, provider } = createService(); + const body = { + title: "待维护资料", + source: { type: "feishu_doc", external_id: "lifecycle-doc" }, + raw_text: "内容", + }; + const imported = await service.importMaterial("company_a", body); + const openVikingUri = service.data.materials[imported.material.id].openviking_uri; + + const sources = service.listMaterialSyncSources("company_a"); + const paused = await service.updateMaterialSyncSource("company_a", { source_id: imported.source.id, action: "pause" }); + const resumed = await service.updateMaterialSyncSource("company_a", { source_id: imported.source.id, action: "resume" }); + const deleted = await service.updateMaterialSyncSource("company_a", { source_id: imported.source.id, action: "delete" }); + + assert.equal(sources.length, 1); + assert.equal(sources[0].id, imported.source.id); + assert.equal(sources[0].material_count, 1); + assert.deepEqual(sources[0].material_ids, [imported.material.id]); + assert.equal(sources[0].checkpoint.last_success_at, imported.checkpoint.last_success_at); + assert.equal(sources[0].openviking_statuses.ready, 1); + assert.equal(paused.source.status, "paused"); + assert.equal(resumed.source.status, "active"); + assert.equal(deleted.source.status, "deleted"); + assert.deepEqual(deleted.affected_material_ids, [imported.material.id]); + assert.deepEqual(provider.removals, [openVikingUri]); + assert.ok(deleted.job_id); + assert.ok(deleted.provider_run_id); + assert.equal((await service.getJob(deleted.job_id)).status, "succeeded"); + assert.equal((await service.getProviderRun(deleted.provider_run_id)).job_id, deleted.job_id); + assert.doesNotMatch(JSON.stringify(imported.openviking_record), /viking:\/\//i); + assert.equal(service.data.materials[imported.material.id], undefined); + assert.deepEqual(service.data.companies.company_a.material_ids, []); +}); + +test("batch material sync is guarded, traceable and keeps raw OpenViking refs private", async () => { + const { service, provider } = createService(); + const imported = await service.importMaterial("company_a", { + title: "客户需求纪要", + source: { type: "feishu_doc", external_id: "batch-sync-doc" }, + raw_text: "客户计划在第三季度完成技术评估。", + }); + provider.writes.length = 0; + + const synced = await service.syncMaterialsToOpenViking("company_a"); + + assert.equal(synced.status, "ready"); + assert.equal(synced.records.length, 1); + assert.equal(synced.records[0].material_id, imported.material.id); + assert.ok(synced.job_id); + assert.ok(synced.provider_run_id); + assert.equal((await service.getJob(synced.job_id)).status, "succeeded"); + assert.equal((await service.getProviderRun(synced.provider_run_id)).job_id, synced.job_id); + assert.equal(provider.writes.length, 1); + assert.doesNotMatch(JSON.stringify(synced), /viking:\/\//i); +}); + +test("source lifecycle rejects a source_id that is not attached to the selected company", async () => { + const { service } = createService(); + const imported = await service.importMaterial("company_a", { + title: "企业 A 私有资料", + source: { type: "feishu_doc", external_id: "company-a-doc" }, + raw_text: "仅属于企业 A 的内容", + }); + + await assert.rejects( + () => service.updateMaterialSyncSource("company_b", { source_id: imported.source.id, action: "pause" }), + (error) => error.status === 404 + && error.code === "sync_source_not_found" + && error.details?.company_id === "company_b", + ); + assert.equal(service.data.sync_sources[imported.source.id].status, "active"); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/materialSync.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/materialSync.test.mjs new file mode 100644 index 00000000..b648cd30 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/materialSync.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildMaterialSyncIdentity, + makeMaterialContentHash, + makeMaterialId, + makeSyncSourceId, + mergeSourceItems, + normalizeExternalId, + renderSourceItems, +} from "../src/sync/materialSync.js"; + +test("Feishu document URLs resolve to one stable source and material identity", () => { + const first = buildMaterialSyncIdentity("company-1", { + title: "销售方案", + source_type: "飞书云文档", + source_url: "https://example.feishu.cn/wiki/AbCdEf?from=copy#section", + }); + const second = buildMaterialSyncIdentity("company-1", { + title: "销售方案(改名)", + source: { + type: "feishu_doc", + external_id: "AbCdEf", + }, + }); + + assert.equal(normalizeExternalId("feishu_doc", first.source_url), "AbCdEf"); + assert.equal(first.source_id, second.source_id); + assert.equal(first.material_id, second.material_id); + assert.equal(first.source_type, "feishu_doc"); +}); + +test("stable identifiers are isolated by source and company", () => { + const sourceA = makeSyncSourceId("feishu_chat", "oc_a"); + const sourceB = makeSyncSourceId("feishu_chat", "oc_b"); + + assert.notEqual(sourceA, sourceB); + assert.notEqual(makeMaterialId("company-a", sourceA), makeMaterialId("company-b", sourceA)); +}); + +test("material hashes ignore line-ending noise but change with business content", () => { + const first = makeMaterialContentHash({ title: "纪要", text: "第一行\r\n第二行" }); + const same = makeMaterialContentHash({ title: "纪要", text: "第一行\n第二行" }); + const changed = makeMaterialContentHash({ title: "纪要", text: "第一行\n内容已更新" }); + + assert.equal(first, same); + assert.notEqual(first, changed); +}); + +test("incremental message items merge by message id and honor deletions", () => { + const merged = mergeSourceItems( + [ + { id: "om_1", occurred_at: "2026-07-20T10:00:00Z", sender: "甲", content: "旧内容" }, + { id: "om_2", occurred_at: "2026-07-20T11:00:00Z", sender: "乙", content: "待删除" }, + ], + [ + { id: "om_1", occurred_at: "2026-07-20T10:00:00Z", sender: "甲", content: "更新内容" }, + { id: "om_2", deleted: true }, + { id: "om_3", occurred_at: "2026-07-20T12:00:00Z", sender: "乙", content: "新增内容" }, + ], + ); + + assert.deepEqual(merged.map((item) => item.id), ["om_1", "om_3"]); + assert.match(renderSourceItems(merged), /更新内容/); + assert.doesNotMatch(renderSourceItems(merged), /待删除/); +}); + diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/modelProvider.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/modelProvider.test.mjs new file mode 100644 index 00000000..5547499e --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/modelProvider.test.mjs @@ -0,0 +1,346 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ModelProvider } from "../src/providers/modelProvider.js"; + +function env(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + number(name, fallback) { + const parsed = Number(Object.hasOwn(values, name) ? values[name] : fallback); + return Number.isFinite(parsed) ? parsed : fallback; + }, + }; +} + +test("model timeout defaults to 90 seconds for structured generation", () => { + const provider = new ModelProvider({ env: env() }); + assert.equal(provider.timeoutMs, 90_000); +}); + +test("model timeout is configurable and bounded", () => { + assert.equal(new ModelProvider({ env: env({ MODEL_TIMEOUT_MS: "120000" }) }).timeoutMs, 120_000); + assert.equal(new ModelProvider({ env: env({ MODEL_TIMEOUT_MS: "1000" }) }).timeoutMs, 5_000); + assert.equal(new ModelProvider({ env: env({ MODEL_TIMEOUT_MS: "600000" }) }).timeoutMs, 300_000); +}); + +test("required function calls retry one transient upstream failure", async () => { + let callCount = 0; + const provider = new ModelProvider({ + env: env({ + AGENT_PLAN_API_KEY: "test-agent-plan-key", + MODEL_BASE_URL: "https://ark.example.test/api/plan/v3", + MODEL_NAME: "ark-code-latest", + MODEL_MAX_RETRIES: "1", + }), + sleep: async () => {}, + fetchImpl: async () => { + callCount += 1; + if (callCount === 1) { + return new Response(JSON.stringify({ + error: { code: "service_unavailable", message: "Service temporarily unavailable." }, + }), { status: 503, headers: { "Content-Type": "application/json" } }); + } + return new Response(JSON.stringify({ + id: "resp_function_retry", + status: "completed", + output: [{ + type: "function_call", + call_id: "call_retry", + name: "submit_sales_dossier", + arguments: "{\"summary\":\"ready\"}", + }], + }), { status: 200, headers: { "Content-Type": "application/json" } }); + }, + }); + + const result = await provider.callRequiredFunction({ + system: "Call the required tool.", + payload: { task: "probe" }, + functionName: "submit_sales_dossier", + functionDescription: "Submit dossier.", + parameters: { + type: "object", + additionalProperties: false, + properties: { summary: { type: "string" } }, + required: ["summary"], + }, + }); + + assert.equal(callCount, 2); + assert.equal(result.ok, true); + assert.equal(result.attempts, 2); + assert.deepEqual(result.parsed, { summary: "ready" }); +}); + +test("structured model calls use the Agent Plan Responses API and normalize usage", async () => { + let captured; + const provider = new ModelProvider({ + env: env({ + AGENT_PLAN_API_KEY: "test-agent-plan-key", + MODEL_BASE_URL: "https://ark.example.test/api/plan/v3", + MODEL_NAME: "ark-code-latest", + MODEL_RUN_ENABLED: "true", + }), + fetchImpl: async (url, options) => { + captured = { url, options, body: JSON.parse(options.body) }; + return new Response(JSON.stringify({ + id: "resp_test_1", + model: "glm-test", + output: [ + { + type: "message", + content: [{ type: "output_text", text: "{\"ok\":true,\"message\":\"ready\"}" }], + }, + ], + usage: { + input_tokens: 24, + output_tokens: 8, + total_tokens: 32, + output_tokens_details: { reasoning_tokens: 0 }, + }, + }), { status: 200, headers: { "Content-Type": "application/json" } }); + }, + }); + + const result = await provider.callJson({ + operation: "test", + system: "Only JSON.", + payload: { task: "probe" }, + maxTokens: 80, + }); + + assert.equal(captured.url, "https://ark.example.test/api/plan/v3/responses"); + assert.equal(captured.options.headers.Authorization, "Bearer test-agent-plan-key"); + assert.equal(captured.body.model, "ark-code-latest"); + assert.equal(captured.body.instructions, "Only JSON."); + assert.equal(captured.body.input, JSON.stringify({ task: "probe" })); + assert.equal(captured.body.max_output_tokens, 80); + assert.deepEqual(captured.body.thinking, { type: "disabled" }); + assert.deepEqual(captured.body.text, { format: { type: "json_object" } }); + assert.equal(Object.hasOwn(captured.body, "messages"), false); + assert.equal(Object.hasOwn(captured.body, "max_tokens"), false); + assert.equal(result.ok, true); + assert.deepEqual(result.parsed, { ok: true, message: "ready" }); + assert.deepEqual(result.usage, { + prompt_tokens: 24, + completion_tokens: 8, + total_tokens: 32, + reasoning_tokens: 0, + }); +}); + +test("structured model calls extract the first balanced JSON value from surrounding text", async () => { + const provider = new ModelProvider({ + env: env({ + AGENT_PLAN_API_KEY: "test-agent-plan-key", + MODEL_BASE_URL: "https://ark.example.test/api/plan/v3", + MODEL_NAME: "ark-code-latest", + MODEL_RUN_ENABLED: "true", + }), + fetchImpl: async () => new Response(JSON.stringify({ + id: "resp_balanced_json", + output_text: [ + "以下是结果:", + "```json", + "{\"message\":\"正文中的 } 和 ] 不应提前结束\",\"items\":[1,2]}", + "```", + "以上为结构化结果。", + ].join("\n"), + }), { status: 200, headers: { "Content-Type": "application/json" } }), + }); + + const result = await provider.callJson({ + operation: "test", + system: "Only JSON.", + payload: { task: "probe" }, + }); + + assert.equal(result.ok, true); + assert.deepEqual(result.parsed, { + message: "正文中的 } 和 ] 不应提前结束", + items: [1, 2], + }); +}); + +test("invalid structured output is retained only as bounded in-memory repair input", async () => { + const malformed = `{"title":"档案","body":[{"text":"未闭合`; + const provider = new ModelProvider({ + env: env({ + AGENT_PLAN_API_KEY: "test-agent-plan-key", + MODEL_BASE_URL: "https://ark.example.test/api/plan/v3", + MODEL_NAME: "ark-code-latest", + MODEL_RUN_ENABLED: "true", + }), + fetchImpl: async () => new Response(JSON.stringify({ + id: "resp_invalid_json", + output_text: malformed, + }), { status: 200, headers: { "Content-Type": "application/json" } }), + }); + + const result = await provider.callJson({ + operation: "test", + system: "Only JSON.", + payload: { task: "probe" }, + }); + + assert.equal(result.ok, false); + assert.equal(result.error.code, "invalid_json"); + assert.equal(result.invalid_content, malformed); + assert.equal(result.raw_ref, "model:resp_invalid_json"); +}); + +test("required function calls use a strict single-tool Responses contract", async () => { + let captured; + const provider = new ModelProvider({ + env: env({ + AGENT_PLAN_API_KEY: "test-agent-plan-key", + MODEL_BASE_URL: "https://ark.example.test/api/plan/v3", + MODEL_NAME: "ark-code-latest", + MODEL_RUN_ENABLED: "true", + }), + fetchImpl: async (url, options) => { + captured = { url, body: JSON.parse(options.body) }; + return new Response(JSON.stringify({ + id: "resp_function_1", + status: "completed", + model: "ark-code-latest", + output: [{ + type: "function_call", + call_id: "call_dossier_1", + name: "submit_sales_dossier", + arguments: "{\"summary\":\"ready\"}", + }], + usage: { + input_tokens: 40, + output_tokens: 12, + total_tokens: 52, + }, + }), { status: 200, headers: { "Content-Type": "application/json" } }); + }, + }); + const parameters = { + type: "object", + additionalProperties: false, + properties: { summary: { type: "string" } }, + required: ["summary"], + }; + + const result = await provider.callRequiredFunction({ + operation: "dossier_agent", + system: "Call the required tool.", + payload: { task: "probe" }, + functionName: "submit_sales_dossier", + functionDescription: "Submit dossier.", + parameters, + maxTokens: 900, + }); + + assert.equal(captured.url, "https://ark.example.test/api/plan/v3/responses"); + assert.equal(captured.body.store, false); + assert.equal(captured.body.tool_choice, "required"); + assert.equal(captured.body.text, undefined); + assert.deepEqual(captured.body.tools, [{ + type: "function", + name: "submit_sales_dossier", + description: "Submit dossier.", + strict: true, + parameters, + }]); + assert.equal(result.ok, true); + assert.deepEqual(result.parsed, { summary: "ready" }); + assert.equal(result.function_call_id, "call_dossier_1"); + assert.equal(result.raw_ref, "model:resp_function_1"); + assert.deepEqual(result.usage, { + prompt_tokens: 40, + completion_tokens: 12, + total_tokens: 52, + }); +}); + +test("required function calls reject incomplete responses before parsing output", async () => { + const provider = new ModelProvider({ + env: env({ + AGENT_PLAN_API_KEY: "test-agent-plan-key", + MODEL_BASE_URL: "https://ark.example.test/api/plan/v3", + MODEL_NAME: "ark-code-latest", + }), + fetchImpl: async () => new Response(JSON.stringify({ + id: "resp_function_incomplete", + status: "incomplete", + incomplete_details: { reason: "max_output_tokens" }, + output: [], + }), { status: 200, headers: { "Content-Type": "application/json" } }), + }); + + const result = await provider.callRequiredFunction({ + system: "Call the required tool.", + payload: { task: "probe" }, + functionName: "submit_sales_dossier", + functionDescription: "Submit dossier.", + parameters: { + type: "object", + additionalProperties: false, + properties: { summary: { type: "string" } }, + required: ["summary"], + }, + }); + + assert.equal(result.ok, false); + assert.equal(result.error.code, "incomplete_response"); + assert.equal(result.error.retryable, true); + assert.equal(result.raw_ref, "model:resp_function_incomplete"); +}); + +test("required function calls reject missing or malformed tool arguments", async () => { + const responses = [ + { + id: "resp_function_missing", + status: "completed", + output: [{ type: "message", content: [{ type: "output_text", text: "plain text" }] }], + }, + { + id: "resp_function_invalid", + status: "completed", + output: [{ + type: "function_call", + call_id: "call_invalid", + name: "submit_sales_dossier", + arguments: "{\"summary\":", + }], + }, + ]; + const provider = new ModelProvider({ + env: env({ + AGENT_PLAN_API_KEY: "test-agent-plan-key", + MODEL_BASE_URL: "https://ark.example.test/api/plan/v3", + MODEL_NAME: "ark-code-latest", + }), + fetchImpl: async () => new Response(JSON.stringify(responses.shift()), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + }); + const request = { + system: "Call the required tool.", + payload: { task: "probe" }, + functionName: "submit_sales_dossier", + functionDescription: "Submit dossier.", + parameters: { + type: "object", + additionalProperties: false, + properties: { summary: { type: "string" } }, + required: ["summary"], + }, + }; + + const missing = await provider.callRequiredFunction(request); + const invalid = await provider.callRequiredFunction(request); + + assert.equal(missing.ok, false); + assert.equal(missing.error.code, "missing_function_call"); + assert.equal(invalid.ok, false); + assert.equal(invalid.error.code, "invalid_function_arguments"); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/openVikingProvider.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/openVikingProvider.test.mjs new file mode 100644 index 00000000..3808ed55 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/openVikingProvider.test.mjs @@ -0,0 +1,258 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { OpenVikingProvider } from "../src/providers/openVikingProvider.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + number(name, fallback) { + return Object.hasOwn(values, name) ? Number(values[name]) : fallback; + }, + }; +} + +test("OpenViking timeout allows long resource ingestion and remains bounded", () => { + assert.equal(new OpenVikingProvider({ env: envReader() }).timeoutMs, 120_000); + assert.equal(new OpenVikingProvider({ env: envReader({ OPENVIKING_TIMEOUT_MS: "180000" }) }).timeoutMs, 180_000); + assert.equal(new OpenVikingProvider({ env: envReader({ OPENVIKING_TIMEOUT_MS: "1000" }) }).timeoutMs, 5_000); + assert.equal(new OpenVikingProvider({ env: envReader({ OPENVIKING_TIMEOUT_MS: "900000" }) }).timeoutMs, 300_000); +}); + +test("sales OpenViking URIs isolate workspace, company and source", () => { + const provider = new OpenVikingProvider({ + env: envReader({ OPENVIKING_SALES_ROOT_URI: "viking://resources/sales-root" }), + }); + + assert.equal( + provider.salesMaterialUri({ workspaceId: "Workspace A", companyId: "Company A", sourceId: "sync_123" }), + "viking://resources/sales-root/workspace-a/companies/company-a/materials/sync_123.md", + ); + assert.notEqual( + provider.salesCompanyUri({ workspaceId: "workspace-a", companyId: "company-a" }), + provider.salesCompanyUri({ workspaceId: "workspace-a", companyId: "company-b" }), + ); + assert.equal( + provider.salesDossierUri({ workspaceId: "Workspace A", companyId: "Company A", dossierId: "Dossier 1" }), + "viking://resources/sales-root/workspace-a/companies/company-a/dossiers/dossier-1.md", + ); + assert.equal( + provider.salesSessionId({ workspaceId: "Workspace A", companyId: "Company A" }), + "sales-workspace-a-company-a", + ); +}); + +test("text resource writes use explicit create and replace modes", async () => { + const calls = []; + const provider = new OpenVikingProvider({ + env: envReader({ + OPENVIKING_RUN_ENABLED: "true", + OPENVIKING_CLI: process.execPath, + }), + execFile: async (_command, args) => { + calls.push(args); + return { + stdout: JSON.stringify({ + ok: true, + result: { semantic_status: "queued", vector_status: "queued" }, + }), + stderr: "", + }; + }, + }); + const uri = provider.salesMaterialUri({ workspaceId: "workspace-a", companyId: "company-a", sourceId: "source-a" }); + + const created = await provider.upsertTextResource({ uri, content: "first", mode: "create" }); + const updated = await provider.upsertTextResource({ uri, content: "second", mode: "replace" }); + + assert.equal(created.ok, true); + assert.equal(updated.ok, true); + assert.equal(created.uri, uri); + assert.equal(created.processing_status, "queued"); + assert.deepEqual(calls[0], ["--agent-id", "default", "write", uri, "--content", "first", "--mode", "create", "-o", "json"]); + assert.deepEqual(calls[1], ["--agent-id", "default", "write", uri, "--content", "second", "--mode", "replace", "-o", "json"]); +}); + +test("text resource reads return canonical content from the official HTTP endpoint", async () => { + const calls = []; + const provider = new OpenVikingProvider({ + env: envReader({ + OPENVIKING_BASE_URL: "https://openviking.example", + OPENVIKING_API_KEY: "private-key", + }), + fetchImpl: async (url, options) => { + calls.push({ url, options }); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ result: { content: "# 飞书资料\n客户关注私有化部署。" } }); + }, + }; + }, + }); + + const result = await provider.readTextResource("viking://resources/company/material.md"); + + assert.equal(result.ok, true); + assert.equal(result.content, "# 飞书资料\n客户关注私有化部署。"); + assert.equal(calls[0].options.method, "GET"); + assert.equal(calls[0].options.body, undefined); + assert.match(calls[0].url, /\/api\/v1\/content\/read\?uri=/); + assert.match(calls[0].url, /raw=true$/); +}); + +test("company-scoped retrieval passes the exact subtree URI", async () => { + const calls = []; + const provider = new OpenVikingProvider({ + env: envReader({ OPENVIKING_CLI: process.execPath }), + execFile: async (_command, args) => { + calls.push(args); + return { stdout: JSON.stringify({ result: { resources: [] } }), stderr: "" }; + }, + }); + const uri = provider.salesCompanyUri({ workspaceId: "workspace-a", companyId: "company-a" }); + + const result = await provider.findMemories("预算", { uri, limit: 5 }); + + assert.equal(result.ok, true); + assert.deepEqual(calls[0], ["--agent-id", "default", "find", "预算", "--uri", uri, "--node-limit", "5", "-o", "json"]); +}); + +test("session capture follows the official create and per-message HTTP flow", async () => { + const calls = []; + const response = (status, payload) => ({ + ok: status >= 200 && status < 300, + status, + async text() { + return JSON.stringify(payload); + }, + }); + const provider = new OpenVikingProvider({ + env: envReader({ + OPENVIKING_RUN_ENABLED: "true", + OPENVIKING_BASE_URL: "https://openviking.example", + OPENVIKING_API_KEY: "private-key", + OPENVIKING_AGENT_ID: "sales-workbench", + }), + fetchImpl: async (url, options) => { + calls.push({ url, options }); + if (options.method === "GET") { + return response(404, { status: "error", error: { code: "NOT_FOUND", message: "Session not found" } }); + } + if (url.endsWith("/api/v1/sessions")) { + return response(200, { result: { session_id: "sales-workspace-company" } }); + } + return response(200, { result: { ok: true } }); + }, + }); + + const captured = await provider.addSessionMessages("sales-workspace-company", [ + { role: "user", content: "客户关注数据权限。" }, + { role: "assistant", content: "下一步确认权限边界。" }, + ]); + const committed = await provider.commitSession(captured.session_id); + const deleted = await provider.deleteSession(captured.session_id); + + assert.equal(captured.ok, true); + assert.equal(captured.created, true); + assert.equal(captured.session_id, "sales-workspace-company"); + assert.equal(committed.ok, true); + assert.equal(deleted.ok, true); + assert.equal(calls.some((call) => call.url.includes("/messages/batch")), false); + assert.deepEqual(JSON.parse(calls[1].options.body), { session_id: "sales-workspace-company" }); + assert.deepEqual(JSON.parse(calls[2].options.body), { + role: "user", + parts: [{ type: "text", text: "客户关注数据权限。" }], + }); + assert.deepEqual(JSON.parse(calls[3].options.body), { + role: "assistant", + parts: [{ type: "text", text: "下一步确认权限边界。" }], + }); + assert.deepEqual(JSON.parse(calls[4].options.body), { + telemetry: false, + keep_recent_count: 6, + }); + assert.equal(calls[5].options.method, "DELETE"); + assert.equal(calls[5].options.body, undefined); + assert.ok(calls.every((call) => call.options.headers["X-OpenViking-Agent"] === "sales-workbench")); + assert.ok(calls.every((call) => call.options.headers.Authorization === "Bearer private-key")); +}); + +test("session context restores normalized live messages and archive overview", async () => { + const calls = []; + const provider = new OpenVikingProvider({ + env: envReader({ + OPENVIKING_BASE_URL: "https://openviking.example", + OPENVIKING_API_KEY: "private-key", + }), + fetchImpl: async (url, options) => { + calls.push({ url, options }); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + result: { + latest_archive_overview: "客户持续关注数据权限。", + messages: [ + { + id: "message-1", + role: "user", + parts: [{ type: "text", text: "预算确认了吗?" }], + created_at: "2026-07-26T09:00:00.000Z", + }, + { + id: "message-2", + role: "assistant", + parts: [{ type: "text", text: "资料中尚未确认预算。" }], + created_at: "2026-07-26T09:00:01.000Z", + }, + ], + }, + }); + }, + }; + }, + }); + + const result = await provider.getSessionContext("sales-company-a", { tokenBudget: 2400 }); + + assert.equal(result.ok, true); + assert.equal(result.latest_archive_overview, "客户持续关注数据权限。"); + assert.deepEqual(result.messages.map(({ id, role, text }) => ({ id, role, text })), [ + { id: "message-1", role: "user", text: "预算确认了吗?" }, + { id: "message-2", role: "assistant", text: "资料中尚未确认预算。" }, + ]); + assert.equal(calls[0].options.method, "GET"); + assert.match(calls[0].url, /\/sessions\/sales-company-a\/context\?token_budget=2400$/); +}); + +test("local ovcli config supplies HTTP URL, API key and agent identity", () => { + const provider = new OpenVikingProvider({ + env: envReader(), + cliConfig: { + url: "https://api.vikingdb.cn-beijing.volces.com/openviking", + api_key: "local-private-key", + agent_id: "local-agent", + }, + }); + + assert.equal(provider.baseUrl, "https://api.vikingdb.cn-beijing.volces.com/openviking"); + assert.equal(provider.apiKey, "local-private-key"); + assert.equal(provider.agentId, "local-agent"); + assert.equal(provider.isConfigured(), true); +}); + +test("Agent Plan key is not reused as OpenViking data-plane authentication", () => { + const provider = new OpenVikingProvider({ + env: envReader({ + AGENT_PLAN_API_KEY: "agent-plan-key", + OPENVIKING_BASE_URL: "https://api.vikingdb.cn-beijing.volces.com/openviking", + }), + cliConfig: {}, + }); + + assert.equal(provider.apiKey, ""); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/openVikingQaBoundary.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/openVikingQaBoundary.test.mjs new file mode 100644 index 00000000..8c640dfe --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/openVikingQaBoundary.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { WORKSPACE_TABLE_SPECS, RESTORE_ORDER } from "../src/backup/supabaseBackup.js"; +import { SupabaseDataRepository } from "../src/repositories/supabaseDataRepository.js"; + +const backendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const rootDir = path.resolve(backendDir, ".."); +const migration = await fs.readFile( + path.join(rootDir, "supabase", "migrations", "202607280001_openviking_qa_boundary.sql"), + "utf8", +); + +test("OpenViking QA boundary is delivered as a non-destructive forward migration", () => { + assert.match(migration, /rename to sales_qa_messages_legacy/i); + assert.match(migration, /revoke all[\s\S]*?from public, anon, authenticated/i); + assert.match(migration, /grant all[\s\S]*?to service_role/i); + assert.match(migration, /values \('202607280001'/); + assert.doesNotMatch(migration, /drop table|delete from|truncate/i); +}); + +test("Supabase backup and restore never carry legacy QA message bodies", () => { + assert.equal(WORKSPACE_TABLE_SPECS.some(({ table }) => table === "sales_qa_messages"), false); + assert.equal(WORKSPACE_TABLE_SPECS.some(({ table }) => table === "sales_qa_messages_legacy"), false); + assert.equal(RESTORE_ORDER.includes("sales_qa_messages"), false); + assert.equal(RESTORE_ORDER.includes("sales_qa_messages_legacy"), false); +}); + +test("Supabase Data API repository exposes no QA body persistence method", () => { + assert.equal(Object.hasOwn(SupabaseDataRepository.prototype, "persistSalesQaMessage"), false); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/paidWorkflowGuard.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/paidWorkflowGuard.test.mjs new file mode 100644 index 00000000..f998b16f --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/paidWorkflowGuard.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { PaidWorkflowGuard, paidWorkflowLimits } from "../src/limits/paidWorkflowGuard.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +function job(id, type = "sales_dossier_generation") { + const createdAt = new Date().toISOString(); + return { + id, + job_type: type, + status: "running", + attempt_count: 1, + max_attempts: 2, + started_at: createdAt, + created_at: createdAt, + updated_at: createdAt, + is_paid: true, + }; +} + +test("paid workflow limits use safe strict-runtime defaults", () => { + assert.deepEqual(paidWorkflowLimits(envReader()), { + max_concurrent: 2, + daily_limit: 100, + timezone: "Asia/Shanghai", + stale_after_seconds: 1800, + }); +}); + +test("local guard rejects excess concurrency and releases the slot on completion", async () => { + const guard = new PaidWorkflowGuard({ + env: envReader({ + PAID_WORKFLOW_MAX_CONCURRENCY: "1", + PAID_WORKFLOW_DAILY_LIMIT: "10", + PAID_WORKFLOW_BUDGET_TIMEZONE: "UTC", + PAID_WORKFLOW_STALE_AFTER_SECONDS: "3600", + }), + }); + + const first = await guard.reserve(job("job-1")); + assert.equal(first.budget.running, 1); + await assert.rejects( + () => guard.reserve(job("job-2")), + (error) => error.status === 429 && error.code === "paid_workflow_concurrency_exceeded", + ); + + await guard.finish({ ...first.job, status: "succeeded", finished_at: new Date().toISOString() }); + const second = await guard.reserve(job("job-2")); + assert.equal(second.budget.running, 1); + assert.equal(second.budget.used_today, 2); +}); + +test("local guard counts every paid attempt against the daily limit", async () => { + const guard = new PaidWorkflowGuard({ + env: envReader({ + PAID_WORKFLOW_MAX_CONCURRENCY: "2", + PAID_WORKFLOW_DAILY_LIMIT: "1", + PAID_WORKFLOW_BUDGET_TIMEZONE: "UTC", + }), + }); + const first = await guard.reserve(job("job-1", "sales_company_search")); + await guard.finish({ ...first.job, status: "failed", finished_at: new Date().toISOString() }); + + await assert.rejects( + () => guard.reserve(job("job-2", "sales_qa")), + (error) => error.status === 429 && error.code === "paid_workflow_daily_limit_exceeded", + ); + const snapshot = await guard.snapshot(); + assert.equal(snapshot.used_today, 1); + assert.equal(snapshot.by_job_type.sales_company_search, 1); +}); + +test("runtime delegates reservation and completion to persistent repository RPCs", async () => { + const calls = []; + const repository = { + async reservePaidWorkflow(candidate, reservationId, limits) { + calls.push({ operation: "reserve", candidate, reservationId, limits }); + return { job: candidate, budget: { running: 1, used_today: 1 } }; + }, + async finishPaidWorkflow(candidate, reservationId) { + calls.push({ operation: "finish", candidate, reservationId }); + return candidate; + }, + async getPaidWorkflowUsage(timezone) { + calls.push({ operation: "snapshot", timezone }); + return { running: 0, used_today: 1, by_job_type: { sales_qa: 1 } }; + }, + }; + const guard = new PaidWorkflowGuard({ env: envReader(), repository, failClosed: true }); + const reservation = await guard.reserve(job("job-prod", "sales_qa")); + await guard.finish({ ...reservation.job, status: "succeeded", finished_at: new Date().toISOString() }); + const snapshot = await guard.snapshot(); + + assert.deepEqual(calls.map((call) => call.operation), ["reserve", "finish", "snapshot"]); + assert.match(reservation.job.reservation_id, /^usage_reservation_/); + assert.equal(snapshot.daily_limit, 100); + assert.equal(snapshot.by_job_type.sales_qa, 1); +}); + +test("runtime fails closed when the persistent reservation capability is missing", async () => { + const guard = new PaidWorkflowGuard({ env: envReader(), repository: {}, failClosed: true }); + await assert.rejects( + () => guard.reserve(job("job-prod")), + (error) => error.status === 503 && error.code === "usage_guard_unavailable", + ); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/providerCircuitBreaker.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/providerCircuitBreaker.test.mjs new file mode 100644 index 00000000..064b893c --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/providerCircuitBreaker.test.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ProviderCircuitBreaker } from "../src/limits/providerCircuitBreaker.js"; + +const retryableFailure = { + code: "timeout", + category: "timeout", + retryable: true, +}; + +test("provider circuit opens after repeated retryable failures and recovers after one probe", () => { + let now = 1_000; + const breaker = new ProviderCircuitBreaker({ + enabled: true, + failureThreshold: 2, + cooldownSeconds: 10, + now: () => now, + }); + + const first = breaker.beforeCall("model"); + breaker.recordFailure(first, retryableFailure); + assert.equal(breaker.snapshot()[0].open, false); + + const second = breaker.beforeCall("model"); + breaker.recordFailure(second, retryableFailure); + assert.equal(breaker.snapshot()[0].open, true); + assert.throws( + () => breaker.beforeCall("model"), + (error) => error.code === "provider_circuit_open" && error.retry_after_seconds === 10, + ); + + now += 10_000; + const probe = breaker.beforeCall("model"); + assert.equal(probe.halfOpen, true); + assert.throws(() => breaker.beforeCall("model"), /temporarily unavailable/); + breaker.recordSuccess(probe); + + assert.deepEqual(breaker.snapshot(), []); + assert.equal(breaker.beforeCall("model").halfOpen, false); +}); + +test("half-open retryable failure reopens the circuit", () => { + let now = 2_000; + const breaker = new ProviderCircuitBreaker({ + enabled: true, + failureThreshold: 1, + cooldownSeconds: 5, + now: () => now, + }); + + const initial = breaker.beforeCall("datapro"); + breaker.recordFailure(initial, retryableFailure); + now += 5_000; + const probe = breaker.beforeCall("datapro"); + breaker.recordFailure(probe, retryableFailure); + + const state = breaker.snapshot()[0]; + assert.equal(state.open, true); + assert.equal(state.retry_after_seconds, 5); +}); + +test("configuration and validation failures do not open the provider circuit", () => { + const breaker = new ProviderCircuitBreaker({ + enabled: true, + failureThreshold: 1, + cooldownSeconds: 5, + }); + + const token = breaker.beforeCall("web_search"); + breaker.recordFailure(token, { + code: "missing_config", + category: "configuration", + retryable: false, + }); + + assert.deepEqual(breaker.snapshot(), []); + assert.equal(breaker.beforeCall("web_search").halfOpen, false); +}); + +test("a non-retryable response resets the consecutive retryable failure count", () => { + const breaker = new ProviderCircuitBreaker({ + enabled: true, + failureThreshold: 2, + cooldownSeconds: 5, + }); + + const first = breaker.beforeCall("openviking"); + breaker.recordFailure(first, retryableFailure); + const validation = breaker.beforeCall("openviking"); + breaker.recordFailure(validation, { + code: "validation_error", + category: "validation", + retryable: false, + }); + const next = breaker.beforeCall("openviking"); + breaker.recordFailure(next, retryableFailure); + + const state = breaker.snapshot()[0]; + assert.equal(state.consecutive_failures, 1); + assert.equal(state.open, false); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/providerResult.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/providerResult.test.mjs new file mode 100644 index 00000000..4092e5e7 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/providerResult.test.mjs @@ -0,0 +1,252 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { WebSearchProvider } from "../src/providers/webSearchProvider.js"; +import { classifyProviderError, executeProviderCall, providerFailure } from "../src/providers/providerResult.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + number(name, fallback = 0) { + const value = Number(Object.hasOwn(values, name) ? values[name] : fallback); + return Number.isFinite(value) ? value : fallback; + }, + }; +} + +test("provider errors use stable categories and retryability", () => { + assert.deepEqual(classifyProviderError({ code: "timeout" }), { category: "timeout", retryable: true }); + assert.deepEqual(classifyProviderError({ code: "missing_config" }), { category: "configuration", retryable: false }); + assert.deepEqual(classifyProviderError({ code: "4003" }), { category: "validation", retryable: false }); + assert.deepEqual(classifyProviderError({ code: "invalid_query" }), { category: "validation", retryable: false }); + assert.deepEqual(classifyProviderError({ http_status: 401 }), { category: "authentication", retryable: false }); + assert.deepEqual( + classifyProviderError({ code: "10500", message: "Internal Error" }), + { category: "upstream", retryable: true }, + ); + const failure = providerFailure("model", { code: "network_error", message: "connection reset" }); + assert.equal(failure.provider, "model"); + assert.equal(failure.provider_mode, "real"); + assert.equal(failure.error.category, "network"); + assert.equal(failure.error.retryable, true); +}); + +test("retry helper retries only retryable failures", async () => { + let calls = 0; + const result = await executeProviderCall(async () => { + calls += 1; + if (calls === 1) return providerFailure("web_search", { code: "network_error", message: "temporary" }); + return { ok: true, provider: "web_search", provider_mode: "real" }; + }, { max_retries: 1, sleep: async () => {} }); + + assert.equal(calls, 2); + assert.equal(result.ok, true); + assert.equal(result.attempts, 2); +}); + +test("web search retries a transient network failure once", async () => { + let calls = 0; + const retryDelays = []; + const provider = new WebSearchProvider({ + env: envReader({ + WEB_SEARCH_API_KEY: "test-key", + WEB_SEARCH_MAX_RETRIES: "1", + WEB_SEARCH_TIMEOUT_MS: "1000", + }), + fetchImpl: async () => { + calls += 1; + if (calls === 1) throw new Error("temporary network failure"); + return { + ok: true, + status: 200, + async json() { + return { + ResponseMetadata: { RequestId: "request-1" }, + Result: { ResultCount: 0, WebResults: [] }, + }; + }, + }; + }, + sleep: async (milliseconds) => { + retryDelays.push(milliseconds); + }, + }); + + const result = await provider.search({ query: "测试查询", count: 1 }); + assert.equal(result.ok, true); + assert.equal(result.attempts, 2); + assert.equal(calls, 2); + assert.deepEqual(retryDelays, [2500]); +}); + +test("web search retries the official 10500 temporary-unavailable response once", async () => { + let calls = 0; + const retryDelays = []; + const provider = new WebSearchProvider({ + env: envReader({ + WEB_SEARCH_API_KEY: "test-key", + WEB_SEARCH_MAX_RETRIES: "1", + WEB_SEARCH_TIMEOUT_MS: "1000", + }), + fetchImpl: async () => { + calls += 1; + return { + ok: true, + status: 200, + async json() { + return { + ResponseMetadata: { + RequestId: `request-${calls}`, + Error: { + Code: "10500", + Message: "Ark AgentPlan service is temporarily unavailable. Please retry later.", + }, + }, + }; + }, + }; + }, + sleep: async (milliseconds) => { + retryDelays.push(milliseconds); + }, + }); + + const result = await provider.search({ query: "测试查询", count: 1 }); + assert.equal(result.ok, false); + assert.equal(result.error.code, "10500"); + assert.equal(result.error.retryable, true); + assert.equal(result.attempts, 2); + assert.equal(calls, 2); + assert.deepEqual(retryDelays, [2500]); +}); + +test("web search retries a 10500 Internal Error response once", async () => { + let calls = 0; + const provider = new WebSearchProvider({ + env: envReader({ + WEB_SEARCH_API_KEY: "test-key", + WEB_SEARCH_MAX_RETRIES: "1", + WEB_SEARCH_TIMEOUT_MS: "1000", + }), + fetchImpl: async () => { + calls += 1; + if (calls === 1) { + return { + ok: true, + status: 200, + async json() { + return { + ResponseMetadata: { + RequestId: "request-internal-error", + Error: { Code: "10500", Message: "Internal Error" }, + }, + }; + }, + }; + } + return { + ok: true, + status: 200, + async json() { + return { + ResponseMetadata: { RequestId: "request-recovered" }, + Result: { ResultCount: 0, WebResults: [] }, + }; + }, + }; + }, + sleep: async () => {}, + }); + + const result = await provider.search({ query: "测试查询", count: 1 }); + assert.equal(result.ok, true); + assert.equal(result.attempts, 2); + assert.equal(calls, 2); +}); + +test("web search sends official authority filter and query rewrite fields", async () => { + let requestBody = null; + let requestHeaders = null; + const provider = new WebSearchProvider({ + env: envReader({ + WEB_SEARCH_API_KEY: "test-key", + WEB_SEARCH_MAX_RETRIES: "0", + WEB_SEARCH_TIMEOUT_MS: "1000", + }), + fetchImpl: async (_url, options) => { + requestBody = JSON.parse(options.body); + requestHeaders = options.headers; + return { + ok: true, + status: 200, + async json() { + return { + ResponseMetadata: { RequestId: "request-2" }, + Result: { ResultCount: 0, WebResults: [] }, + }; + }, + }; + }, + }); + + const result = await provider.search({ + query: "权威来源测试", + count: 3, + auth_level: 1, + query_rewrite: true, + }); + + assert.equal(result.ok, true); + assert.deepEqual(requestBody.Filter, { AuthInfoLevel: 1 }); + assert.deepEqual(requestBody.QueryControl, { QueryRewrite: true }); + assert.equal(Object.hasOwn(requestBody, "AuthLevel"), false); + assert.equal(requestHeaders["X-Traffic-Tag"], "skill_web_search_common"); +}); + +test("web search cleans structured titles and discards epoch publish times", async () => { + const provider = new WebSearchProvider({ + env: envReader({ + WEB_SEARCH_API_KEY: "test-key", + WEB_SEARCH_MAX_RETRIES: "0", + WEB_SEARCH_TIMEOUT_MS: "1000", + }), + fetchImpl: async () => ({ + ok: true, + status: 200, + async json() { + return { + ResponseMetadata: { RequestId: "request-clean" }, + Result: { + ResultCount: 3, + WebResults: [ + { + Title: "--- title: 比亚迪与合作伙伴发布新项目 source: 示例网 datetime: 2026-07-20", + Url: "https://news.example.org/byd", + Summary: " 比亚迪发布合作动态。\n", + PublishTime: 0, + }, + { + Title: "正常标题", + Url: "https://news.example.org/current", + PublishTime: 1784505600, + }, + { + Title: "没有日期的旧结果", + Url: "https://news.example.org/epoch", + PublishTime: 0, + }, + ], + }, + }; + }, + }), + }); + + const result = await provider.search({ query: "比亚迪 最新合作", count: 2 }); + assert.equal(result.results[0].title, "比亚迪与合作伙伴发布新项目"); + assert.equal(result.results[0].summary, "比亚迪发布合作动态。"); + assert.equal(result.results[0].publish_time, "2026-07-20T00:00:00.000Z"); + assert.equal(result.results[1].publish_time, "2026-07-20T00:00:00.000Z"); + assert.equal(result.results[2].publish_time, null); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/providerRunStore.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/providerRunStore.test.mjs new file mode 100644 index 00000000..bfa3bd90 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/providerRunStore.test.mjs @@ -0,0 +1,180 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ProviderCircuitBreaker } from "../src/limits/providerCircuitBreaker.js"; +import { ProviderRunStore } from "../src/observability/providerRunStore.js"; +import { SalesService } from "../src/services/salesService.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +test("provider run records redact secrets and retain safe usage metadata", async () => { + const store = new ProviderRunStore(); + const run = await store.startRun({ operation: "test" }); + + await store.executeStep(run.id, { + provider: "model", + operation: "probe", + input_summary: "Authorization: Bearer fake", + output_summary: "Probe completed.", + }, async () => ({ + ok: true, + request_id: "request-1", + raw_ref: "model:request-1", + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + })); + await store.completeRun(run.id, { result_ref: "result:1" }); + + const saved = await store.get(run.id); + assert.equal(saved.status, "succeeded"); + assert.match(saved.steps[0].input_summary, /\[REDACTED\]/); + assert.equal(saved.steps[0].usage.total_tokens, 15); + assert.equal(saved.steps[0].raw_ref, "model:request-1"); + assert.equal(saved.app_mode, "production"); +}); + +test("sales service provider run APIs expose diagnostics without internal references", async () => { + const store = new ProviderRunStore(); + const service = new SalesService({ + env: envReader(), + runtimePolicy: { fail_closed: false }, + providerRunStore: store, + }); + const run = await store.startRun({ + operation: "public_provider_run", + app_mode: "production", + entity_type: "target_enterprise", + entity_id: "company-1", + }); + await store.executeStep(run.id, { + provider: "model", + operation: "generate", + input_summary: "Generate a report.", + }, async () => ({ + ok: true, + request_id: "request-private", + raw_ref: "model:request-private", + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + })); + await store.completeRun(run.id, { result_ref: "dossier:private" }); + + const detail = await service.getProviderRun(run.id); + const listed = await service.listProviderRuns({ operation: "public_provider_run" }); + for (const publicRun of [detail, listed[0]]) { + assert.equal(publicRun.id, run.id); + assert.equal(publicRun.steps[0].provider, "model"); + assert.equal(publicRun.steps[0].usage.total_tokens, 15); + assert.equal(Object.hasOwn(publicRun, "result_ref"), false); + assert.equal(Object.hasOwn(publicRun, "app_mode"), false); + assert.equal(Object.hasOwn(publicRun.steps[0], "request_id"), false); + assert.equal(Object.hasOwn(publicRun.steps[0], "raw_ref"), false); + } +}); + +test("provider run failure retains bounded redacted validation diagnostics", async () => { + const store = new ProviderRunStore(); + const run = await store.startRun({ operation: "validation_failure", app_mode: "production" }); + + await store.failRun(run.id, { + code: "model_unavailable", + message: "Dossier validation failed.", + category: "workflow", + details: { + validation_errors: [ + "经营与业务动态必须优先引用语义匹配的专业数据库", + "Bearer private-token", + ], + }, + }); + + const saved = await store.get(run.id); + assert.deepEqual(saved.error.validation_errors, [ + "经营与业务动态必须优先引用语义匹配的专业数据库", + "Bearer [REDACTED]", + ]); +}); + +test("provider runs can be reloaded from a persistent repository", async () => { + const saved = new Map(); + const repository = { + persistProviderRun(run) { + saved.set(run.id, structuredClone(run)); + return run; + }, + getProviderRun(runId) { + return saved.has(runId) ? structuredClone(saved.get(runId)) : null; + }, + listProviderRuns() { + return [...saved.values()].map((run) => structuredClone(run)); + }, + }; + const firstStore = new ProviderRunStore({ repository, failOnPersistenceError: true }); + const run = await firstStore.startRun({ operation: "persistent_test", entity_id: "company-1" }); + const step = await firstStore.startStep(run.id, { provider: "supabase", operation: "persist" }); + await firstStore.finishStep(run.id, step.id, { ok: true, usage: { total_tokens: 0 } }); + await firstStore.completeRun(run.id, { result_ref: "result:company-1" }); + + const secondStore = new ProviderRunStore({ repository, failOnPersistenceError: true }); + assert.equal((await secondStore.get(run.id)).status, "succeeded"); + assert.equal((await secondStore.get(run.id)).steps.length, 1); + assert.equal((await secondStore.list({ operation: "persistent_test" }))[0].id, run.id); +}); + +test("provider run start fails closed when required persistence is unavailable", async () => { + const store = new ProviderRunStore({ + repository: { + persistProviderRun() { + throw new Error("database unavailable"); + }, + }, + failOnPersistenceError: true, + }); + + await assert.rejects(() => store.startRun({ operation: "must_persist" }), /database unavailable/); +}); + +test("provider run store blocks an open circuit before another upstream call", async () => { + let calls = 0; + const store = new ProviderRunStore({ + circuitBreaker: new ProviderCircuitBreaker({ + enabled: true, + failureThreshold: 1, + cooldownSeconds: 30, + }), + }); + const run = await store.startRun({ operation: "circuit_test" }); + const operation = async () => { + calls += 1; + return { + ok: false, + error: { code: "timeout", category: "timeout", retryable: true }, + }; + }; + + await store.executeStep(run.id, { provider: "model", operation: "generate" }, operation); + await assert.rejects( + () => store.executeStep(run.id, { provider: "model", operation: "generate" }, operation), + (error) => error.code === "provider_circuit_open", + ); + + const saved = await store.get(run.id); + assert.equal(calls, 1); + assert.equal(saved.steps.length, 2); + assert.equal(saved.steps[1].error.code, "provider_circuit_open"); +}); + +test("cancelling a provider run also closes its running step", async () => { + const store = new ProviderRunStore(); + const run = await store.startRun({ operation: "cancel_test" }); + await store.startStep(run.id, { provider: "web_search", operation: "search" }); + + const cancelled = await store.cancelRun(run.id, { summary: "User cancelled the task." }); + assert.equal(cancelled.status, "cancelled"); + assert.ok(cancelled.finished_at); + assert.equal(cancelled.steps[0].status, "cancelled"); + assert.equal(cancelled.steps[0].output_summary, "User cancelled the task."); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/publicDocumentation.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/publicDocumentation.test.mjs new file mode 100644 index 00000000..a4aeb87d --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/publicDocumentation.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(testDir, "../.."); +const docsRoot = path.join(projectRoot, "docs"); +const hasPublicDocs = fs.existsSync(path.join(docsRoot, "api", "api-contract.md")); + +function read(relativePath) { + return fs.readFileSync(path.join(projectRoot, relativePath), "utf8"); +} + +test("public API contract documents the current sales workbench only", { + skip: !hasPublicDocs, +}, () => { + const contract = read("docs/api/api-contract.md"); + assert.match(contract, /\/api\/sales-goals/); + assert.match(contract, /\/api\/target-enterprises/); + assert.doesNotMatch(contract, /\/api\/change-cards/); + assert.doesNotMatch(contract, /competitive-change-card/i); +}); + +test("public documentation points to versioned migrations and current authentication", { + skip: !hasPublicDocs, +}, () => { + const index = read("docs/README.md"); + const schema = read("docs/database/supabase-schema.md"); + const security = read("SECURITY.md"); + + assert.doesNotMatch(index, /supabase-schema\.sql/); + assert.match(schema, /supabase\/migrations\//); + assert.doesNotMatch(schema, /docs\/open-source\//); + assert.match(security, /Supabase Auth/); + assert.doesNotMatch(security, /does not yet include HTTP user authentication/i); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/releaseSecretScan.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/releaseSecretScan.test.mjs new file mode 100644 index 00000000..d6cdcb22 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/releaseSecretScan.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { scanReleaseTree, scanTextForSecrets } from "../scripts/check-release-secrets.mjs"; + +test("release secret scan accepts empty examples and does not expose matched values", () => { + assert.deepEqual(scanTextForSecrets("AGENT_PLAN_API_KEY=\nSUPABASE_SERVICE_ROLE_KEY=<your-key>\n", ".env.example"), []); + + const synthetic = ["ark", "aaaaaaaa", "bbbb", "cccc", "dddd", "eeeeeeeeeeee", "ffff"].join("-"); + const findings = scanTextForSecrets(`AGENT_PLAN_API_KEY=${synthetic}\n`, "unsafe.env"); + assert.ok(findings.some((finding) => finding.rule === "agent_plan_api_key")); + assert.ok(findings.some((finding) => finding.rule === "configured_agent_plan_api_key")); + assert.equal(JSON.stringify(findings).includes(synthetic), false); +}); + +test("release tree scan catches private config files and skips ignored dependency folders", async (context) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "sales-release-secret-scan-")); + context.after(() => fs.rm(root, { recursive: true, force: true })); + + await fs.mkdir(path.join(root, "node_modules")); + await fs.writeFile(path.join(root, ".env.example"), "AGENT_PLAN_API_KEY=<your-key>\n"); + await fs.writeFile(path.join(root, ".env"), "AGENT_PLAN_API_KEY=synthetic-secret-value\n"); + await fs.writeFile(path.join(root, "node_modules", ".env"), "ignored=true\n"); + + const findings = await scanReleaseTree(root); + assert.deepEqual(findings, [{ rule: "forbidden_secret_file", path: ".env" }]); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/runtimePolicy.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/runtimePolicy.test.mjs new file mode 100644 index 00000000..b89b42d9 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/runtimePolicy.test.mjs @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createRuntimePolicy } from "../src/config/runtimePolicy.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + hasAny(names) { + return names.some((name) => Boolean(this.value(name))); + }, + hasAll(names) { + return names.every((name) => Boolean(this.value(name))); + }, + }; +} + +const readyConfiguration = Object.freeze({ + REPOSITORY_MODE: "supabase", + SUPABASE_READ_ONLY: "false", + SUPABASE_API_URL: "https://supabase.example.test", + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", + APP_WORKSPACE_ID: "54768bef-53aa-47d0-a9e3-bbca4593cf58", + HTTP_AUTH_ENABLED: "true", + AGENT_PLAN_API_KEY: "test-key", + DATAPRO_RUN_ENABLED: "true", + WEB_SEARCH_RUN_ENABLED: "true", + MODEL_RUN_ENABLED: "true", + OPENVIKING_BASE_URL: "https://openviking.example.test", + OPENVIKING_API_KEY: "test-openviking-key", + OPENVIKING_RUN_ENABLED: "true", +}); + +test("missing real storage and providers block readiness", () => { + const policy = createRuntimePolicy({ + env: envReader({ + REPOSITORY_MODE: "memory", + }), + }); + + assert.equal(policy.ready, false); + assert.equal(policy.fail_closed, true); + assert.match(policy.blockers.join(" | "), /REPOSITORY_MODE must be supabase/); + assert.match(policy.blockers.join(" | "), /DataPro/); + assert.match(policy.blockers.join(" | "), /web search/); + assert.match(policy.blockers.join(" | "), /model provider/); +}); + +test("fully configured runtime is structurally ready", () => { + const policy = createRuntimePolicy({ env: envReader(readyConfiguration) }); + assert.equal(policy.ready, true); + assert.deepEqual(policy.blockers, []); +}); + +test("authentication and paid-workflow protections are mandatory", () => { + const policy = createRuntimePolicy({ + env: envReader({ + ...readyConfiguration, + HTTP_AUTH_ENABLED: "false", + PAID_WORKFLOW_MAX_CONCURRENCY: "0", + PAID_WORKFLOW_DAILY_LIMIT: "0", + PAID_WORKFLOW_STALE_AFTER_SECONDS: "0", + PAID_WORKFLOW_BUDGET_TIMEZONE: "Mars/Olympus", + }), + }); + const blockers = policy.blockers.join(" | "); + assert.match(blockers, /HTTP_AUTH_ENABLED must be true/); + assert.match(blockers, /PAID_WORKFLOW_MAX_CONCURRENCY/); + assert.match(blockers, /PAID_WORKFLOW_DAILY_LIMIT/); + assert.match(blockers, /PAID_WORKFLOW_STALE_AFTER_SECONDS/); + assert.match(blockers, /PAID_WORKFLOW_BUDGET_TIMEZONE/); +}); + +test("the persistent worker queue and circuit breaker are mandatory", () => { + const policy = createRuntimePolicy({ + env: envReader({ + ...readyConfiguration, + ASYNC_JOBS_ENABLED: "false", + JOB_WORKER_LEASE_SECONDS: "30", + PROVIDER_CIRCUIT_BREAKER_ENABLED: "false", + PROVIDER_CIRCUIT_BREAKER_FAILURE_THRESHOLD: "0", + PROVIDER_CIRCUIT_BREAKER_COOLDOWN_SECONDS: "0", + }), + }); + const blockers = policy.blockers.join(" | "); + assert.match(blockers, /ASYNC_JOBS_ENABLED must be true/); + assert.match(blockers, /JOB_WORKER_LEASE_SECONDS must be at least 60/); + assert.match(blockers, /PROVIDER_CIRCUIT_BREAKER_ENABLED must be true/); + assert.match(blockers, /PROVIDER_CIRCUIT_BREAKER_FAILURE_THRESHOLD/); + assert.match(blockers, /PROVIDER_CIRCUIT_BREAKER_COOLDOWN_SECONDS/); +}); + +test("proxied deployments require secure cookies and explicit HTTPS origins", () => { + const unsafe = createRuntimePolicy({ + env: envReader({ + ...readyConfiguration, + TRUST_PROXY: "true", + AUTH_COOKIE_SECURE: "false", + ALLOWED_ORIGINS: "http://sales.example.test", + }), + }); + const blockers = unsafe.blockers.join(" | "); + assert.match(blockers, /AUTH_COOKIE_SECURE=true/); + assert.match(blockers, /HTTPS ALLOWED_ORIGINS/); + + const safe = createRuntimePolicy({ + env: envReader({ + ...readyConfiguration, + TRUST_PROXY: "true", + AUTH_COOKIE_SECURE: "true", + ALLOWED_ORIGINS: "https://sales.example.test", + }), + }); + assert.equal(safe.ready, true); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesCompanySearch.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesCompanySearch.test.mjs new file mode 100644 index 00000000..514f2c04 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesCompanySearch.test.mjs @@ -0,0 +1,237 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SalesService } from "../src/services/salesService.js"; + +const permissiveTestPolicy = Object.freeze({ + fail_closed: false, +}); + +const strictRuntimePolicy = Object.freeze({ + fail_closed: true, +}); + +function emptyState() { + return { + goals: [], + companies: {}, + dossiers: {}, + materials: {}, + qa_messages: {}, + sync_sources: {}, + sync_checkpoints: {}, + jobs: {}, + }; +} + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +function createRepository() { + const persistedCompanies = []; + const jobs = new Map(); + return { + persistedCompanies, + async getSalesState() { + return emptyState(); + }, + async persistSalesGoal() {}, + async persistSalesSearchResults() {}, + async persistSalesCompany(company) { + persistedCompanies.push(structuredClone(company)); + }, + async persistJob(job) { + jobs.set(job.id, structuredClone(job)); + }, + async reservePaidWorkflow(job, reservationId) { + const reserved = { ...structuredClone(job), reservation_id: reservationId, is_paid: true }; + jobs.set(reserved.id, reserved); + return { job: reserved, budget: { running: 1, used_today: jobs.size } }; + }, + async finishPaidWorkflow(job) { + jobs.set(job.id, structuredClone(job)); + return structuredClone(job); + }, + async getJob(jobId) { + return jobs.has(jobId) ? structuredClone(jobs.get(jobId)) : null; + }, + async persistProviderRun() {}, + }; +} + +function webProvider() { + return { + isRunEnabled: () => true, + async search() { + return { + ok: true, + results: [{ + title: "测试企业官网动态", + summary: "测试企业发布了最新业务公告。", + url: "https://company.test/news", + }], + }; + }, + }; +} + +test("company search uses structured DataPro identities and deduplicates repeated searches", async () => { + const repository = createRepository(); + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: emptyState(), + repository, + dataProProvider: { + isRunEnabled: () => true, + async callTool() { + return { + ok: true, + raw_ref: "datapro:trace-company-search", + parsed: { + code: 0, + data: { + items: [{ + 公司名称: "北京测试科技有限公司", + 统一社会信用代码: "91110000TEST000001", + 法定代表人: "张三", + 注册资本: "1000万元人民币", + 企业状态: "存续", + 所属行业: "企业软件", + 注册地址: "北京市海淀区测试路1号", + 成立日期: "2020-01-02", + 经营范围: "软件开发与技术服务。", + }], + }, + }, + summary: "公司名称:北京测试科技有限公司;统一社会信用代码:91110000TEST000001", + }; + }, + }, + webSearchProvider: webProvider(), + }); + await service.assertRuntimeReady(); + const goal = await service.createGoal({ name: "测试销售目标" }); + + const first = await service.searchCompanies(goal.id, { query: "测试科技" }); + const second = await service.searchCompanies(goal.id, { query: "测试科技" }); + + assert.equal(first.length, 1); + assert.equal(first[0].name, "北京测试科技有限公司"); + assert.equal(first[0].identity_status, "verified"); + assert.equal(first[0].unified_social_credit_code, "91110000TEST000001"); + assert.equal(first[0].legal_representative, "张三"); + assert.equal(first[0].registered_capital, "1000万元人民币"); + assert.equal(first[0].location, "北京市"); + assert.match(first[0].reason, /专业数据集已核验/); + assert.equal(second[0].id, first[0].id); + assert.equal(Object.keys(service.data.companies).length, 1); + assert.ok(first[0].id.startsWith("company_dp_")); + assert.equal(repository.persistedCompanies.at(-1).professional_source_ref, "datapro:trace-company-search"); + assert.ok(repository.persistedCompanies.at(-1).aliases.includes("测试科技")); + const runs = await service.listProviderRuns({ operation: "sales_company_search" }); + assert.equal(runs.length, 2); + assert.deepEqual(runs[0].steps.map((step) => step.provider), ["datapro", "web_search"]); + assert.ok(runs.every((run) => run.status === "succeeded")); +}); + +test("company search can parse a DataPro text summary when structured items are absent", async () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: permissiveTestPolicy, + seed: emptyState(), + dataProProvider: { + isRunEnabled: () => true, + async callTool() { + return { + ok: true, + summary: "公司名称:上海示例信息技术有限公司;统一社会信用代码:91310000TEST000002;法人姓名:李四;企业状态:存续;注册地址:上海市浦东新区示例路2号", + }; + }, + }, + webSearchProvider: webProvider(), + }); + const goal = await service.createGoal({ name: "文本结果测试" }); + const results = await service.searchCompanies(goal.id, { query: "示例信息" }); + + assert.equal(results.length, 1); + assert.equal(results[0].name, "上海示例信息技术有限公司"); + assert.equal(results[0].unified_social_credit_code, "91310000TEST000002"); + assert.equal(results[0].legal_representative, "李四"); + assert.equal(results[0].location, "上海市"); +}); + +test("runtime search rejects a successful DataPro response without an identifiable company", async () => { + const repository = createRepository(); + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + seed: emptyState(), + repository, + dataProProvider: { + isRunEnabled: () => true, + async callTool() { + return { ok: true, summary: "DataPro 返回成功,但没有企业主体字段。", parsed: { code: 0, items: [] } }; + }, + }, + webSearchProvider: webProvider(), + }); + await service.assertRuntimeReady(); + const goal = await service.createGoal({ name: "生产校验" }); + + await assert.rejects( + () => service.searchCompanies(goal.id, { query: "无法识别的公司" }), + (error) => error.status === 503 + && error.code === "datapro_unavailable" + && error.details.reason === "company_identity_unavailable", + ); + assert.equal(Object.keys(service.data.companies).length, 0); +}); + +test("runtime search keeps a verified DataPro candidate when optional web search is unavailable", async () => { + const repository = createRepository(); + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + seed: emptyState(), + repository, + dataProProvider: { + isRunEnabled: () => true, + async callTool() { + return { + ok: true, + raw_ref: "datapro:verified-without-web", + parsed: { + items: [{ + 企业名称: "广州可靠数据有限公司", + 统一社会信用代码: "91440100TEST000003", + 经营状态: "存续", + 注册地址: "广东省广州市天河区可靠路3号", + }], + }, + }; + }, + }, + webSearchProvider: { + isRunEnabled: () => true, + async search() { + return { ok: false, error: { code: "10500", message: "upstream unavailable" } }; + }, + }, + }); + await service.assertRuntimeReady(); + const goal = await service.createGoal({ name: "降级搜索" }); + const results = await service.searchCompanies(goal.id, { query: "可靠数据" }); + + assert.equal(results.length, 1); + assert.equal(results[0].identity_status, "verified"); + assert.match(results[0].reason, /联网公开信息暂不可用/); + assert.ok(results[0].warnings.some((warning) => warning.includes("10500"))); + const run = (await service.listProviderRuns({ operation: "sales_company_search" }))[0]; + assert.equal(run.status, "succeeded_with_issues"); + assert.equal(run.steps.find((step) => step.provider === "web_search").status, "failed"); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesDossierEvidenceCompilerIntegration.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesDossierEvidenceCompilerIntegration.test.mjs new file mode 100644 index 00000000..7cafc5e5 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesDossierEvidenceCompilerIntegration.test.mjs @@ -0,0 +1,249 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildDossierEvidencePack, +} from "../src/evidence/salesEvidence.js"; +import { + SalesService, +} from "../src/services/salesService.js"; + +const COMPANY = { + id: "company_fictional_cloud", + name: "云穹矩阵科技有限公司", + initial: "云", + industry: "企业软件", + location: "北京", + tags: [], + progress: { + label: "新商机", + summary: "待生成档案", + evidence: "尚未生成", + updated_at: null, + }, + dossier_ids: [], + material_ids: [], + qa_session_id: "sales-company_fictional_cloud", +}; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +function seed() { + return { + goals: [], + companies: { + [COMPANY.id]: structuredClone(COMPANY), + }, + dossiers: {}, + materials: {}, + qa_messages: { [COMPANY.id]: [] }, + sync_sources: {}, + sync_checkpoints: {}, + jobs: {}, + }; +} + +function fullEvidencePack() { + return buildDossierEvidencePack({ + company: { + ...COMPANY, + unified_social_credit_code: "91110000MA0CLOUD01", + }, + generatedAt: "2026-07-31T10:00:00.000Z", + collected: { + professional: [ + { + label: "企业工商数据库", + query: "云穹矩阵科技有限公司 企业工商信息", + summary: [ + "公司名称:云穹矩阵科技有限公司;", + "统一社会信用代码:91110000MA0CLOUD01;", + "经营范围:企业软件与知识库产品。", + ].join(""), + source_group: "business", + }, + { + label: "企业风险数据库", + query: "云穹矩阵科技有限公司 风险信息", + summary: "云穹矩阵科技有限公司披露项目交付周期延长,需要核验实施排期。", + source_group: "risk", + }, + { + label: "企业经营数据库", + query: "云穹矩阵科技有限公司 经营动态", + summary: "云穹矩阵科技有限公司持续升级知识库产品与企业协作检索能力。", + source_group: "market", + }, + ], + public_sources: [{ + label: "云穹矩阵产品升级公告", + summary: "2026年7月30日,云穹矩阵科技有限公司披露知识库产品升级进展。", + url: "https://official.example.com/cloud-product-update", + site_name: "虚构企业官网", + published_at: "2026-07-30T08:00:00.000Z", + official: true, + }], + }, + }); +} + +function sectionResponse(request) { + const evidenceBySection = request.payload.evidence_by_section; + const evidenceId = (key, predicate = () => true) => { + const atom = evidenceBySection[key].allowed_evidence.find(predicate) + || evidenceBySection[key].allowed_evidence[0]; + assert.ok(atom, `${key} must have allowed evidence`); + return atom.id; + }; + return { + sections: { + company_overview: { + text: "云穹矩阵科技有限公司经营企业软件与知识库产品。", + evidence_ids: [evidenceId("company_overview", (atom) => atom.quote.includes("经营范围"))], + }, + business_dynamics: { + text: "云穹矩阵科技有限公司持续升级知识库产品与企业协作检索能力。", + evidence_ids: [evidenceId("business_dynamics", (atom) => atom.quote.includes("持续升级"))], + }, + recent_public_updates: { + text: "2026年7月30日,云穹矩阵科技有限公司披露知识库产品升级进展。", + evidence_ids: [evidenceId("recent_public_updates")], + }, + risk_attention: { + text: "云穹矩阵科技有限公司披露项目交付周期延长,需要核验实施排期。", + evidence_ids: [evidenceId("risk_attention")], + }, + sales_opportunity: { + text: "知识库产品升级形成销售沟通窗口,但不代表企业已有采购意向。", + evidence_ids: [evidenceId("sales_opportunity", (atom) => atom.quote.includes("持续升级"))], + }, + recommended_actions: { + text: "销售人员应联系产品负责人核验知识库产品升级范围和实施排期。", + evidence_ids: [evidenceId("recommended_actions", (atom) => atom.quote.includes("持续升级"))], + }, + }, + }; +} + +test("SalesService compiles evidence before the Agent and persists server-derived citations", async () => { + const modelCalls = []; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: { fail_closed: true }, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callRequiredFunction(request) { + modelCalls.push(structuredClone(request)); + return { + ok: true, + parsed: sectionResponse(request), + raw_ref: "model:atom-contract", + }; + }, + }, + }); + + const dossier = await service.generateDossierWithModel( + service.data.companies[COMPANY.id], + fullEvidencePack(), + [], + ); + + assert.equal(modelCalls.length, 1); + assert.ok(modelCalls[0].payload.evidence_by_section); + assert.equal(modelCalls[0].payload.citations, undefined); + assert.equal(modelCalls[0].payload.allowed_citation_ids, undefined); + assert.doesNotMatch(JSON.stringify(modelCalls[0].parameters), /quote|citation_id|url/iu); + assert.equal(dossier.body.length, 6); + assert.ok(dossier.body.every((section) => ( + section.segments.length === 1 + && section.segments[0].citation_ids.length >= 1 + && section.citation_ids.length >= 1 + ))); + assert.ok(dossier.citations.length >= 4); + assert.equal(dossier.body[2].text.startsWith("近期公开动态:"), true); +}); + +test("SalesService completes six grounded sections when only legal-entity evidence is available", async () => { + let modelCalls = 0; + const sparsePack = buildDossierEvidencePack({ + company: { + ...COMPANY, + unified_social_credit_code: "91110000MA0CLOUD01", + }, + generatedAt: "2026-07-31T10:00:00.000Z", + collected: { + professional: [{ + label: "企业工商数据库", + query: "云穹矩阵科技有限公司 企业工商信息", + summary: "公司名称:云穹矩阵科技有限公司;统一社会信用代码:91110000MA0CLOUD01;经营范围:企业软件。", + }], + }, + }); + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: { fail_closed: true }, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callRequiredFunction(request) { + modelCalls += 1; + const evidenceId = (key) => ( + request.payload.evidence_by_section[key].allowed_evidence[0].id + ); + return { + ok: true, + parsed: { + sections: { + company_overview: { + text: "云穹矩阵科技有限公司的经营范围包括企业软件。", + evidence_ids: [evidenceId("company_overview")], + }, + business_dynamics: { + text: "该企业从事企业软件相关经营活动。", + evidence_ids: [evidenceId("business_dynamics")], + }, + recent_public_updates: { + text: "该企业当前公开登记的经营范围包含企业软件。", + evidence_ids: [evidenceId("recent_public_updates")], + }, + risk_attention: { + text: "商务推进需要结合企业软件业务核验项目责任边界。", + evidence_ids: [evidenceId("risk_attention")], + }, + sales_opportunity: { + text: "企业软件业务可形成方案沟通场景,但不代表企业已有采购意向。", + evidence_ids: [evidenceId("sales_opportunity")], + }, + recommended_actions: { + text: "销售人员应围绕企业软件业务联系相关负责人,确认实际应用场景和决策流程。", + evidence_ids: [evidenceId("recommended_actions")], + }, + }, + }, + raw_ref: "model:sparse-grounded", + }; + }, + }, + }); + + const dossier = await service.generateDossierWithModel( + service.data.companies[COMPANY.id], + sparsePack, + [], + ); + + assert.equal(modelCalls, 1); + assert.equal(dossier.body.length, 6); + assert.ok(dossier.body.every((section) => ( + section.segments.length === 1 + && section.segments[0].citation_ids.length === 1 + ))); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesEvidence.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesEvidence.test.mjs new file mode 100644 index 00000000..bab0cf8d --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesEvidence.test.mjs @@ -0,0 +1,607 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + assessQaAnswerability, + buildDossierEvidencePack, + buildQaEnumerationRequirements, + buildQaEvidence, + evidencePackCitations, + makeDossierFingerprint, + validateDossierModelAnswer, + validateProductionEvidencePack, + validateQaModelAnswer, +} from "../src/evidence/salesEvidence.js"; + +const company = { id: "company_xinlan", name: "星蓝新能源科技有限公司" }; + +test("evidence packs keep stable ids and reject unrelated public results", () => { + const pack = buildDossierEvidencePack({ + company, + generatedAt: "2026-07-21T10:00:00.000Z", + collected: { + professional: [{ + label: "企业工商数据库", + query: "星蓝新能源科技有限公司 企业工商信息", + summary: "公司名称:星蓝新能源科技有限公司;经营范围:新能源汽车相关业务。", + }], + public_sources: [ + { + label: "星蓝发布最新公告", + summary: "星蓝新能源科技有限公司发布最新公告。", + url: "https://example.org/xinlan?a=1&utm_source=test", + site_name: "星蓝官网", + published_at: "2026-07-20T08:00:00Z", + }, + { + label: "无关企业新闻", + summary: "另一家公司发布公告。", + url: "https://example.org/unrelated", + }, + ], + }, + }); + + assert.equal(pack.items.length, 2); + assert.equal(pack.rejected.length, 1); + assert.equal(pack.rejected[0].reason, "entity_not_verified"); + assert.equal(pack.data_as_of, "2026-07-20T08:00:00.000Z"); + assert.match(pack.items[1].url, /^https:\/\/example\.org\/xinlan\?a=1$/); + assert.equal(pack.items[0].source_quality_label, "专业权威来源"); + assert.equal(pack.items[1].freshness_label, "近期资料"); + assert.equal(pack.items[1].site_name, "星蓝官网"); + assert.equal(evidencePackCitations(pack)[1].site_name, "星蓝官网"); + assert.equal(pack.policy.current_public_count, 1); + assert.equal(validateProductionEvidencePack(pack).ok, true); +}); + +test("evidence packs retain brand-alias public news without treating it as the legal entity", () => { + const pack = buildDossierEvidencePack({ + company: { + id: "company_byd_industry", + name: "比亚迪汽车工业有限公司", + aliases: ["比亚迪"], + }, + generatedAt: "2026-07-24T10:00:00.000Z", + collected: { + professional: [{ + label: "企业工商数据库", + query: "比亚迪汽车工业有限公司 企业工商信息", + summary: "公司名称:比亚迪汽车工业有限公司;经营范围:汽车制造。", + }], + public_sources: [ + { + label: "比亚迪发布供应链合作动态", + summary: "比亚迪与合作伙伴发布供应链合作计划。", + url: "https://news.example.org/byd-cooperation", + published_at: "2026-07-20T08:00:00Z", + query: "比亚迪 2026 最新项目 合作", + }, + { + label: "其他汽车品牌新闻", + summary: "其他汽车品牌发布新车型。", + url: "https://news.example.org/other", + published_at: "2026-07-20T08:00:00Z", + }, + ], + }, + }); + + const aliasEvidence = pack.items.find((item) => item.label.includes("供应链合作")); + assert.equal(aliasEvidence.entity_match, "alias_scoped"); + assert.ok(pack.rejected.some((item) => item.label === "其他汽车品牌新闻")); +}); + +test("evidence packs derive a scoped brand alias from China investment-company names", () => { + const pack = buildDossierEvidencePack({ + company: { + id: "company_bosch_china", + name: "博世(中国)投资有限公司", + }, + generatedAt: "2026-07-29T10:00:00.000Z", + collected: { + professional: [{ + label: "企业工商数据库", + query: "博世(中国)投资有限公司 企业工商信息", + summary: "公司名称:博世(中国)投资有限公司;经营范围:机械制造、电子和信息产业投资。", + }], + public_sources: [{ + label: "博世发布在华合作项目动态", + summary: "博世与合作伙伴发布在华技术合作项目计划。", + url: "https://news.example.org/bosch-cooperation", + published_at: "2026-07-28T08:00:00Z", + query: "博世 2026 合作 项目", + }], + }, + }); + + const aliasEvidence = pack.items.find((item) => item.source_kind === "public"); + assert.equal(aliasEvidence.entity_match, "alias_scoped"); +}); + +test("evidence packs reject verification-gate pages instead of treating them as report sources", () => { + const pack = buildDossierEvidencePack({ + company, + generatedAt: "2026-07-29T10:00:00.000Z", + collected: { + professional: [{ + label: "企业工商数据库", + query: "星蓝新能源科技有限公司 企业工商信息", + summary: "公司名称:星蓝新能源科技有限公司;经营范围:新能源汽车相关业务。", + }], + public_sources: [{ + label: "星蓝新能源科技有限公司法律风险", + summary: "For better experience, please complete the verification process. TIME: 2026-07-29 09:00:00", + url: "https://example.org/verification-gate", + published_at: "2026-07-28T08:00:00Z", + }], + }, + }); + + assert.equal(pack.items.length, 1); + assert.equal(pack.rejected.length, 1); + assert.equal(pack.rejected[0].reason, "content_not_substantive"); + assert.equal(pack.policy.traceable_public_count, 0); +}); + +test("evidence hash ignores collection time but changes with source content", () => { + const input = { + company, + collected: { + professional: [{ + label: "企业工商数据库", + query: "星蓝新能源科技有限公司 企业工商信息", + summary: "公司名称:星蓝新能源科技有限公司。", + }], + }, + }; + const first = buildDossierEvidencePack({ ...input, generatedAt: "2026-07-21T10:00:00Z" }); + const second = buildDossierEvidencePack({ ...input, generatedAt: "2026-07-21T11:00:00Z" }); + const changed = buildDossierEvidencePack({ + ...input, + generatedAt: "2026-07-21T11:00:00Z", + collected: { + professional: [{ + ...input.collected.professional[0], + summary: "公司名称:星蓝新能源科技有限公司;经营范围已更新。", + }], + }, + }); + + assert.equal(first.evidence_hash, second.evidence_hash); + assert.notEqual(first.evidence_hash, changed.evidence_hash); + assert.equal(evidencePackCitations(first)[0].entity_match, "verified"); +}); + +test("dossier fingerprints are deterministic and include citation-backed content", () => { + const dossier = { + title: "星蓝最近档案", + summary: "近期信息已更新。", + body: [{ text: "近期动态:已发布公告。", citation_ids: ["evidence_1"] }], + citations: [{ id: "evidence_1", summary: "公告摘要" }], + }; + assert.equal(makeDossierFingerprint(dossier), makeDossierFingerprint(structuredClone(dossier))); + assert.notEqual( + makeDossierFingerprint(dossier), + makeDossierFingerprint({ ...dossier, summary: "近期信息发生变化。" }), + ); +}); + +test("QA validation derives citations from allowed evidence and rejects fabricated ids", () => { + const evidence = buildQaEvidence({ + dossier: { + id: "dossier_1", + title: "星蓝新能源科技有限公司销售情报报告", + version_no: 2, + summary: "企业近期发布了产品更新公告。", + body: [{ text: "近期公开动态:企业近期发布了产品更新公告。" }], + }, + contexts: [{ uri: "viking://resources/workspace/company/materials/a.md", title: "会议纪要", abstract: "客户关注预算窗口。" }], + }); + const dossierEvidence = evidence.find((item) => item.source_kind === "企业档案"); + const result = validateQaModelAnswer({ + paragraphs: [ + { text: "企业近期发布了产品更新公告。", citation_ids: [dossierEvidence.id] }, + { text: "客户关注预算窗口。", citation_ids: ["fabricated"] }, + ], + insufficient: false, + }, evidence); + + assert.equal(result.citations.length, 1); + assert.equal(result.citations[0].source_kind, "企业档案"); + assert.deepEqual(evidence.map((item) => item.source_kind).sort(), ["企业档案", "内部资料"]); + assert.ok(result.errors.some((item) => item.includes("无效引用"))); + assert.ok(result.errors.some((item) => item.includes("缺少有效引用"))); +}); + +test("QA removes an unrequested gap paragraph and rejects a risk paragraph citing the wrong dossier section", () => { + const evidence = [{ + id: "recent_section", + label: "测试企业 销售情报报告 V2 · 近期公开动态", + source_kind: "企业档案", + source_quality: "verified_dossier", + summary: "近期公开动态:2026年7月30日,测试企业发布产品升级公告。", + }]; + const result = validateQaModelAnswer({ + paragraphs: [{ + text: "风险:该企业的交付周期需要核验。", + citation_ids: ["recent_section"], + }, { + text: "缺口:还需要补充更多资料。", + citation_ids: ["recent_section"], + }], + insufficient: false, + }, evidence, { question: "说明该企业的主要风险。" }); + + assert.equal(result.paragraphs.length, 1); + assert.ok(result.errors.some((item) => item.includes("风险与关注事项"))); + + const requested = validateQaModelAnswer({ + paragraphs: [{ + text: "缺口:还需要补充交付记录。", + citation_ids: ["recent_section"], + }], + insufficient: false, + }, evidence, { question: "还有哪些资料缺口?" }); + assert.equal(requested.paragraphs.length, 1); +}); + +test("QA evidence reads and ranks the relevant chunk instead of sending one long material blob", () => { + const evidence = buildQaEvidence({ + question: "客户的预算窗口和试点范围是什么?", + dossier: { + id: "dossier_qa_1", + title: "测试企业销售情报报告", + version_no: 2, + body: [ + { text: "企业与业务概览:该企业提供知识库产品。" }, + { text: "建议行动:确认试点范围和预算窗口。" }, + ], + }, + contexts: [{ + material_id: "material_long", + title: "客户需求确认会", + source_kind: "会议纪要", + uri: "viking://resources/material_long.md", + score: 0.72, + content: `${"一般背景信息。".repeat(220)}\n\n预算窗口:客户计划在第四季度确认预算;试点范围为两个业务部门。`, + }], + maxItems: 6, + }); + + assert.ok(evidence.length >= 3); + assert.ok(evidence.some((item) => item.summary.includes("第四季度确认预算"))); + assert.ok(evidence[0].summary.includes("预算") || evidence[0].summary.includes("试点范围")); + assert.ok(evidence.every((item) => item.summary.length <= 1800)); + assert.equal(assessQaAnswerability("客户的预算窗口是什么?", evidence).supported, true); +}); + +test("QA evidence carries a Markdown heading into the following table block", () => { + const content = [ + "# Agent Plan CookBook", + "## 项目介绍", + "这是一份个人投资助手搭建教程。", + "### 核心使用能力", + "| 能力点 | 说明 |", + "|-|-|", + "| 语言模型 | 完成需求理解和网站交付 |", + "| 联网搜索 | 补充公开新闻和行业动态 |", + "| 专业数据集 | 查询股票金融和企业工商数据 |", + "## 前置准备", + "购买套餐并完成环境配置。", + "## 网站开发流程", + "生成方案、开发页面并完成调试。", + ].join("\n\n"); + + const evidence = buildQaEvidence({ + question: "文档的核心使用能力有哪些?", + contexts: [{ + material_id: "material_doc", + title: "个人投资助手 CookBook", + source_kind: "云文档", + content, + }], + maxItems: 2, + }); + + assert.match(evidence[0].summary, /核心使用能力/); + assert.match(evidence[0].summary, /语言模型/); + assert.match(evidence[0].summary, /联网搜索/); + assert.doesNotMatch(evidence[0].summary, /^### 核心使用能力$/); +}); + +test("QA evidence ranks the complete capability table above title-only noise", () => { + const content = [ + "<title>Agent Plan CookBook -「个人投资助手」", + "更多 CookBook 可见:", + "---", + "# 一、项目介绍", + "「**核心使用能力**」", + "| **能力点** | 说明 |", + "|-|-|", + "| **语言模型** | 支持模型切换与网站交付 |", + "| **Claude code/ Agent 能力** | 承接需求理解、任务编排与开发 |", + "| **联网搜索** | 补充公开新闻和行业动态 |", + "| **Data MCP:股票金融数据/国内企业工商数据** | 查询专业结构化数据 |", + "| **多工具兼容** | 可在多个主流 Agent 平台中使用 |", + "| **消耗统一计量** | 在控制台查看统一计量结果 |", + "---", + "# 二、前置准备", + "购买套餐并完成环境配置。", + ].join("\n\n"); + + const evidence = buildQaEvidence({ + question: "这份个人投资助手文档明确使用了哪些核心能力?", + contexts: [{ + material_id: "material_full_table", + title: "飞书云文档:Agent Plan CookBook -「个人投资助手」", + source_kind: "云文档", + score: 0.7, + content, + }], + maxItems: 3, + }); + + assert.match(evidence[0].summary, /核心使用能力/); + assert.match(evidence[0].summary, /语言模型/); + assert.match(evidence[0].summary, /Claude code\/ Agent 能力/); + assert.match(evidence[0].summary, /联网搜索/); + assert.match(evidence[0].summary, /Data MCP/); + assert.match(evidence[0].summary, /多工具兼容/); + assert.match(evidence[0].summary, /消耗统一计量/); + assert.ok(evidence.every((item) => item.summary !== "---")); + + const competingEvidence = [{ + id: "evidence_demand_types", + label: "个人投资助手 CookBook", + source_kind: "云文档", + retrieval_score: 0.99, + summary: "### Step2 识别核心需求 | 需求类型 | 核心诉求 | |-|-| | 主动查看 | 想快速了解某只股票最近有没有值得关注的变化 | | 持续跟踪 | 不想每天手动查公告、新闻和风险事件 |", + }]; + const requirements = buildQaEnumerationRequirements( + "这份个人投资助手文档明确使用了哪些核心能力?", + [...competingEvidence, ...evidence], + ); + assert.deepEqual( + requirements.map((item) => item.label), + [ + "语言模型", + "Claude code/ Agent 能力", + "联网搜索", + "Data MCP:股票金融数据/国内企业工商数据", + "多工具兼容", + "消耗统一计量", + ], + ); + const incomplete = validateQaModelAnswer({ + paragraphs: [{ + text: "文档使用语言模型、Claude Code、联网搜索和 Data MCP。", + citation_ids: [evidence[0].id], + }], + insufficient: false, + }, evidence, { enumerationRequirements: requirements }); + assert.deepEqual( + incomplete.missing_enumeration_items.map((item) => item.label), + ["多工具兼容", "消耗统一计量"], + ); + assert.ok(incomplete.errors.some((item) => item.includes("回答遗漏枚举项"))); +}); + +test("QA enumeration completeness ignores unrelated tables for compare-style questions", () => { + const evidence = [{ + id: "evidence_trace_span", + label: "全链路数据体系建设研讨会", + source_kind: "飞书云文档", + retrieval_score: 0.99, + summary: [ + "Trace 通过唯一 Trace ID 串联一次完整调用,每个执行节点对应一个 Span。", + "| 阶段 | 说明 |", + "|-|-|", + "| 接入 | 完成数据接入 |", + "| 路由 | 完成请求路由 |", + "| 调用 | 完成模型调用 |", + "| 验收 | 完成效果验收 |", + ].join(" "), + }]; + + const requirements = buildQaEnumerationRequirements( + "Trace 和 Span 分别承担什么作用?请用三点说明。", + evidence, + ); + + assert.deepEqual(requirements, []); +}); + +test("QA answerability rejects unrelated questions even when enterprise evidence exists", () => { + const evidence = buildQaEvidence({ + question: "今天当地天气怎么样?", + dossier: { + id: "dossier_qa_2", + title: "测试企业销售情报报告", + body: [{ text: "企业与业务概览:该企业提供知识库产品。" }], + }, + contexts: [{ + material_id: "material_qa_2", + title: "客户需求确认会", + source_kind: "会议纪要", + content: "客户希望先验证知识库问答,并确认数据权限边界。", + }], + }); + + assert.equal(assessQaAnswerability("今天当地天气怎么样?", evidence).supported, false); +}); + +test("runtime evidence policy records public-source gaps without rejecting a legally anchored dossier", () => { + const pack = buildDossierEvidencePack({ + company, + generatedAt: "2026-07-21T10:00:00.000Z", + collected: { + professional: [{ + label: "企业工商数据库", + query: "星蓝新能源科技有限公司 企业工商信息", + summary: "星蓝新能源科技有限公司主体信息。", + }], + public_sources: [{ + label: "星蓝动态摘要", + summary: "星蓝新能源科技有限公司发布业务动态。", + }], + }, + }); + + const validation = validateProductionEvidencePack(pack); + assert.equal(pack.data_as_of, null); + assert.equal(validation.ok, true); + assert.equal(validation.policy.traceable_public_count, 0); + assert.equal(validation.policy.current_public_count, 0); + assert.equal(validation.policy.legal_entity_anchor_count, 1); +}); + +test("runtime evidence policy rejects a professional result that does not anchor the legal entity", () => { + const pack = buildDossierEvidencePack({ + company, + generatedAt: "2026-07-21T10:00:00.000Z", + collected: { + professional: [{ + label: "企业工商数据库", + query: "星蓝新能源科技有限公司 企业工商信息", + summary: "该记录仅描述新能源汽车相关业务,没有返回可核对的企业名称或统一社会信用代码。", + }], + }, + }); + + const validation = validateProductionEvidencePack(pack); + assert.equal(validation.ok, false); + assert.equal(validation.policy.legal_entity_anchor_count, 0); + assert.ok(validation.errors.some((item) => item.includes("目标主体"))); +}); + +test("evidence packs detect competing critical numbers from contemporaneous sources", () => { + const pack = buildDossierEvidencePack({ + company, + generatedAt: "2026-07-21T10:00:00.000Z", + collected: { + professional: [ + { + label: "企业工商数据库", + query: "星蓝新能源科技有限公司 注册资本", + summary: "星蓝新能源科技有限公司注册资本为1000万元。", + }, + { + label: "企业风险数据库", + query: "星蓝新能源科技有限公司 注册资本", + summary: "星蓝新能源科技有限公司注册资本为2000万元。", + }, + ], + }, + }); + + assert.equal(pack.conflicts.length, 1); + assert.equal(pack.conflicts[0].field, "registered_capital"); + assert.equal(pack.policy.conflict_count, 1); + assert.ok(pack.items.every((item) => item.conflict_fields.includes("registered_capital"))); +}); + +test("dossier validation requires two sources to agree on a critical number", () => { + const disagreeing = [ + { + id: "professional-1", + label: "企业工商数据库", + source_kind: "专业数据集", + quality_tier: 1, + independence_key: "datapro:business", + summary: "星蓝新能源科技有限公司注册资本为1000万元。", + }, + { + id: "professional-2", + label: "企业风险数据库", + source_kind: "专业数据集", + quality_tier: 1, + independence_key: "datapro:risk", + summary: "星蓝新能源科技有限公司注册资本为2000万元。", + }, + { + id: "public-1", + label: "近期公告", + source_kind: "联网搜索", + quality_tier: 2, + independence_key: "example.org", + summary: "星蓝新能源科技有限公司发布近期公告。", + }, + ]; + const parsed = { + body: [ + { text: "企业与业务概览:该企业注册资本为1000万元,并面向企业客户提供相关服务。", citation_ids: ["professional-1", "professional-2"] }, + { text: "经营与业务动态:专业数据可用于核验该企业当前经营主体。", citation_ids: ["professional-1"] }, + { text: "近期公开动态:企业发布了近期公告。", citation_ids: ["public-1"] }, + { text: "风险与关注事项:当前仍需交叉核验关键经营数字。", citation_ids: ["professional-1", "public-1"] }, + { text: "销售机会判断:当前可继续核验业务需求与合作场景。", citation_ids: ["professional-1", "public-1"] }, + { text: "建议行动:确认业务部门、采购计划和数据合规要求。", citation_ids: ["professional-1", "public-1"] }, + ], + }; + + const rejected = validateDossierModelAnswer(parsed, disagreeing); + assert.ok(rejected.errors.some((item) => item.includes("未获得双来源一致支持"))); + + const agreeing = disagreeing.map((item) => item.id === "professional-2" + ? { ...item, summary: "星蓝新能源科技有限公司注册资本为1000万元。" } + : item); + assert.deepEqual(validateDossierModelAnswer(parsed, agreeing).errors, []); +}); + +test("dossier validation does not require unrelated sources to pad citation counts", () => { + const evidence = [ + { + id: "professional-business", + label: "企业工商数据库", + source_kind: "专业数据集", + independence_key: "datapro:business", + summary: "星蓝新能源科技有限公司从事新能源汽车相关业务。", + }, + { + id: "professional-market", + label: "金融数据库", + source_kind: "专业数据集", + independence_key: "datapro:finance", + summary: "星蓝新能源科技有限公司持续推进新能源业务。", + }, + { + id: "public-project", + label: "星蓝新能源项目合作公告", + source_kind: "联网搜索", + independence_key: "official.example.org", + summary: "星蓝新能源科技有限公司发布新能源项目合作公告。", + }, + { + id: "public-delivery", + label: "星蓝新能源设备交付公告", + source_kind: "联网搜索", + independence_key: "news.example.net", + summary: "星蓝新能源科技有限公司披露设备交付进展。", + }, + ]; + const titles = [ + "企业与业务概览", + "经营与业务动态", + "近期公开动态", + "风险与关注事项", + "销售机会判断", + "建议行动", + ]; + const sparse = { + body: titles.map((title) => ({ + text: `${title}:这是由已核验来源支持的完整业务事实说明。`, + citation_ids: ["professional-business", "public-project"], + })), + }; + const sparseValidation = validateDossierModelAnswer(sparse, evidence); + assert.deepEqual(sparseValidation.errors, []); + + const covered = { + body: titles.map((title, index) => ({ + text: `${title}:这是由已核验来源支持的完整业务事实说明。`, + citation_ids: index % 2 + ? ["professional-market", "public-delivery"] + : ["professional-business", "public-project"], + })), + }; + assert.deepEqual(validateDossierModelAnswer(covered, evidence).errors, []); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesFailClosed.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesFailClosed.test.mjs new file mode 100644 index 00000000..344e5ed7 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesFailClosed.test.mjs @@ -0,0 +1,472 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SalesService } from "../src/services/salesService.js"; + +const strictRuntimePolicy = Object.freeze({ + fail_closed: true, +}); + +const permissiveTestPolicy = Object.freeze({ + fail_closed: false, +}); + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +function unavailableProviders() { + return { + dataProProvider: { + maxSources: 1, + isRunEnabled: () => true, + callTool: async () => ({ ok: false, error: { code: "temporarily_unavailable" } }), + }, + webSearchProvider: { + isRunEnabled: () => true, + search: async () => ({ ok: false, error: { code: "temporarily_unavailable" }, results: [] }), + }, + }; +} + +test("the runtime starts with no business data", () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: permissiveTestPolicy, + }); + + assert.deepEqual(service.data.goals, []); + assert.deepEqual(service.data.companies, {}); +}); + +test("test data is loaded only when a test explicitly injects a seed", () => { + const seed = { + goals: [{ id: "goal-1", name: "Test goal" }], + companies: {}, + dossiers: {}, + materials: {}, + qa_messages: {}, + }; + const service = new SalesService({ + env: envReader(), + runtimePolicy: permissiveTestPolicy, + seed, + }); + + assert.deepEqual(service.data.goals, seed.goals); +}); + +test("an empty persistent repository replaces injected test data", async () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + seed: { + goals: [{ id: "seed-goal", name: "Seed" }], + companies: { seed: { id: "seed", name: "Seed Company" } }, + dossiers: {}, + materials: {}, + qa_messages: {}, + }, + repository: { + getSalesState() { + return { + goals: [], + companies: {}, + dossiers: {}, + materials: {}, + qa_messages: {}, + }; + }, + }, + }); + + await service.assertRuntimeReady(); + assert.deepEqual(service.data.goals, []); + assert.deepEqual(service.data.companies, {}); + assert.equal(service.persistence.enabled, true); +}); + +test("the runtime refuses to continue when verified professional evidence is unavailable", async () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + ...unavailableProviders(), + }); + + await assert.rejects( + () => service.collectDossierEvidence({ id: "company-1", name: "测试企业" }), + (error) => error.status === 503 && error.code === "datapro_unavailable", + ); +}); + +test("the runtime preserves retryability when public evidence has a transient provider failure", async () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + dataProProvider: { + maxSources: 1, + isRunEnabled: () => true, + callTool: async () => ({ + ok: true, + summary: "已核验的企业专业资料", + raw_ref: "datapro:test", + }), + }, + webSearchProvider: { + isRunEnabled: () => true, + search: async () => ({ + ok: false, + error: { + code: "10500", + category: "upstream", + retryable: true, + }, + results: [], + }), + }, + }); + + await assert.rejects( + () => service.collectDossierEvidence({ id: "company-1", name: "测试企业" }), + (error) => ( + error.status === 503 + && error.code === "web_search_unavailable" + && error.retryable === true + && error.details.retryable === true + ), + ); +}); + +test("a unit-test policy can inspect issues without inventing professional evidence", async () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: permissiveTestPolicy, + ...unavailableProviders(), + }); + + const evidence = await service.collectDossierEvidence({ id: "company-1", name: "测试企业" }); + assert.deepEqual(evidence.professional, []); + assert.deepEqual(evidence.public_sources, []); + assert.ok(evidence.issues.length >= 2); +}); + +test("the runtime refuses rule-based dossier fallback when the model is disabled", async () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + modelProvider: { isRunEnabled: () => false }, + }); + + await assert.rejects( + () => service.generateDossierWithModel( + { id: "company-1", name: "测试企业有限公司", industry: "测试行业", location: "测试地区" }, + { + professional: [ + { label: "企业工商数据库", summary: "测试企业有限公司经营测试行业相关的软件与技术服务业务。" }, + { label: "金融数据库", summary: "测试企业有限公司持续推进软件产品研发与客户交付。" }, + ], + public_sources: [ + { + label: "测试企业有限公司发布产品升级公告", + summary: "测试企业有限公司于2026年7月发布产品升级公告。", + url: "https://news.test/company-update", + published_at: "2026-07-20T09:00:00.000Z", + }, + { + label: "测试企业有限公司披露项目交付计划", + summary: "测试企业有限公司披露项目分阶段交付计划。", + url: "https://official.test/company-delivery", + published_at: "2026-07-21T09:00:00.000Z", + }, + ], + }, + [], + ), + (error) => error.status === 503 && error.code === "model_unavailable", + ); +}); + +test("the runtime does not persist a rule dossier when the final model quality gate returns no dossier", async () => { + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test", ASYNC_JOBS_ENABLED: "false" }), + runtimePolicy: strictRuntimePolicy, + seed: { + goals: [], + companies: { + company_1: { + id: "company_1", + name: "测试科技有限公司", + industry: "企业软件", + location: "北京", + dossier_ids: [], + material_ids: [], + }, + }, + dossiers: {}, + materials: {}, + qa_messages: {}, + jobs: {}, + }, + providerRunStore: { + startRun: async () => ({ id: "run-1" }), + failRun: async () => null, + }, + }); + service.startJob = async () => ({ id: "job-1" }); + service.assertJobActive = async () => ({ id: "job-1" }); + service.trackProviderStep = async (_runId, _input, operation) => operation(); + service.collectDossierEvidence = async () => ({ + professional: [{ + label: "企业工商数据库", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件。", + }], + public_sources: [{ + label: "测试科技有限公司发布产品升级公告", + summary: "测试科技有限公司于2026年7月发布企业知识库产品升级公告。", + url: "https://news.test/product-update", + published_at: "2026-07-20T09:00:00.000Z", + }], + issues: [], + }); + service.generateDossierWithModel = async () => null; + service.buildRuleDossier = () => { + throw new Error("rule fallback must not run"); + }; + service.failJob = async () => null; + + await assert.rejects( + () => service.createDossier("company_1"), + (error) => ( + error.status === 503 + && error.code === "model_unavailable" + && error.details.reason === "dossier_quality_gate_failed" + ), + ); + assert.deepEqual(service.data.dossiers, {}); +}); + +test("the runtime requires the actually cited sources to anchor the legal entity", async () => { + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test", ASYNC_JOBS_ENABLED: "false" }), + runtimePolicy: strictRuntimePolicy, + seed: { + goals: [], + companies: { + company_1: { + id: "company_1", + name: "测试科技有限公司", + industry: "企业软件", + location: "北京", + dossier_ids: [], + material_ids: [], + }, + }, + dossiers: {}, + materials: {}, + qa_messages: {}, + jobs: {}, + }, + providerRunStore: { + startRun: async () => ({ id: "run-1" }), + failRun: async () => null, + }, + }); + service.startJob = async () => ({ id: "job-1" }); + service.assertJobActive = async () => ({ id: "job-1" }); + service.trackProviderStep = async (_runId, _input, operation) => operation(); + service.collectDossierEvidence = async () => ({ + professional: [ + { + label: "企业工商数据库", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件与知识库产品。", + }, + { + label: "企业风险数据库", + summary: "项目交付、合同责任和供应保障事项需要持续核验。", + }, + ], + public_sources: [ + { + label: "测试科技有限公司产品升级公告", + summary: "测试科技有限公司于2026年7月发布销售知识库产品升级公告。", + url: "https://news.test/product-update", + published_at: "2026-07-20T09:00:00.000Z", + }, + { + label: "测试科技有限公司项目交付公告", + summary: "测试科技有限公司于2026年7月披露企业软件项目的分阶段交付安排。", + url: "https://official.test/project-delivery", + published_at: "2026-07-21T09:00:00.000Z", + }, + ], + issues: [], + }); + service.generateDossierWithModel = async () => ({ + id: "under-sourced-model-dossier", + company_id: "company_1", + title: "测试科技有限公司 销售情报报告", + summary: "测试科技有限公司近期升级企业知识库产品。", + body: [ + { text: "企业与业务概览:测试科技有限公司面向企业客户提供知识库软件。", citation_ids: ["p2"] }, + { text: "经营与业务动态:公司持续升级企业知识库产品与内容检索能力。", citation_ids: ["p2"] }, + { text: "近期公开动态:测试科技有限公司于2026年7月发布产品升级公告。", citation_ids: ["w1"] }, + { text: "风险与关注事项:项目推进前需要确认实施排期和合同责任边界。", citation_ids: ["p2", "w1"] }, + { text: "销售机会判断:产品升级形成沟通窗口,但不代表客户已经形成采购意向。", citation_ids: ["p2", "w1"] }, + { text: "建议行动:1. 联系产品负责人。\n2. 核验实施排期。\n3. 准备试点方案。", citation_ids: ["p2", "w1"] }, + ], + citations: [ + { id: "p1", label: "企业工商数据库", source_kind: "专业数据集", summary: "测试科技有限公司主营企业软件与知识库产品。" }, + { id: "p2", label: "企业风险数据库", source_kind: "专业数据集", summary: "项目交付和合同责任需要持续核验。" }, + { id: "w1", label: "测试科技有限公司产品升级公告", source_kind: "联网搜索", url: "https://news.test/product-update", summary: "测试科技有限公司于2026年7月发布产品升级公告。" }, + { id: "w2", label: "测试科技有限公司项目交付公告", source_kind: "联网搜索", url: "https://official.test/project-delivery", summary: "测试科技有限公司于2026年7月披露项目交付安排。" }, + ], + memory_summary: "测试科技有限公司近期升级企业知识库产品。", + created_at: "2026-07-29T10:00:00.000Z", + }); + service.failJob = async () => null; + + await assert.rejects( + () => service.createDossier("company_1"), + (error) => ( + error.status === 503 + && error.code === "model_unavailable" + && error.details.reason === "public_dossier_quality_gate_failed" + && error.details.validation_errors.some((message) => /目标法定主体/.test(message)) + ), + ); + assert.deepEqual(service.data.dossiers, {}); +}); + +test("dossier lists hide six-section records that contain search debris or question-like facts", () => { + const goodBody = [ + { text: "企业与业务概览:测试科技有限公司面向企业客户提供知识库软件,并持续服务销售团队的信息管理场景。", citation_ids: ["professional-1"] }, + { text: "经营与业务动态:公司在2026年持续升级企业知识库产品,重点增强权限管理、内容检索和协作能力。", citation_ids: ["professional-1"] }, + { text: "近期公开动态:公司于2026年7月发布产品升级公告,披露了面向销售团队的新协作功能。", citation_ids: ["public-1"] }, + { text: "风险与关注事项:公开公告提示交付计划仍受实施资源影响,商务推进前应确认项目排期和责任边界。", citation_ids: ["public-1"] }, + { text: "销售机会判断:产品升级形成了知识库集成和数据治理的沟通窗口,但不代表客户已经形成采购意向。", citation_ids: ["professional-1", "public-1"] }, + { text: "建议行动:1. 联系产品负责人确认升级范围和试点计划。\n2. 准备权限治理与交付边界材料。\n3. 核验预算窗口和采购流程。", citation_ids: ["professional-1", "public-1"] }, + ]; + const citations = [ + { id: "professional-1", label: "企业工商数据库", source_kind: "专业数据集", summary: "测试科技有限公司主营企业软件。" }, + { id: "public-1", label: "测试科技有限公司产品升级公告", source_kind: "联网搜索", summary: "测试科技有限公司于2026年7月发布产品升级公告。" }, + ]; + const service = new SalesService({ + env: envReader(), + runtimePolicy: permissiveTestPolicy, + seed: { + goals: [], + companies: { + company_1: { + id: "company_1", + name: "测试科技有限公司", + dossier_ids: ["bad-dossier", "good-dossier"], + material_ids: [], + }, + }, + dossiers: { + "bad-dossier": { + id: "bad-dossier", + company_id: "company_1", + summary: "测试科技有限公司是否有法律诉讼-启信宝。", + body: goodBody.map((paragraph, index) => ( + index === 2 + ? { text: "近期公开动态:测试科技有限公司是否有法律诉讼-启信宝。", citation_ids: ["public-1"] } + : paragraph + )), + citations, + version_no: 2, + created_at: "2026-07-29T10:00:00.000Z", + }, + "good-dossier": { + id: "good-dossier", + company_id: "company_1", + summary: "测试科技有限公司近期升级企业知识库产品,销售侧可围绕权限治理、系统集成和试点交付窗口继续核验。", + body: goodBody, + citations, + version_no: 1, + created_at: "2026-07-28T10:00:00.000Z", + }, + }, + materials: {}, + qa_messages: {}, + jobs: {}, + }, + }); + + assert.deepEqual(service.listDossiers("company_1").map((item) => item.id), ["good-dossier"]); +}); + +test("strict dossier detail keeps a concise record when its claims are grounded and the subject is anchored", () => { + const body = [ + { text: "企业与业务概览:测试科技有限公司面向企业客户提供知识库软件,并持续服务销售团队的信息管理场景。", citation_ids: ["professional-1"] }, + { text: "经营与业务动态:公司持续升级企业知识库产品,重点增强权限管理、内容检索和协作能力。", citation_ids: ["professional-1"] }, + { text: "近期公开动态:测试科技有限公司于2026年7月发布产品升级公告,披露面向销售团队的新协作功能。", citation_ids: ["public-1"] }, + { text: "风险与关注事项:公开公告提示交付计划仍受实施资源影响,商务推进前应确认项目排期和责任边界。", citation_ids: ["public-1"] }, + { text: "销售机会判断:产品升级形成知识库集成和数据治理的沟通窗口,但不代表客户已经形成采购意向。", citation_ids: ["professional-1", "public-1"] }, + { text: "建议行动:1. 联系产品负责人确认升级范围。\n2. 准备权限治理材料。\n3. 核验预算窗口。", citation_ids: ["professional-1", "public-1"] }, + ]; + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + seed: { + goals: [], + companies: { + company_1: { + id: "company_1", + name: "测试科技有限公司", + dossier_ids: ["under-sourced-dossier"], + material_ids: [], + }, + }, + dossiers: { + "under-sourced-dossier": { + id: "under-sourced-dossier", + company_id: "company_1", + summary: "测试科技有限公司近期升级企业知识库产品。", + body, + citations: [ + { + id: "professional-1", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "测试科技有限公司主营企业软件与知识库产品。", + }, + { + id: "public-1", + label: "测试科技有限公司产品升级公告", + source_kind: "联网搜索", + url: "https://news.test/product-update", + summary: "测试科技有限公司于2026年7月发布产品升级公告。", + }, + ], + version_no: 1, + created_at: "2026-07-29T10:00:00.000Z", + }, + }, + materials: {}, + qa_messages: {}, + jobs: {}, + }, + }); + + assert.deepEqual( + service.listDossiers("company_1").map((item) => item.id), + ["under-sourced-dossier"], + ); + assert.equal(service.dossierDetail("under-sourced-dossier").citations.length, 2); +}); + +test("business access requires a working persistent repository", async () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + }); + + await assert.rejects( + () => service.assertRuntimeReady(), + (error) => error.status === 503 && error.code === "supabase_unavailable", + ); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesQaQuality.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesQaQuality.test.mjs new file mode 100644 index 00000000..76335062 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesQaQuality.test.mjs @@ -0,0 +1,272 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + assessQaAnswerability, + buildQaEvidence, + fuseQaRetrievalContexts, +} from "../src/evidence/salesEvidence.js"; + +const dossier = { + id: "dossier_quality_v2", + title: "远航能源销售情报报告", + version_no: 2, + body: [ + { text: "企业与业务概览:远航能源主营储能系统集成与电池管理平台。" }, + { text: "经营与业务动态:公司正在推进华东区域工商业储能项目。" }, + { text: "近期公开动态:近期公开信息显示公司启动了新一轮供应商遴选。" }, + { text: "风险与关注事项:项目尚未完成预算审批,交付周期是当前主要风险。" }, + { text: "销售机会判断:储能监控、运维和数据平台存在进一步合作机会。" }, + { text: "建议行动:先确认预算审批节点,再向信息化部门提交小范围验证方案。" }, + ], +}; + +const contexts = [ + { + material_id: "doc_budget", + title: "云文档:储能平台立项说明", + source_kind: "云文档", + score: 0.82, + content: [ + "项目背景:客户计划统一管理华东区域的储能站点。", + "技术范围:一期先接入十二个站点,验证监控告警和设备健康分析。", + "预算与排期:首期预算为320万元,计划在第四季度完成采购,采购前需完成安全评审。", + "验收要求:告警到达率不低于99.9%,并支持私有化部署。", + ].join("\n\n"), + }, + { + material_id: "chat_people", + title: "飞书会话:7月客户沟通", + source_kind: "飞书会话", + score: 0.76, + content: [ + "销售:本轮验证由谁牵头?", + "客户:信息化部的周敏负责方案评审,采购部的林涛负责商务流程。", + "客户:目前主要顾虑是历史设备协议不统一,希望先做三个站点的兼容性验证。", + ].join("\n"), + }, + { + material_id: "doc_unrelated", + title: "云文档:员工活动安排", + source_kind: "云文档", + score: 0.2, + content: "员工活动计划在园区举办,内容与销售项目无关。", + }, +]; + +const cases = [ + { + question: "客户的预算是多少,计划什么时候采购?", + expectedMaterialId: "doc_budget", + }, + { + question: "谁负责方案评审和商务流程?", + expectedMaterialId: "chat_people", + }, + { + question: "客户当前最主要的顾虑是什么?", + expectedMaterialId: "chat_people", + }, + { + question: "这个项目有哪些风险,下一步应该怎么推进?", + expectedText: "预算审批", + }, + { + question: "一期计划接入多少个站点?", + expectedMaterialId: "doc_budget", + }, + { + question: "验收时对告警到达率有什么要求?", + expectedMaterialId: "doc_budget", + }, + { + question: "客户是否要求私有化部署?", + expectedMaterialId: "doc_budget", + }, + { + question: "采购部由谁负责商务流程?", + expectedMaterialId: "chat_people", + }, +]; + +test("QA retrieval quality gate keeps every golden fact inside top five evidence chunks", () => { + let hits = 0; + for (const item of cases) { + const evidence = buildQaEvidence({ + dossier, + contexts, + question: item.question, + maxItems: 8, + }); + const topFive = evidence.slice(0, 5); + const matched = item.expectedMaterialId + ? topFive.some((candidate) => candidate.material_id === item.expectedMaterialId) + : topFive.some((candidate) => candidate.summary.includes(item.expectedText)); + if (matched) hits += 1; + assert.equal(matched, true, `未命中问题:${item.question}`); + assert.equal(assessQaAnswerability(item.question, evidence).supported, true); + } + assert.equal(hits / cases.length, 1); +}); + +test("QA retrieval quality gate rejects an unrelated question instead of forcing an answer", () => { + const question = "明天上海会不会下雨?"; + const evidence = buildQaEvidence({ dossier, contexts, question, maxItems: 8 }); + const assessment = assessQaAnswerability(question, evidence); + assert.equal(assessment.supported, false); + assert.equal(assessment.reason, "low_relevance"); +}); + +test("QA evidence remains bounded and preserves source diversity", () => { + const evidence = buildQaEvidence({ + dossier, + contexts, + question: "总结项目需求、负责人、风险和下一步行动", + maxItems: 8, + }); + assert.ok(evidence.length <= 8); + assert.ok(evidence.some((item) => item.source_kind === "企业档案")); + assert.ok(evidence.some((item) => item.source_kind !== "企业档案")); + assert.ok(evidence.every((item) => item.summary.length <= 1600)); +}); + +test("QA retrieval fusion promotes evidence recalled by multiple query variants", () => { + const fused = fuseQaRetrievalContexts([ + { + query: "远航能源 客户预算", + contexts: [ + { + material_id: "doc_general", + uri: "viking://sales/workspace/company/materials/general.md", + abstract: "一般项目背景。", + score: 0.9, + }, + { + material_id: "doc_budget", + uri: "viking://sales/workspace/company/materials/budget.md", + abstract: "首期预算为 320 万元。", + score: 0.8, + }, + ], + }, + { + query: "远航能源 采购时间 预算窗口", + contexts: [ + { + material_id: "doc_budget", + uri: "viking://sales/workspace/company/materials/budget.md", + abstract: "第四季度完成采购。", + score: 0.84, + }, + { + material_id: "doc_schedule", + uri: "viking://sales/workspace/company/materials/schedule.md", + abstract: "项目排期说明。", + score: 0.79, + }, + ], + }, + ], { + maxContexts: 3, + maxPerMaterial: 2, + }); + + assert.equal(fused[0].material_id, "doc_budget"); + assert.equal(fused[0].query_hits, 2); + assert.deepEqual(fused[0].matched_queries, [ + "远航能源 客户预算", + "远航能源 采购时间 预算窗口", + ]); + assert.ok(fused[0].fusion_score > fused[1].fusion_score); +}); + +test("QA retrieval fusion preserves distinct sections but limits one material from crowding out others", () => { + const fused = fuseQaRetrievalContexts([ + { + query: "客户需求 风险 下一步", + contexts: [ + { + material_id: "doc_project", + uri: "viking://sales/workspace/company/materials/project/requirements.md", + abstract: "客户需要私有化部署。", + score: 0.91, + }, + { + material_id: "doc_project", + uri: "viking://sales/workspace/company/materials/project/risks.md", + abstract: "预算审批尚未完成。", + score: 0.89, + }, + { + material_id: "doc_project", + uri: "viking://sales/workspace/company/materials/project/background.md", + abstract: "一般项目背景。", + score: 0.88, + }, + { + material_id: "chat_people", + uri: "viking://sales/workspace/company/materials/chat.md", + abstract: "周敏负责方案评审。", + score: 0.82, + }, + ], + }, + ], { + maxContexts: 4, + maxPerMaterial: 2, + }); + + assert.equal(fused.filter((item) => item.material_id === "doc_project").length, 2); + assert.ok(fused.some((item) => item.material_id === "chat_people")); +}); + +test("QA evidence does not force an unrelated dossier section into a focused internal-material answer", () => { + const evidence = buildQaEvidence({ + dossier, + contexts, + question: "谁负责方案评审和商务流程?", + maxItems: 2, + }); + + assert.equal(evidence.length, 2); + assert.equal(evidence[0].material_id, "chat_people"); + assert.ok(evidence.every((item) => item.source_kind !== "企业档案")); +}); + +test("QA chunk overlap keeps a fact intact when it crosses a long-text boundary", () => { + const boundaryContent = `${"背景".repeat(549)}第四季度确认预算,首批试点覆盖两个部门。`; + const evidence = buildQaEvidence({ + contexts: [{ + material_id: "doc_boundary", + title: "客户项目计划", + source_kind: "云文档", + content: boundaryContent, + score: 0.8, + }], + question: "客户什么时候确认预算?", + maxItems: 4, + }); + + assert.ok(evidence.some((item) => item.summary.includes("第四季度确认预算"))); +}); + +test("QA evidence expands a matched chunk with adjacent document context", () => { + const evidence = buildQaEvidence({ + contexts: [{ + material_id: "doc_context_window", + title: "客户采购安排", + source_kind: "云文档", + content: [ + "项目范围:首批验证覆盖两个业务部门。", + "预算窗口:客户计划在第四季度确认 320 万元预算。", + "付款安排:合同签署后支付首款,验收通过后支付尾款。", + ].join("\n\n"), + score: 0.86, + }], + question: "客户什么时候确认预算,付款怎么安排?", + maxItems: 3, + }); + + assert.match(evidence[0].summary, /第四季度确认 320 万元预算/); + assert.match(evidence[0].summary, /验收通过后支付尾款/); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesStage4Workflow.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesStage4Workflow.test.mjs new file mode 100644 index 00000000..d2c7ae2a --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/salesStage4Workflow.test.mjs @@ -0,0 +1,3098 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assessDossierEvidenceCoverage, + SalesService, +} from "../src/services/salesService.js"; + +const permissiveTestPolicy = Object.freeze({ + fail_closed: false, +}); + +const strictRuntimePolicy = Object.freeze({ + fail_closed: true, +}); + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + }; +} + +function seed() { + return { + goals: [], + companies: { + company_1: { + id: "company_1", + name: "测试科技有限公司", + initial: "测", + industry: "企业软件", + location: "北京", + tags: [], + progress: { label: "新商机", summary: "待生成档案", evidence: "暂无", updated_at: null }, + dossier_ids: [], + material_ids: ["material_1"], + qa_session_id: "sales-company_1", + }, + }, + dossiers: {}, + materials: { + material_1: { + id: "material_1", + company_id: "company_1", + title: "客户需求确认会", + summary: "客户希望先验证知识库问答,并要求明确数据权限边界。", + source_type: "飞书会议纪要", + openviking_uri: "viking://resources/workspace-test/companies/company_1/materials/material_1", + updated_at: "2026-07-20T08:00:00.000Z", + }, + }, + qa_messages: { company_1: [] }, + sync_sources: {}, + sync_checkpoints: {}, + jobs: {}, + }; +} + +function stagedDossierPlan(request, { preferredPublicTitle = "" } = {}) { + const evidenceBySection = request.payload.evidence_by_section; + const evidence = (key, predicate = () => true, preferPublic = false) => { + const candidates = evidenceBySection?.[key]?.allowed_evidence || []; + if (preferPublic && preferredPublicTitle) { + const preferred = candidates.find((item) => ( + item.title.includes(preferredPublicTitle) && predicate(item) + )); + if (preferred) return preferred; + } + return candidates.find(predicate) || candidates[0]; + }; + const complete = (value) => ( + /[。!?]$/u.test(String(value || "")) ? String(value) : `${String(value || "")}。` + ); + let businessDynamicsEvidenceId = ""; + const builders = { + company_overview() { + const atom = evidence("company_overview", (item) => ( + /经营范围|面向企业|主营/.test(item.quote) + )); + return { + text: "该企业经营企业软件相关业务。", + evidence_ids: [atom.id], + }; + }, + business_dynamics() { + const atom = evidence("business_dynamics", (item) => ( + item.source_kind === "professional" + && /金融数据库|汽车销量数据库|科研学术数据搜索服务/.test(item.title) + )); + businessDynamicsEvidenceId = atom.id; + return { text: complete(atom.quote), evidence_ids: [atom.id] }; + }, + recent_public_updates() { + const atom = evidence( + "recent_public_updates", + (item) => item.id !== businessDynamicsEvidenceId, + true, + ); + return { text: complete(atom.quote), evidence_ids: [atom.id] }; + }, + risk_attention() { + const atom = evidence("risk_attention"); + return { + text: "业务推进前需要核验企业软件的实施范围和责任边界。", + evidence_ids: [atom.id], + }; + }, + sales_opportunity() { + const atom = evidence("sales_opportunity", (item) => ( + /升级|更新|项目|产品/.test(item.quote) + ), true); + return { + text: "产品更新为销售知识库场景提供试点沟通窗口,但不代表企业已有采购意向。", + evidence_ids: [atom.id], + }; + }, + recommended_actions() { + const atom = evidence("recommended_actions", (item) => ( + /升级|更新|项目|产品|交付/.test(item.quote) + ), true); + return { + text: "销售人员应联系产品负责人确认产品更新范围、试点目标和验收边界。", + evidence_ids: [atom.id], + }; + }, + }; + const required = request.parameters.properties.sections.required; + return { + sections: Object.fromEntries(required.map((key) => [key, builders[key]()])), + }; +} + +function createWorkflowService({ + sharedSessionMessages = new Map(), + seedData = seed(), +} = {}) { + let publicSummary = "测试科技有限公司发布了企业知识库产品更新公告。"; + const modelCalls = []; + const sessionMessages = sharedSessionMessages; + const modelProvider = { + isRunEnabled: () => true, + async callJson(input) { + modelCalls.push(structuredClone(input)); + if (input.operation === "sales_qa") { + const dossier = input.payload.evidence.find((item) => item.source_kind === "企业档案"); + const internal = input.payload.evidence.find((item) => item.source_kind !== "企业档案"); + return { + ok: true, + parsed: { + paragraphs: [ + { text: "当前企业档案显示该企业近期更新了知识库产品。", citation_ids: [dossier.id] }, + { text: "历史沟通中,客户要求先确认数据权限边界。", citation_ids: [internal.id] }, + ], + insufficient: false, + }, + usage: { prompt_tokens: 120, completion_tokens: 60, total_tokens: 180 }, + raw_ref: "model:qa-1", + }; + } + if (input.operation === "sales_dossier_agent_plan" || input.operation === "sales_dossier_agent_replan") { + return { + ok: true, + parsed: stagedDossierPlan(input, { + preferredPublicTitle: "测试科技有限公司产品更新公告", + }), + usage: { prompt_tokens: 180, completion_tokens: 80, total_tokens: 260 }, + raw_ref: `model:dossier-plan-${modelCalls.length}`, + }; + } + throw new Error(`unexpected dossier operation: ${input.operation}`); + }, + async callRequiredFunction(input) { + return this.callJson(input); + }, + }; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test", ASYNC_JOBS_ENABLED: "false" }), + runtimePolicy: permissiveTestPolicy, + seed: seedData, + dataProProvider: { + maxSources: 1, + isRunEnabled: () => true, + async callTool(query) { + return { + ok: true, + summary: "测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件。", + raw_ref: "datapro:company_1", + query, + }; + }, + }, + webSearchProvider: { + isRunEnabled: () => true, + async search() { + return { + ok: true, + results: [{ + title: "测试科技有限公司产品更新公告", + summary: publicSummary, + url: "https://news.test/company-1-update", + publish_time: "2026-07-20T09:00:00.000Z", + }], + }; + }, + }, + modelProvider, + openVikingProvider: { + isConfigured: () => true, + isRunEnabled: () => true, + salesCompanyUri: ({ workspaceId, companyId }) => `viking://resources/${workspaceId}/companies/${companyId}`, + salesSessionId: ({ workspaceId, companyId }) => `sales-${workspaceId}-${companyId}`, + async findMemories() { + return { + ok: true, + result: { + resources: [ + { + uri: "viking://resources/workspace-test/companies/company_1/materials/material_1.md", + title: "material_1.md", + abstract: "客户希望先验证知识库问答,并要求明确数据权限边界。", + }, + { + uri: "viking://resources/workspace-test/companies/company_1/materials/overview.md", + title: "overview", + abstract: "内部目录 company_dp_should_not_be_visible 的实现说明。", + }, + ], + }, + }; + }, + async getSessionContext(sessionId) { + const messages = sessionMessages.get(sessionId) || []; + if (!messages.length) { + return { ok: false, http_status: 404, error: { code: "not_found", message: "Session not found" } }; + } + return { + ok: true, + session_id: sessionId, + messages, + latest_archive_overview: "", + raw_ref: `openviking:session:${sessionId}:context`, + }; + }, + async addSessionMessages(sessionId, messages) { + const existing = sessionMessages.get(sessionId) || []; + const appended = messages.map((message, index) => ({ + id: `session-message-${existing.length + index + 1}`, + role: message.role, + text: message.content, + created_at: "2026-07-26T10:00:00.000Z", + })); + sessionMessages.set(sessionId, [...existing, ...appended]); + return { + ok: true, + session_id: sessionId, + raw_ref: `openviking:session:${sessionId}:messages`, + }; + }, + async recordSessionUsed() { + return { ok: true }; + }, + async commitSession(sessionId) { + return { ok: true, raw_ref: `openviking:session:${sessionId}:commit` }; + }, + }, + }); + return { + service, + modelCalls, + sessionMessages, + changePublicSummary(value) { + publicSummary = value; + }, + }; +} + +test("dossier generation skips unchanged evidence and versions material changes", async () => { + const fixture = createWorkflowService(); + const first = await fixture.service.createDossier("company_1"); + const firstModelCalls = fixture.modelCalls.filter((call) => call.operation === "sales_dossier_agent_plan"); + + assert.equal(first.action, "created"); + assert.equal(first.detail.version_no, 1); + assert.equal(first.detail.previous_dossier_id, null); + assert.equal(Object.hasOwn(first.detail, "evidence_hash"), false); + assert.equal(Object.hasOwn(first.detail, "dossier_fingerprint"), false); + assert.equal(Object.hasOwn(first.detail, "provider_run_id"), false); + assert.equal(firstModelCalls.length, 1); + assert.equal(first.detail.body.length, 6); + assert.ok( + first.detail.body.every((paragraph) => paragraph.citation_ids.length > 0), + JSON.stringify({ body: first.detail.body, citations: first.detail.citations }, null, 2), + ); + assert.ok(first.detail.citations.every((citation) => ["专业数据集", "联网搜索"].includes(citation.source_kind))); + assert.equal(first.detail.citations.some((citation) => citation.source_kind === "内部资料"), false); + assert.equal(firstModelCalls[0].payload.citations, undefined); + assert.doesNotMatch( + JSON.stringify(firstModelCalls[0].payload.evidence_by_section), + /内部资料|openviking|viking:\/\//iu, + ); + + const unchanged = await fixture.service.createDossier("company_1"); + assert.equal(unchanged.action, "no_material_change"); + assert.equal(unchanged.detail.id, first.detail.id); + assert.equal(fixture.modelCalls.filter((call) => call.operation === "sales_dossier_agent_plan").length, 1); + assert.equal(Object.keys(fixture.service.data.dossiers).length, 1); + + fixture.changePublicSummary("测试科技有限公司新增了面向销售团队的知识库协作能力。"); + const changed = await fixture.service.createDossier("company_1"); + assert.equal(changed.action, "created"); + assert.equal(changed.detail.version_no, 2); + assert.equal(changed.detail.previous_dossier_id, first.detail.id); + assert.equal( + fixture.modelCalls.filter((call) => call.operation === "sales_dossier_agent_plan").length, + 2, + ); + assert.equal(Object.keys(fixture.service.data.dossiers).length, 2); + + const hiddenDossierId = "dossier-hidden-v9"; + const changedRecord = fixture.service.data.dossiers[changed.detail.id]; + fixture.service.data.dossiers[hiddenDossierId] = { + ...structuredClone(changedRecord), + id: hiddenDossierId, + version_no: 9, + summary: "测试科技有限公司是否有法律诉讼-启信宝。", + body: changedRecord.body.map((paragraph, index) => ( + index === 2 + ? { ...paragraph, text: "近期公开动态:测试科技有限公司是否有法律诉讼-启信宝。" } + : structuredClone(paragraph) + )), + evidence_hash: "hidden-low-quality-evidence", + created_at: "2026-07-29T10:00:00.000Z", + }; + fixture.service.data.companies.company_1.dossier_ids.unshift(hiddenDossierId); + fixture.changePublicSummary("测试科技有限公司新增了面向销售负责人的客户洞察能力。"); + const afterHiddenVersion = await fixture.service.createDossier("company_1"); + assert.equal(afterHiddenVersion.action, "created"); + assert.equal(afterHiddenVersion.detail.version_no, 10); + assert.equal(afterHiddenVersion.detail.previous_dossier_id, changed.detail.id); + assert.equal(fixture.service.listDossiers("company_1").some((item) => item.id === hiddenDossierId), false); + + const jobs = await fixture.service.listJobs({ job_type: "sales_dossier_generation" }); + assert.equal(jobs.length, 4); + assert.ok(jobs.every((job) => job.status === "succeeded")); +}); + +test("dossier generation does not persist a second version when added evidence leaves the public report unchanged", async () => { + const fixture = createWorkflowService(); + const stablePublicResults = [ + { + title: "测试科技有限公司产品更新公告", + summary: "测试科技有限公司发布了企业知识库产品更新公告。", + url: "https://news.test/company-1-update", + publish_time: "2026-07-20T09:00:00.000Z", + }, + { + title: "测试科技有限公司产品交付说明", + summary: "测试科技有限公司披露企业知识库产品交付范围与实施安排。", + url: "https://news.test/company-1-delivery", + publish_time: "2026-07-18T09:00:00.000Z", + }, + ]; + fixture.service.webSearchProvider.search = async () => ({ + ok: true, + results: stablePublicResults, + }); + const first = await fixture.service.createDossier("company_1"); + fixture.service.webSearchProvider.search = async () => ({ + ok: true, + results: [ + ...stablePublicResults, + { + title: "测试科技有限公司产品更新补充说明", + summary: "测试科技有限公司补充披露了企业知识库产品更新安排。", + url: "https://news.test/company-1-update-note", + publish_time: "2026-07-19T09:00:00.000Z", + }, + ], + }); + fixture.service.modelProvider.callRequiredFunction = async (input) => { + fixture.modelCalls.push(structuredClone(input)); + if (input.operation === "sales_dossier_agent_plan") { + return { + ok: true, + parsed: stagedDossierPlan(input, { + preferredPublicTitle: "测试科技有限公司产品更新公告", + }), + raw_ref: "model:same-report-plan", + }; + } + throw new Error(`unexpected dossier operation: ${input.operation}`); + }; + + const second = await fixture.service.createDossier("company_1"); + + assert.equal(second.action, "no_report_change"); + assert.equal(second.detail.id, first.detail.id); + assert.equal(Object.keys(fixture.service.data.dossiers).length, 1); +}); + +test("unchanged evidence regenerates a legacy dossier that no longer meets citation coverage", async () => { + const fixture = createWorkflowService(); + const first = await fixture.service.createDossier("company_1"); + const stored = fixture.service.data.dossiers[first.detail.id]; + const professionalId = stored.citations.find((citation) => citation.source_kind === "专业数据集")?.id; + assert.ok(professionalId); + stored.body = stored.body.map((paragraph) => ({ + ...paragraph, + citation_ids: [professionalId], + segments: (paragraph.segments || []).map((segment) => ({ + ...segment, + citation_ids: [professionalId], + })), + })); + + const regenerated = await fixture.service.createDossier("company_1"); + assert.equal(regenerated.action, "created"); + assert.equal(regenerated.detail.version_no, 2); + assert.equal(regenerated.detail.previous_dossier_id, first.detail.id); + assert.equal(fixture.modelCalls.filter((call) => call.operation === "sales_dossier_agent_plan").length, 2); + assert.ok(regenerated.detail.citations.length >= 2); +}); + +test("dossier evidence collection supplements professional data with public risk queries", async () => { + const webQueries = []; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + dataProProvider: { + maxSources: 2, + isRunEnabled: () => true, + planDossierQueries: () => [ + { + label: "企业工商数据库", + purpose: "主体与经营信息核验", + query: "测试科技有限公司 企业工商数据", + }, + { + label: "企业风险数据库", + purpose: "风险与关注事项核验", + query: "测试科技有限公司 企业风险数据", + }, + ], + async callTool(query) { + return { + ok: true, + summary: query.includes("风险") + ? "企业风险信息包含经营异常、行政处罚、司法诉讼和限制高消费等核验维度。" + : "测试科技有限公司经营范围包括企业软件与知识库产品。", + raw_ref: `datapro:${query}`, + }; + }, + }, + webSearchProvider: { + isRunEnabled: () => true, + async search(input) { + webQueries.push(input.query); + return { + ok: true, + results: [{ + title: `${input.query}公开结果`, + summary: "公开来源披露了与该查询相关的企业事项。", + url: `https://news.test/${webQueries.length}`, + publish_time: "2026-07-20T09:00:00.000Z", + }], + }; + }, + }, + }); + + await service.collectDossierEvidence(service.data.companies.company_1); + + assert.ok(webQueries.some((query) => ( + /行政处罚/.test(query) + && /司法诉讼/.test(query) + && /失信被执行/.test(query) + && /经营异常/.test(query) + ))); +}); + +test("dossier evidence collection resumes completed provider queries from a durable checkpoint", async () => { + let dataProCalls = 0; + let webCalls = 0; + let savedCheckpoint = null; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + dataProProvider: { + maxSources: 2, + isRunEnabled: () => true, + planDossierQueries: () => [ + { + label: "企业工商数据库", + purpose: "主体与经营信息核验", + query: "测试科技有限公司 企业工商数据", + }, + { + label: "企业风险数据库", + purpose: "风险与关注事项核验", + query: "测试科技有限公司 企业风险数据", + }, + ], + async callTool(query) { + dataProCalls += 1; + return { + ok: true, + summary: query.includes("风险") + ? "测试科技有限公司的企业风险数据包含司法诉讼、行政处罚和经营异常核验结果。" + : "公司名称:测试科技有限公司;经营范围:企业软件与知识库产品。", + raw_ref: `datapro:${dataProCalls}`, + }; + }, + }, + webSearchProvider: { + isRunEnabled: () => true, + async search() { + webCalls += 1; + return { + ok: true, + results: [{ + title: "测试科技有限公司发布知识库项目公告", + summary: "测试科技有限公司于2026年7月发布知识库项目公告,并披露产品交付安排。", + url: `https://official.test/update-${webCalls}`, + site_name: "测试科技有限公司", + auth_level: 2, + publish_time: "2026-07-25T09:00:00.000Z", + }], + }; + }, + }, + }); + const company = service.data.companies.company_1; + + const first = await service.collectDossierEvidence(company, "", { + save_checkpoint: async (checkpoint) => { + savedCheckpoint = structuredClone(checkpoint); + }, + }); + const firstDataProCalls = dataProCalls; + const firstWebCalls = webCalls; + assert.ok(first.professional.length >= 2); + assert.ok(first.public_sources.length >= 1); + assert.ok(savedCheckpoint.completed_query_keys.length >= firstDataProCalls + firstWebCalls); + + const resumed = await service.collectDossierEvidence(company, "", { + checkpoint: savedCheckpoint, + save_checkpoint: async (checkpoint) => { + savedCheckpoint = structuredClone(checkpoint); + }, + }); + + assert.equal(dataProCalls, firstDataProCalls); + assert.equal(webCalls, firstWebCalls); + assert.deepEqual(resumed.professional, first.professional); + assert.deepEqual(resumed.public_sources, first.public_sources); +}); + +test("dossier evidence collection uses bounded concurrency for independent provider queries", async () => { + let activeDataPro = 0; + let maxActiveDataPro = 0; + let activeWeb = 0; + let maxActiveWeb = 0; + const service = new SalesService({ + env: envReader({ + APP_WORKSPACE_ID: "workspace-test", + DOSSIER_DATAPRO_CONCURRENCY: "2", + DOSSIER_WEB_CONCURRENCY: "3", + }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + dataProProvider: { + maxSources: 3, + isRunEnabled: () => true, + planDossierQueries: () => [1, 2, 3].map((index) => ({ + label: index === 1 ? "企业工商数据库" : `专业数据库 ${index}`, + purpose: `专业核验 ${index}`, + query: `测试科技有限公司 专业查询 ${index}`, + })), + async callTool() { + activeDataPro += 1; + maxActiveDataPro = Math.max(maxActiveDataPro, activeDataPro); + await new Promise((resolve) => setTimeout(resolve, 5)); + activeDataPro -= 1; + return { + ok: true, + summary: "公司名称:测试科技有限公司;经营范围:企业软件与知识库产品。", + }; + }, + }, + webSearchProvider: { + isRunEnabled: () => true, + async search(input) { + activeWeb += 1; + maxActiveWeb = Math.max(maxActiveWeb, activeWeb); + await new Promise((resolve) => setTimeout(resolve, 5)); + activeWeb -= 1; + return { + ok: true, + results: [{ + title: "测试科技有限公司发布知识库项目公告", + summary: "测试科技有限公司于2026年7月发布知识库项目公告,并披露产品交付安排。", + url: `https://official.test/${encodeURIComponent(input.query)}`, + publish_time: "2026-07-25T09:00:00.000Z", + }], + }; + }, + }, + }); + + await service.collectDossierEvidence(service.data.companies.company_1); + + assert.equal(maxActiveDataPro, 2); + assert.equal(maxActiveWeb, 3); + assert.ok(maxActiveDataPro <= 2); + assert.ok(maxActiveWeb <= 3); +}); + +test("dossier evidence collection follows coverage gaps with bounded topic queries", async () => { + const webQueries = []; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + dataProProvider: { + maxSources: 1, + isRunEnabled: () => true, + planDossierQueries: () => [{ + label: "企业工商数据库", + purpose: "主体信息核验", + query: "测试科技有限公司 企业工商数据", + }], + async callTool() { + return { + ok: true, + summary: "公司名称:测试科技有限公司;经营范围:企业软件与知识库产品。", + raw_ref: "datapro:company", + }; + }, + }, + webSearchProvider: { + isRunEnabled: () => true, + async search(input) { + webQueries.push(input.query); + if (input.query.includes("官方公告 项目 合作 投资")) { + return { + ok: true, + results: [{ + title: "测试科技有限公司发布知识库项目合作公告", + summary: "测试科技有限公司于2026年7月发布知识库项目合作公告,并推进产品交付。", + url: "https://official.test/project", + publish_time: "2026-07-25T09:00:00.000Z", + }], + }; + } + if (input.query.includes("监管 处罚 诉讼 召回 经营异常")) { + return { + ok: true, + results: [{ + title: "测试科技有限公司行政处罚整改公告", + summary: "测试科技有限公司于2026年7月披露行政处罚整改进展,相关事项已进入整改阶段。", + url: "https://regulator.test/risk", + publish_time: "2026-07-24T09:00:00.000Z", + }], + }; + } + return { + ok: true, + results: [{ + title: "测试科技有限公司企业介绍", + summary: "测试科技有限公司提供企业软件、知识库和内容检索产品与服务。", + url: "https://profile.test/company", + publish_time: "2026-07-20T09:00:00.000Z", + }], + }; + }, + }, + }); + + const company = service.data.companies.company_1; + const collected = await service.collectDossierEvidence(company); + const coverage = assessDossierEvidenceCoverage(company, collected); + + assert.ok(webQueries.length > 5); + assert.ok(webQueries.length <= 9); + assert.ok(webQueries.some((query) => query.includes("官方公告 项目 合作 投资"))); + assert.ok(webQueries.some((query) => query.includes("监管 处罚 诉讼 召回 经营异常"))); + assert.equal(coverage.recent_public, true); + assert.equal(coverage.operations, true); + assert.equal(coverage.risk, true); +}); + +test("dossier evidence collection searches a scoped brand alias for China investment companies", async () => { + const webQueries = []; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + dataProProvider: { isRunEnabled: () => false }, + webSearchProvider: { + isRunEnabled: () => true, + async search(input) { + webQueries.push(input.query); + return { ok: true, results: [] }; + }, + }, + }); + + await service.collectDossierEvidence({ + id: "company_bosch", + name: "博世(中国)投资有限公司", + aliases: [], + }); + + assert.ok(webQueries.some((query) => /^博世 2026/.test(query))); + assert.ok(webQueries.some((query) => /^博世(中国)投资有限公司 2026/.test(query))); +}); + +test("dossier evidence collection follows a discovered authoritative host when no usable recent event exists", async () => { + const webQueries = []; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + dataProProvider: { isRunEnabled: () => false }, + webSearchProvider: { + isRunEnabled: () => true, + async search(input) { + webQueries.push(structuredClone(input)); + if (/^site:bosch\.com\.cn/.test(input.query)) { + return { + ok: true, + results: [ + { + title: "博世中国与合作伙伴签署智能驾驶战略合作协议", + summary: "博世中国宣布与合作伙伴签署智能驾驶战略合作协议,双方将推进面向中国市场的量产应用。", + url: "https://bosch.com.cn/news-and-stories/strategic-cooperation/", + site_name: "博世", + auth_level: 2, + publish_time: "2026-07-20T09:00:00.000Z", + }, + { + title: "博世中国披露智能制造项目进展", + summary: "博世中国披露智能制造项目进展,项目将加强本地研发、生产和供应链协同能力。", + url: "https://bosch.com.cn/news-and-stories/manufacturing-project/", + site_name: "博世", + auth_level: 2, + publish_time: "2026-07-21T09:00:00.000Z", + }, + ], + }; + } + if (/^博世 2026/.test(input.query)) { + return { + ok: true, + results: [{ + title: "博世在中国", + summary: "博世在中国持续提供汽车技术、工业技术与消费品相关产品和服务。", + url: "https://bosch.com.cn/our-company/bosch-in-china/", + site_name: "博世", + auth_level: 2, + }], + }; + } + return { ok: true, results: [] }; + }, + }, + }); + + const collected = await service.collectDossierEvidence({ + id: "company_bosch", + name: "博世(中国)投资有限公司", + aliases: [], + }); + + assert.ok(webQueries.every((input) => input.auth_level === 1)); + assert.ok(webQueries.some((input) => /^site:bosch\.com\.cn/.test(input.query))); + assert.ok(collected.public_sources.some((source) => /智能驾驶战略合作/.test(source.label))); + assert.ok(collected.public_sources.some((source) => /智能制造项目进展/.test(source.label))); +}); + +test("brand-scoped evidence must not be written as a confirmed legal-entity event", () => { + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + }); + const company = { + id: "company_bosch", + name: "博世(中国)投资有限公司", + aliases: ["博世"], + }; + const citations = [ + { + id: "professional_bosch", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "公司名称:博世(中国)投资有限公司;经营范围:机械制造、电子和信息产业投资。", + entity_match: "verified", + }, + { + id: "public_bosch", + label: "博世与合作伙伴签署智能驾驶战略合作协议", + source_kind: "联网搜索", + summary: "博世与合作伙伴签署智能驾驶战略合作协议,双方将推进面向中国市场的量产应用。", + url: "https://bosch.com.cn/news-and-stories/strategic-cooperation/", + entity_match: "alias_scoped", + }, + ]; + const body = [ + { text: "企业与业务概览:博世(中国)投资有限公司从事机械制造、电子和信息产业相关投资与业务。", citation_ids: ["professional_bosch"] }, + { text: "经营与业务动态:该法定主体的专业资料显示其业务范围覆盖机械制造、电子和信息产业投资。", citation_ids: ["professional_bosch"] }, + { text: "近期公开动态:博世(中国)投资有限公司与合作伙伴签署智能驾驶战略合作协议。", citation_ids: ["public_bosch"] }, + { text: "风险与关注事项:商务推进前应核验具体签约主体、项目责任边界和量产安排。", citation_ids: ["professional_bosch"] }, + { text: "销售机会判断:智能驾驶合作形成技术与量产协同的沟通窗口,但不代表目标企业已经形成采购意向。", citation_ids: ["professional_bosch", "public_bosch"] }, + { text: "建议行动:1. 核验签约主体和项目阶段。\n2. 联系业务与采购负责人。\n3. 准备量产协同方案。", citation_ids: ["professional_bosch", "public_bosch"] }, + ]; + + const errors = service.publicDossierQualityErrors({ body, citations }, company); + assert.ok(errors.some((item) => item.includes("主体边界"))); + + const corrected = structuredClone(body); + corrected[2].text = "近期公开动态:博世集团相关业务与合作伙伴签署智能驾驶战略合作协议,具体法定签约主体仍需核验。"; + assert.deepEqual(service.publicDossierQualityErrors({ body: corrected, citations }, company), []); +}); + +test("dossier readability gate rejects a search-title fragment but accepts concise complete facts", () => { + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + }); + const company = service.data.companies.company_1; + const citations = [ + { + id: "professional_1", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "测试科技有限公司主营企业软件和知识库产品。", + }, + { + id: "public_1", + label: "测试科技有限公司项目中标公告", + source_kind: "联网搜索", + summary: "测试科技有限公司于2026年7月中标某企业知识库建设项目。", + url: "https://official.test/win", + published_at: "2026-07-22T09:00:00.000Z", + }, + ]; + const body = [ + { text: "企业与业务概览:测试科技有限公司主营企业软件和知识库产品。", citation_ids: ["professional_1"] }, + { text: "经营与业务动态:该企业持续经营知识库建设和内容检索业务。", citation_ids: ["professional_1"] }, + { text: "近期公开动态:测试科技有限公司-最新中标结果发布。", citation_ids: ["public_1"] }, + { text: "风险与关注事项:项目交付需确认数据权限和验收范围。", citation_ids: ["professional_1", "public_1"] }, + { text: "销售机会判断:该项目为知识库交付形成了沟通窗口。", citation_ids: ["professional_1", "public_1"] }, + { text: "建议行动:1. 联系项目负责人。\n2. 核验交付范围。\n3. 准备验收方案。", citation_ids: ["professional_1", "public_1"] }, + ]; + + const rejected = service.publicDossierQualityErrors({ body, citations }, company); + assert.ok(rejected.some((item) => item.includes("搜索标题或事件标题残片"))); + + const corrected = structuredClone(body); + corrected[2].text = "近期公开动态:测试科技有限公司于2026年7月中标企业知识库建设项目。"; + assert.deepEqual(service.publicDossierQualityErrors({ body: corrected, citations }, company), []); + + const riskStatement = structuredClone(corrected); + riskStatement[3].text = "风险与关注事项:项目推进前需要确认企业是否具备相应的数据权限和交付条件。"; + assert.deepEqual(service.publicDossierQualityErrors({ body: riskStatement, citations }, company), []); + + const directQuestion = structuredClone(corrected); + directQuestion[3].text = "风险与关注事项:该企业是否具备相应的数据权限和交付条件?"; + assert.ok( + service.publicDossierQualityErrors({ body: directQuestion, citations }, company) + .some((item) => item.includes("问句")), + ); +}); + +test("dossier Agent revises an invalid six-section plan before deterministic compilation", async () => { + const modelCalls = []; + const modelProvider = { + isRunEnabled: () => true, + async callRequiredFunction(input) { + modelCalls.push(structuredClone(input)); + if (input.operation === "sales_dossier_agent_plan") { + const invalid = stagedDossierPlan(input); + invalid.sections.company_overview.text = "企业产品更新公告"; + return { + ok: true, + parsed: invalid, + raw_ref: "model:dossier-invalid-plan", + }; + } + return { + ok: true, + parsed: stagedDossierPlan(input), + raw_ref: "model:dossier-repaired", + }; + }, + }; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + modelProvider, + }); + const evidencePack = { + evidence_hash: "evidence-pack-test", + items: [ + { + id: "evidence_professional", + label: "企业工商数据库", + source_kind_label: "专业数据集", + summary: "测试科技有限公司面向企业客户提供软件产品。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-company", + }, + { + id: "evidence_risk", + label: "企业风险数据库", + source_kind_label: "专业数据集", + summary: "本次查询未发现可直接下结论的重大风险记录,仍需核验来源日期。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-risk", + }, + { + id: "evidence_professional_2", + label: "金融数据库", + source_kind_label: "专业数据集", + summary: "测试科技有限公司持续经营企业软件和知识库产品相关业务,并推进内容检索能力升级。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-market", + }, + { + id: "evidence_public", + label: "企业产品更新公告", + source_kind_label: "联网搜索", + summary: "测试科技有限公司近期发布了产品更新公告。", + provider: "web_search", + url: "https://news.test/company-update", + quality_tier: 2, + independence_key: "news.test", + }, + { + id: "evidence_public_2", + label: "企业交付计划公告", + source_kind_label: "联网搜索", + summary: "测试科技有限公司近期披露产品交付计划,明确将分阶段推进知识库协作能力上线。", + provider: "web_search", + url: "https://official.test/company-delivery", + quality_tier: 2, + independence_key: "official.test", + }, + { + id: "evidence_internal", + label: "客户需求确认会", + source_kind_label: "内部资料", + summary: "客户希望先验证知识库问答,并要求明确数据权限边界。", + provider: "openviking", + uri: "viking://resources/workspaces/test/companies/company_1/materials/material_1", + quality_tier: 2, + independence_key: "internal-material-1", + }, + ], + }; + + const dossier = await service.generateDossierWithModel( + service.data.companies.company_1, + evidencePack, + [], + ); + + assert.equal(modelCalls.length, 2); + assert.equal(modelCalls[0].operation, "sales_dossier_agent_plan"); + assert.equal(modelCalls[0].payload.allowed_citation_ids, undefined); + assert.equal(modelCalls[0].payload.citations, undefined); + assert.ok(modelCalls[0].payload.evidence_by_section); + assert.equal( + JSON.stringify(modelCalls[0].payload.evidence_by_section).includes("evidence_internal"), + false, + ); + assert.equal(modelCalls[0].functionName, "plan_sales_dossier"); + assert.match(modelCalls[0].system, /最终引用全部由服务端根据 Evidence Atom 确定性派生/); + assert.match( + modelCalls[0].system, + /企业与业务概览用于交代主体、主营方向、业务定位和来源能够直接支持的业务应用场景.*不得在本章写采购场景、采购需求、采购计划或采购意向/u, + ); + assert.equal(modelCalls[0].parameters.properties.sections.required.length, 6); + assert.deepEqual( + Object.keys( + modelCalls[0].parameters.properties.sections.properties.company_overview.properties, + ), + ["text", "evidence_ids"], + ); + assert.equal(modelCalls[1].operation, "sales_dossier_agent_replan"); + assert.ok(modelCalls[1].payload.planning_errors.some((item) => item.includes("完整句子"))); + assert.doesNotMatch(JSON.stringify(dossier), /关键字段存在来源差异|来源冲突/); + assert.equal(dossier.body.length, 6); + assert.deepEqual(dossier.body[0].citation_ids, ["evidence_professional"]); + assert.deepEqual(dossier.body[3].citation_ids, ["evidence_public_2"]); + assert.equal(dossier.raw_ref, "model:dossier-repaired"); +}); + +test("dossier Agent retries one incomplete planning response", async () => { + const modelCalls = []; + const modelProvider = { + isRunEnabled: () => true, + async callRequiredFunction(input) { + modelCalls.push(structuredClone(input)); + if (modelCalls.length === 1) { + return { + ok: false, + error: { + code: "incomplete_response", + message: "The function response reached its output budget.", + retryable: true, + }, + raw_ref: "model:dossier-incomplete", + }; + } + return { + ok: true, + parsed: stagedDossierPlan(input), + usage: { prompt_tokens: 160, completion_tokens: 80, total_tokens: 240 }, + raw_ref: "model:dossier-retry", + }; + }, + }; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + modelProvider, + }); + const evidencePack = { + evidence_hash: "evidence-pack-json-retry", + items: [ + { + id: "evidence_professional", + label: "企业工商数据库", + source_kind_label: "专业数据集", + summary: "测试科技有限公司面向企业客户提供软件产品。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-company", + }, + { + id: "evidence_professional_2", + label: "金融数据库", + source_kind_label: "专业数据集", + summary: "测试科技有限公司持续经营企业软件与知识库产品相关业务。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-market", + }, + { + id: "evidence_public", + label: "企业产品更新公告", + source_kind_label: "联网搜索", + summary: "测试科技有限公司近期发布了产品更新公告。", + provider: "web_search", + url: "https://news.test/company-update", + quality_tier: 2, + independence_key: "news.test", + }, + { + id: "evidence_public_2", + label: "企业交付计划公告", + source_kind_label: "联网搜索", + summary: "测试科技有限公司近期披露产品交付计划,明确分阶段推进知识库能力上线。", + provider: "web_search", + url: "https://official.test/company-delivery", + quality_tier: 2, + independence_key: "official.test", + }, + ], + }; + + const dossier = await service.generateDossierWithModel( + service.data.companies.company_1, + evidencePack, + [], + ); + + assert.equal(modelCalls.length, 2); + assert.equal(modelCalls[0].operation, "sales_dossier_agent_plan"); + assert.equal(modelCalls[0].maxTokens, 2400); + assert.equal(modelCalls[1].operation, "sales_dossier_agent_plan"); + assert.equal(modelCalls[1].maxTokens, 2400); + assert.equal(dossier.body.length, 6); + assert.equal(dossier.raw_ref, "model:dossier-retry"); +}); + +test("dossier Agent fails closed after three incomplete planning responses", async () => { + const modelCalls = []; + const modelProvider = { + isRunEnabled: () => true, + async callRequiredFunction(input) { + modelCalls.push(structuredClone(input)); + return { + ok: false, + error: { + code: "incomplete_response", + message: "Model returned an incomplete function call.", + retryable: true, + }, + raw_ref: `model:incomplete-function-${modelCalls.length}`, + }; + }, + }; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + modelProvider, + }); + const evidencePack = { + evidence_hash: "evidence-pack-json-reconstruction", + items: [ + { + id: "evidence_company", + label: "企业工商数据库", + source_kind_label: "专业数据集", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件、知识库与内容检索产品。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-company", + }, + { + id: "evidence_market", + label: "科研学术数据搜索服务", + source_kind_label: "专业数据集", + summary: "测试科技有限公司持续开展企业知识库、内容检索和智能协作相关技术研发。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-research", + }, + { + id: "evidence_product_update", + label: "测试科技有限公司产品升级公告", + source_kind_label: "联网搜索", + summary: "测试科技有限公司于2026年7月发布销售知识库产品升级公告,新增内容检索和协作管理能力。", + provider: "web_search", + url: "https://news.test/product-update", + published_at: "2026-07-20T09:00:00.000Z", + quality_tier: 2, + independence_key: "news.test", + }, + { + id: "evidence_procurement", + label: "测试科技有限公司项目采购结果公告", + source_kind_label: "联网搜索", + summary: "公开采购结果显示测试科技有限公司参与企业知识库建设项目,项目范围包括内容治理与检索能力交付。", + provider: "web_search", + url: "https://procurement.test/project-result", + published_at: "2026-07-21T09:00:00.000Z", + quality_tier: 2, + independence_key: "procurement.test", + }, + ], + }; + + await assert.rejects( + () => service.generateDossierWithModel( + service.data.companies.company_1, + evidencePack, + [], + ), + (error) => ( + error.status === 503 + && error.code === "model_unavailable" + && error.details.reason === "incomplete_response" + ), + ); + + assert.equal(modelCalls.length, 3); + assert.equal(modelCalls[0].operation, "sales_dossier_agent_plan"); + assert.equal(modelCalls[1].operation, "sales_dossier_agent_plan"); + assert.equal(modelCalls[2].operation, "sales_dossier_agent_plan"); +}); + +test("QA derives paragraph citations from allowed evidence and records model usage", async () => { + const fixture = createWorkflowService(); + await fixture.service.createDossier("company_1"); + const result = await fixture.service.askQuestion("company_1", { question: "客户最关注什么,下一步怎么推进?" }); + + assert.ok(result.job_id); + assert.ok(result.provider_run_id); + assert.equal(result.message.paragraphs.length, 2); + assert.equal(result.message.citations.length, 2); + assert.ok(result.message.paragraphs.every((paragraph) => paragraph.citation_ids.length > 0)); + assert.ok(result.message.citation_ids.every((id) => result.message.citations.some((citation) => citation.id === id))); + const qaCall = fixture.modelCalls.find((call) => call.operation === "sales_qa"); + assert.deepEqual( + [...new Set(qaCall.payload.evidence.map((item) => item.source_kind))].sort(), + ["企业档案", "云文档"].sort(), + ); + const materialEvidence = qaCall.payload.evidence.find((item) => item.source_kind === "云文档"); + assert.equal(materialEvidence.label, "客户需求确认会"); + assert.doesNotMatch(JSON.stringify(qaCall.payload.evidence), /overview|company_dp_should_not_be_visible/i); + assert.match(qaCall.system, /正式展示标题/); + assert.match(qaCall.system, /不得输出 evidence\.uri/); + assert.match(qaCall.system, /不得自行增加“补充”/); + + const run = await fixture.service.getProviderRun(result.provider_run_id); + const modelStep = run.steps.find((step) => step.provider === "model"); + assert.equal(run.job_id, result.job_id); + assert.equal(modelStep.usage.total_tokens, 180); + assert.equal((await fixture.service.getJob(result.job_id)).status, "succeeded"); +}); + +test("QA hydrates the full OpenViking resource before chunking and reranking", async () => { + const fixture = createWorkflowService(); + fixture.service.openVikingProvider.readTextResource = async () => ({ + ok: true, + content: [ + `${"一般会议背景。".repeat(180)}\n\n预算窗口:客户计划在第四季度确认预算,首批试点覆盖两个业务部门。`, + "", + ].join("\n"), + }); + await fixture.service.createDossier("company_1"); + await fixture.service.askQuestion("company_1", { question: "客户的预算窗口和试点范围是什么?" }); + + const qaCall = fixture.modelCalls.find((call) => call.operation === "sales_qa"); + assert.ok(qaCall.payload.evidence.some((item) => ( + item.source_kind === "云文档" + && item.summary.includes("第四季度确认预算") + && item.summary.includes("两个业务部门") + ))); + assert.doesNotMatch(JSON.stringify(qaCall.payload.evidence), /sales-workbench-material-v1|cHJpdmF0ZS1zeW5jLXNuYXBzaG90/); + assert.ok(qaCall.payload.retrieval_plan.answerability.supported); +}); + +test("QA sends bounded prior turns to the model for follow-up questions", async () => { + const fixture = createWorkflowService(); + await fixture.service.createDossier("company_1"); + await fixture.service.askQuestion("company_1", { question: "客户最关注什么?" }); + await fixture.service.askQuestion("company_1", { question: "那下一步怎么推进?" }); + + const qaCalls = fixture.modelCalls.filter((call) => call.operation === "sales_qa"); + assert.equal(qaCalls.length, 2); + assert.deepEqual( + qaCalls[0].payload.conversation_history, + [], + ); + assert.equal(qaCalls[1].payload.question, "那下一步怎么推进?"); + assert.equal(qaCalls[1].payload.conversation_history.length, 2); + assert.equal(qaCalls[1].payload.conversation_history[0].role, "user"); + assert.equal(qaCalls[1].payload.conversation_history[0].text, "客户最关注什么?"); + assert.equal(qaCalls[1].payload.conversation_history[1].role, "assistant"); + assert.match(qaCalls[1].payload.conversation_history[1].text, /知识库产品|数据权限边界/); + assert.equal( + qaCalls[1].payload.conversation_history.some((message) => message.text === "那下一步怎么推进?"), + false, + ); +}); + +test("QA restores recent turns and citations from OpenViking after a process restart", async () => { + const sharedSessionMessages = new Map(); + const firstRuntime = createWorkflowService({ sharedSessionMessages }); + await firstRuntime.service.createDossier("company_1"); + await firstRuntime.service.askQuestion("company_1", { question: "客户最关注什么?" }); + + const persistedSeed = structuredClone(firstRuntime.service.data); + persistedSeed.qa_messages = { company_1: [] }; + const restartedRuntime = createWorkflowService({ + sharedSessionMessages, + seedData: persistedSeed, + }); + + const restored = await restartedRuntime.service.getQa("company_1"); + assert.equal(restored.messages.length, 2); + assert.equal(restored.messages[0].role, "user"); + assert.equal(restored.messages[0].text, "客户最关注什么?"); + assert.equal(restored.messages[1].role, "assistant"); + assert.equal(restored.messages[1].citations.length, 2); + + await restartedRuntime.service.askQuestion("company_1", { question: "那下一步怎么推进?" }); + const qaCall = restartedRuntime.modelCalls.find((call) => call.operation === "sales_qa"); + assert.equal(qaCall.payload.conversation_history.length, 2); + assert.equal(qaCall.payload.conversation_history[0].text, "客户最关注什么?"); + assert.match(qaCall.payload.conversation_history[1].text, /知识库产品|数据权限边界/); +}); + +test("QA hides legacy dossier-memory answers and excludes them from follow-up context", async () => { + const fixture = createWorkflowService(); + fixture.service.data.qa_messages.company_1.push( + { + id: "qa_user_legacy", + role: "user", + text: "旧问题", + created_at: "2026-07-19T08:00:00.000Z", + }, + { + id: "qa_assistant_legacy", + role: "assistant", + text: "旧版回答", + citations: [{ + id: "legacy_dossier_memory", + source_kind: "内部资料", + label: "旧档案记忆", + uri: "viking://resources/workspaces/test/companies/company_1/dossiers/legacy.md", + }], + citation_ids: ["legacy_dossier_memory"], + created_at: "2026-07-19T08:01:00.000Z", + }, + ); + + assert.deepEqual((await fixture.service.getQa("company_1")).messages, []); + + await fixture.service.createDossier("company_1"); + await fixture.service.askQuestion("company_1", { question: "当前重点是什么?" }); + const qaCall = fixture.modelCalls.find((call) => call.operation === "sales_qa"); + assert.deepEqual(qaCall.payload.conversation_history, []); + assert.equal((await fixture.service.getQa("company_1")).messages.length, 2); +}); + +test("business responses do not expose OpenViking or provider raw references", async () => { + const fixture = createWorkflowService(); + const dossier = await fixture.service.createDossier("company_1"); + const qa = await fixture.service.askQuestion("company_1", { question: "客户最关注什么?" }); + const materials = fixture.service.listMaterials("company_1"); + + const publicPayload = JSON.stringify({ dossier: dossier.detail, qa: qa.message, materials }); + assert.doesNotMatch(publicPayload, /viking:\/\//i); + assert.doesNotMatch(publicPayload, /model:/i); + assert.equal(Object.hasOwn(materials[0], "openviking_uri"), false); + assert.equal(materials[0].memory_ready, true); + assert.equal(Object.hasOwn(dossier.detail, "raw_ref"), false); + assert.equal(Object.hasOwn(dossier.detail, "evidence_pack"), false); + assert.equal(Object.hasOwn(dossier.detail, "provider_run_id"), false); + assert.equal(Object.hasOwn(dossier.detail, "memory_summary"), false); +}); + +test("QA public view hides legacy internal paths and resource identifiers", () => { + const fixture = createWorkflowService(); + const publicMessage = fixture.service.publicQaMessage({ + id: "qa_internal_leak", + role: "assistant", + text: "资料位于 company_dp_1234567890 的 /materials/private 目录。", + paragraphs: [{ + text: "OpenViking URI 是 viking://resources/private/materials/one。", + citation_ids: [], + }], + citations: [], + citation_ids: [], + }); + + assert.match(publicMessage.text, /已隐藏/); + assert.match(publicMessage.paragraphs[0].text, /已隐藏/); + assert.doesNotMatch(JSON.stringify(publicMessage), /company_dp_|\/materials\/|viking:\/\//i); +}); + +test("QA public view merges retrieval chunks from the same Feishu material", () => { + const fixture = createWorkflowService(); + const publicMessage = fixture.service.publicQaMessage({ + id: "qa_duplicate_material_chunks", + role: "assistant", + text: "会议纪要显示当前仍处于方案验证阶段。", + paragraphs: [ + { + text: "客户首先关注数据权限边界。", + citation_ids: ["chunk_1", "chunk_2"], + }, + { + text: "下一步需要确认试点范围和负责人。", + citation_ids: ["chunk_3", "chunk_4"], + }, + ], + citations: [1, 2, 3, 4].map((index) => ({ + id: `chunk_${index}`, + material_id: "material_1", + source_kind: "飞书云文档", + label: "客户需求确认会", + uri: `viking://resources/workspace-test/companies/company_1/materials/material_1/chunks/${index}`, + })), + citation_ids: ["chunk_1", "chunk_2", "chunk_3", "chunk_4"], + }); + + assert.equal(publicMessage.citations.length, 1); + assert.deepEqual(publicMessage.citation_ids, ["1"]); + assert.deepEqual(publicMessage.paragraphs[0].citation_ids, ["1"]); + assert.deepEqual(publicMessage.paragraphs[1].citation_ids, ["1"]); + assert.equal(publicMessage.citations[0].label, "客户需求确认会"); +}); + +test("QA public view keeps different dossier sections as separate verifiable citations", () => { + const fixture = createWorkflowService(); + const publicMessage = fixture.service.publicQaMessage({ + id: "qa_dossier_sections", + role: "assistant", + text: "近期动态与风险分别有对应档案章节。", + paragraphs: [{ + text: "近期动态如下。", + citation_ids: ["recent_section"], + }, { + text: "风险与关注事项如下。", + citation_ids: ["risk_section"], + }], + citations: [{ + id: "recent_section", + source_kind: "企业档案", + label: "测试企业 销售情报报告 V2 · 近期公开动态", + }, { + id: "risk_section", + source_kind: "企业档案", + label: "测试企业 销售情报报告 V2 · 风险与关注事项", + }], + citation_ids: ["recent_section", "risk_section"], + }); + + assert.equal(publicMessage.citations.length, 2); + assert.deepEqual(publicMessage.paragraphs[0].citation_ids, ["1"]); + assert.deepEqual(publicMessage.paragraphs[1].citation_ids, ["2"]); + assert.match(publicMessage.citations[0].label, /近期公开动态/u); + assert.match(publicMessage.citations[1].label, /风险与关注事项/u); +}); + +test("QA removes legacy answers that only cite generic internal materials", async () => { + const fixture = createWorkflowService(); + fixture.service.data.qa_messages.company_1.push( + { + id: "qa_user_generic_internal", + role: "user", + text: "旧版资料标题是什么?", + created_at: "2026-07-19T09:00:00.000Z", + }, + { + id: "qa_assistant_generic_internal", + role: "assistant", + text: "这是旧版本根据正文推测出的标题。", + citations: [{ + id: "material_1", + source_kind: "内部资料", + label: "内部资料", + uri: "viking://resources/workspace-test/companies/company_1/materials/material_1.md", + }], + citation_ids: ["material_1"], + created_at: "2026-07-19T09:01:00.000Z", + }, + ); + + assert.deepEqual((await fixture.service.getQa("company_1")).messages, []); + + await fixture.service.createDossier("company_1"); + await fixture.service.askQuestion("company_1", { question: "请使用正式标题回答。" }); + const qaCall = fixture.modelCalls.find((call) => call.operation === "sales_qa"); + assert.deepEqual(qaCall.payload.conversation_history, []); +}); + +test("legacy four-section dossiers are hidden instead of being synthesized into a formal report", () => { + const fixture = createWorkflowService(); + const publicDossier = fixture.service.publicDossier({ + id: "legacy_dossier_1", + company_id: "company_1", + title: "测试科技有限公司最近档案", + summary: "企业资料已更新。", + version_no: 1, + body: [ + { + text: "企业情况:企业ID(关联主键):254716 | 企业ID(关联主键):58059066。", + citation_ids: ["professional_1"], + }, + { + text: "近期动态:企业近期发布产品更新公告。", + citation_ids: ["public_1"], + }, + { + text: "销售判断:可继续跟进。", + citation_ids: ["professional_1", "public_1"], + }, + { + text: "下一步建议:确认业务场景。", + citation_ids: ["public_1"], + }, + ], + citations: [ + { + id: "professional_1", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司;经营范围:企业软件与知识库产品。", + conflict_fields: ["registered_capital"], + }, + { + id: "public_1", + label: "测试科技有限公司产品更新公告", + source_kind: "联网搜索", + summary: "测试科技有限公司近期发布产品更新公告。", + url: "https://news.test/company-update", + }, + ], + }); + + assert.equal(publicDossier.title, "测试科技有限公司 销售情报报告"); + assert.deepEqual(publicDossier.body, []); + assert.deepEqual(publicDossier.citations, []); + assert.equal(publicDossier.summary, ""); + assert.doesNotMatch(JSON.stringify(publicDossier), /企业ID|关联主键|内部资料|OpenViking/i); + assert.doesNotMatch(JSON.stringify(publicDossier), /关键字段存在来源差异|conflict_label/i); +}); + +test("public dossiers with retrieval diagnostics are rejected instead of template-rewritten", () => { + const fixture = createWorkflowService(); + const publicDossier = fixture.service.publicDossier({ + id: "diagnostic_dossier_1", + company_id: "company_1", + title: "测试科技有限公司销售情报报告", + summary: "企业近期发布了产品更新公告。", + body: [ + { text: "企业与业务概览:该企业经营范围包括企业软件、知识库建设和内容检索服务。", citation_ids: ["professional_1"] }, + { text: "经营与业务动态:本次未检索到可核验的经营变化,专业数据仅覆盖工商注册记录。", citation_ids: ["professional_1", "public_1"] }, + { text: "近期公开动态:企业于2026年7月发布产品更新公告,新增销售知识库协作功能。", citation_ids: ["public_1"] }, + { text: "风险与关注事项:资料缺口包括供应链交付明细,多个公开来源的经营数字口径冲突,因此不作为确定事实。", citation_ids: ["professional_1", "risk_public_1"] }, + { text: "销售机会判断:产品更新为销售知识库问答和协作检索试点提供了明确切入场景。", citation_ids: ["professional_1", "public_1"] }, + { text: "建议行动:1. 联系销售运营负责人。\n2. 核实知识库范围。\n3. 准备试点方案。", citation_ids: ["professional_1", "public_1"] }, + ], + citations: [ + { + id: "professional_1", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "测试科技有限公司经营范围包括企业软件、知识库建设和内容检索服务。", + conflict_fields: ["revenue"], + }, + { + id: "public_1", + label: "测试科技有限公司产品更新公告", + source_kind: "联网搜索", + summary: "企业于2026年7月发布产品更新公告,新增销售知识库协作功能。", + url: "https://news.test/company-update", + }, + { + id: "risk_public_1", + label: "测试科技有限公司供应链交付公告", + source_kind: "联网搜索", + summary: "企业公告披露部分核心组件交付周期延长,可能影响重点项目的实施排期。", + url: "https://news.test/company-risk", + }, + ], + }); + + const serialized = JSON.stringify(publicDossier); + assert.equal(publicDossier.body.length, 0); + assert.equal(publicDossier.summary, ""); + assert.equal(publicDossier.citations.length, 0); + assert.doesNotMatch( + serialized, + /本次未检索到|资料缺口|关键字段存在来源差异|来源冲突|口径冲突|不作为确定事实|conflict_label/i, + ); +}); + +test("public dossier summary is rebuilt only from visible citation-backed sections", () => { + const { service } = createWorkflowService(); + const longOpportunityDetail = "后续沟通仍需依次确认知识库覆盖范围、数据权限边界、部署方式、接口责任、项目排期、验收标准、运维安排、采购主体、预算审批路径、合同责任、业务牵头部门、技术评审角色、信息安全要求、试点成功标准、扩容触发条件、服务响应边界和最终决策链,在这些事项得到对方明确回复以前,不能把公开产品动作写成已经成立的采购需求、预算计划、签约安排或交付承诺。"; + const trailingOpportunityDetail = "书面确认记录还应覆盖试点负责人、双方沟通节奏、需求变更方式、交付依赖条件和最终验收责任,再据此决定是否继续投入售前资源。"; + const overflowOpportunityDetail = "最终复盘清单需要明确记录已经核验的事实、仍待确认的问题和下一次沟通的负责人。"; + const view = service.publicDossier({ + id: "dossier_grounded_summary", + company_id: "company_1", + title: "测试科技有限公司销售情报报告", + summary: "未经正文和最终引用支撑的合作、诉讼与展会结论。", + version_no: 1, + created_at: "2026-07-25T10:00:00.000Z", + body: [ + { text: "企业与业务概览:测试科技有限公司面向企业客户提供软件与知识库产品。", citation_ids: ["p1"] }, + { text: "经营与业务动态:专业数据反映该企业持续推进内容检索与协作管理能力。", citation_ids: ["p2"] }, + { text: "近期公开动态:测试科技有限公司于2026年7月发布知识库产品升级公告。", citation_ids: ["w1"] }, + { text: "风险与关注事项:项目推进需在商务报价前确认数据权限、合同责任和交付排期。", citation_ids: ["p1", "w2"] }, + { text: `销售机会判断:产品升级形成试点窗口,但不代表企业已经形成采购意向。${longOpportunityDetail}${trailingOpportunityDetail}${overflowOpportunityDetail}`, citation_ids: ["p2", "w1"] }, + { text: "建议行动:1. 联系产品负责人核验范围。\n2. 确认数据权限边界。\n3. 准备试点方案。", citation_ids: ["p1", "w2"] }, + ], + citations: [ + { + id: "p1", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "测试科技有限公司经营范围包括企业软件与知识库产品。", + independence_key: "datapro-business", + }, + { + id: "p2", + label: "金融数据库", + source_kind: "专业数据集", + summary: "测试科技有限公司持续推进内容检索与协作管理相关业务。", + independence_key: "datapro-market", + }, + { + id: "w1", + label: "测试科技有限公司发布知识库产品升级公告", + source_kind: "联网搜索", + summary: "测试科技有限公司持续提供企业软件服务。测试科技有限公司于2026年7月发布知识库产品升级公告。", + url: "https://news.test/company-update", + independence_key: "news.test", + }, + { + id: "w2", + label: "测试科技有限公司披露产品交付安排", + source_kind: "联网搜索", + summary: "测试科技有限公司披露知识库产品的分阶段交付安排。", + url: "https://official.test/company-delivery", + independence_key: "official.test", + }, + ], + }); + + assert.doesNotMatch(view.summary, /未经正文|诉讼|展会/); + assert.match(view.summary, /知识库产品升级公告/); + assert.match(view.summary, /试点窗口/); + assert.ok(view.summary.length <= 300); + assert.match(view.summary, /[。!?]$/u); + assert.doesNotMatch(view.summary, /最终复盘清单/); + assert.equal(view.body.length, 6); + assert.match( + view.citations.find((item) => item.label.includes("知识库产品升级公告"))?.summary || "", + /持续提供企业软件服务.*发布知识库产品升级公告/u, + ); +}); + +test("public dossier keeps bounded source detail needed to verify late evidence spans", () => { + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + }); + const longAction = "销售人员应联系产品负责人,依次确认内容检索场景、知识库覆盖范围、数据权限边界、部署方式、接口责任、试点排期、验收标准、运维安排、采购主体、预算审批路径、合同责任、业务牵头部门、技术评审角色、信息安全要求、试点成功标准、扩容触发条件、服务响应边界、故障升级路径、双方沟通节奏、需求变更方式、交付依赖条件、数据迁移范围、旧系统衔接方案、最终验收责任和最终决策链,再据此准备与已确认范围一致的试点方案。书面确认记录还应覆盖试点负责人、双方沟通节奏、需求变更方式、交付依赖条件和最终验收责任,再决定是否继续投入售前资源。最终复盘清单需要明确记录已经核验的事实、仍待确认的问题、下一次沟通的负责人和对应截止时间。"; + assert.ok(longAction.length > 260); + const claims = [ + "测试科技有限公司的登记经营范围包括企业软件与知识库产品。", + "测试科技有限公司持续开展内容检索能力研发。", + "测试科技有限公司发布知识库产品升级公告。", + "测试科技有限公司的项目交付排期需要持续核验。", + "现有产品升级动作显示可从内容检索场景切入销售沟通。", + longAction, + ]; + const sectionTitles = [ + "企业与业务概览", + "经营与业务动态", + "近期公开动态", + "风险与关注事项", + "销售机会判断", + "建议行动", + ]; + const sourceSummary = [ + "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件、知识库与内容检索产品。", + "业务资料说明。".repeat(140), + ...claims, + ].join(" "); + const body = sectionTitles.map((title, index) => ({ + text: `${title}:${claims[index]}`, + citation_ids: ["long-professional-source"], + segments: [{ + text: claims[index], + citation_ids: ["long-professional-source"], + }], + })); + + const publicView = service.publicDossier({ + id: "dossier-long-professional-source", + company_id: "company_1", + title: "测试科技有限公司 销售情报报告", + summary: claims[2], + body, + citations: [{ + id: "long-professional-source", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: sourceSummary, + entity_match: "verified", + quality_tier: 1, + independence_key: "internal:long-professional-source", + }], + created_at: "2026-08-03T00:00:00.000Z", + }); + + assert.equal(publicView.body.length, 6); + assert.equal(publicView.citations.length, 1); + assert.match(publicView.body[5].text, /信息安全要求.*最终决策链.*试点方案/u); + assert.match(publicView.citations[0].summary, /销售人员应联系产品负责人/); + assert.equal( + Object.prototype.propertyIsEnumerable.call(publicView, "_validation_citations"), + false, + ); + assert.doesNotMatch(JSON.stringify(publicView), /internal:long-professional-source/u); + assert.deepEqual(service.publicDossierQualityErrors( + publicView, + service.data.companies.company_1, + ), []); + + const overreachingBody = body.map((item, index) => (index === 3 ? { + ...item, + text: "风险与关注事项:某个公开项目金额为87.6392万元,说明其订单结构以中小额分散采购为主。", + segments: [{ + text: "某个公开项目金额为87.6392万元,说明其订单结构以中小额分散采购为主。", + citation_ids: ["long-professional-source"], + }], + } : item)); + assert.ok(service.publicDossierQualityErrors({ + ...publicView, + body: overreachingBody, + }, service.data.companies.company_1).includes( + "风险与关注事项不能把个别项目或单条公开信息外推为企业整体结构性结论", + )); +}); + +test("company identity fields stay bound to the exact business registry entity", () => { + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + }); + const company = service.data.companies.company_1; + const sectionTitles = [ + "企业与业务概览", + "经营与业务动态", + "近期公开动态", + "风险与关注事项", + "销售机会判断", + "建议行动", + ]; + const citations = [ + { + id: "target-registry", + label: "企业工商数据库 · 记录 1", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;注册号:110108028740260;注册地址:北京市海淀区测试路4号;成立日期:2020-05-11T08:00:00;经营范围:企业软件与知识库产品。", + }, + { + id: "branch-registry", + label: "企业工商数据库 · 记录 2", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司山东分公司;统一社会信用代码:TEST0002;注册号:370102300099154;注册地址:山东省青岛市测试路168号;成立日期:2021-05-17T08:00:00;经营范围:企业软件服务。", + }, + ]; + const completeBody = [ + "测试科技有限公司成立于2021年5月17日,注册地址为北京市海淀区测试路4号。", + "测试科技有限公司经营企业软件与知识库产品。", + "测试科技有限公司持续提供企业软件服务。", + "测试科技有限公司的项目范围和交付责任需要在商务沟通中核验。", + "测试科技有限公司的企业软件业务可作为销售沟通的应用场景。", + "销售人员应联系业务负责人确认企业软件服务范围。", + ].map((text, index) => ({ + text: `${sectionTitles[index]}:${text}`, + citation_ids: index === 0 ? ["target-registry", "branch-registry"] : ["target-registry"], + segments: [{ + text, + citation_ids: index === 0 ? ["target-registry", "branch-registry"] : ["target-registry"], + }], + })); + + const invalidErrors = service.publicDossierQualityErrors({ + company_id: company.id, + summary: completeBody[2].text, + body: completeBody, + citations, + }, company); + assert.ok(invalidErrors.some((error) => ( + error.includes("日期 2021-05-17") + && error.includes("测试科技有限公司") + && error.includes("对应工商记录不支持该归属") + ))); + + const correctlyScopedBody = structuredClone(completeBody); + correctlyScopedBody[0] = { + text: "企业与业务概览:测试科技有限公司成立于2020年5月11日。测试科技有限公司山东分公司成立于2021年5月17日。", + citation_ids: ["target-registry", "branch-registry"], + segments: [{ + text: "测试科技有限公司成立于2020年5月11日。测试科技有限公司山东分公司成立于2021年5月17日。", + citation_ids: ["target-registry", "branch-registry"], + }], + }; + const scopedErrors = service.publicDossierQualityErrors({ + company_id: company.id, + summary: correctlyScopedBody[2].text, + body: correctlyScopedBody, + citations, + }, company); + assert.equal( + scopedErrors.some((error) => error.includes("对应工商记录不支持该归属")), + false, + JSON.stringify(scopedErrors), + ); + + const uncitedBranchBody = structuredClone(correctlyScopedBody); + uncitedBranchBody[0].citation_ids = ["target-registry"]; + uncitedBranchBody[0].segments[0].citation_ids = ["target-registry"]; + const uncitedBranchErrors = service.publicDossierQualityErrors({ + company_id: company.id, + summary: uncitedBranchBody[2].text, + body: uncitedBranchBody, + citations, + }, company); + assert.ok(uncitedBranchErrors.some((error) => ( + error.includes("测试科技有限公司山东分公司") + && error.includes("没有引用该分支机构自己的工商记录") + ))); + + const trajectoryBody = structuredClone(completeBody); + trajectoryBody[1].text = "经营与业务动态:少量项目显示其业务已从单一软件服务扩展到综合知识库能力供给。"; + trajectoryBody[1].segments[0].text = "少量项目显示其业务已从单一软件服务扩展到综合知识库能力供给。"; + assert.ok(service.publicDossierQualityErrors({ + company_id: company.id, + summary: trajectoryBody[2].text, + body: trajectoryBody, + citations, + }, company).includes("企业概览或经营动态不能把静态经营范围或少量项目外推为业务转型或能力扩展")); + + const inferredExpansionBody = structuredClone(completeBody); + inferredExpansionBody[1].text = "经营与业务动态:公司的业务布局延伸至能源管理和数据中心基础设施。"; + inferredExpansionBody[1].segments[0].text = "公司的业务布局延伸至能源管理和数据中心基础设施。"; + assert.ok(service.publicDossierQualityErrors({ + company_id: company.id, + summary: inferredExpansionBody[2].text, + body: inferredExpansionBody, + citations, + }, company).includes("企业概览或经营动态不能把静态经营范围或少量项目外推为业务转型或能力扩展")); + + const overviewExpansionBody = structuredClone(completeBody); + overviewExpansionBody[0].text = "企业与业务概览:该企业的登记业务布局延伸至知识库产品。"; + overviewExpansionBody[0].segments[0].text = "该企业的登记业务布局延伸至知识库产品。"; + assert.ok(service.publicDossierQualityErrors({ + company_id: company.id, + summary: overviewExpansionBody[2].text, + body: overviewExpansionBody, + citations, + }, company).includes("企业概览或经营动态不能把静态经营范围或少量项目外推为业务转型或能力扩展")); + + const registryPositionBody = structuredClone(completeBody); + registryPositionBody[0] = { + text: "企业与业务概览:登记范围包括企业软件与知识库产品,形成软件与知识管理并行的业务定位。", + citation_ids: ["target-registry"], + segments: [{ + text: "登记范围包括企业软件与知识库产品,形成软件与知识管理并行的业务定位。", + citation_ids: ["target-registry"], + }], + }; + assert.ok(service.publicDossierQualityErrors({ + company_id: company.id, + summary: registryPositionBody[2].text, + body: registryPositionBody, + citations, + }, company).includes("企业与业务概览只能把工商信息表述为登记范围,不能提升为实际主营、制造主体或现实业务定位")); + + const registryOpportunityBody = structuredClone(completeBody); + registryOpportunityBody[4] = { + text: "销售机会判断:该主体同时承担企业软件与知识库产品业务,可从相关场景切入。", + citation_ids: ["target-registry"], + segments: [{ + text: "该主体同时承担企业软件与知识库产品业务,可从相关场景切入。", + citation_ids: ["target-registry"], + }], + }; + assert.ok(service.publicDossierQualityErrors({ + company_id: company.id, + summary: registryOpportunityBody[2].text, + body: registryOpportunityBody, + citations, + }, company).includes("销售机会判断可以把登记范围作为对接方向,但不能写成企业已承担该业务或已具备现实能力")); + + const demandBody = structuredClone(completeBody); + demandBody[2].text = "近期公开动态:近期项目节奏说明其配套采购需求正处于活跃期。"; + demandBody[2].segments[0].text = "近期项目节奏说明其配套采购需求正处于活跃期。"; + assert.ok(service.publicDossierQualityErrors({ + company_id: company.id, + summary: demandBody[2].text, + body: demandBody, + citations, + }, company).includes("近期公开动态不能把中标或公告节奏写成来源未披露的采购需求或采购意向")); +}); + +test("registry rows stay entity-scoped even when a specialized DataPro query mislabels them", () => { + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + }); + const company = service.data.companies.company_1; + const citations = [ + { + id: "target-registry", + label: "企业工商数据库 · 记录 1", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;注册地址:北京市海淀区测试路4号;经营范围:企业软件与知识库产品。", + }, + { + id: "mislabeled-branch-registry", + label: "科研学术数据搜索服务 · 记录 2", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司山东分公司;统一社会信用代码:TEST0002;注册地址:山东省青岛市测试路168号;经营范围:企业软件服务。", + }, + { + id: "public-event", + label: "测试科技有限公司产品升级公告", + source_kind: "联网搜索", + summary: "2026年7月,测试科技有限公司发布企业知识库产品升级公告。", + url: "https://test-company.test/news/product-update", + published_at: "2026-07-20T09:00:00.000Z", + }, + ]; + const body = [ + ["企业与业务概览:测试科技有限公司登记经营范围包括企业软件与知识库产品。", ["target-registry"]], + ["经营与业务动态:测试科技有限公司登记业务覆盖企业软件服务。", ["mislabeled-branch-registry"]], + ["近期公开动态:2026年7月,测试科技有限公司发布企业知识库产品升级公告。", ["public-event"]], + ["风险与关注事项:对接前应核验产品升级的实施范围。", ["public-event"]], + ["销售机会判断:产品升级可作为销售沟通的切入场景,但不代表已经形成采购意向。", ["public-event"]], + ["建议行动:联系产品负责人核验升级范围并准备能力说明材料。", ["public-event"]], + ].map(([text, citationIds]) => ({ + text, + citation_ids: citationIds, + segments: [{ text: text.replace(/^[^:]+:/u, ""), citation_ids: citationIds }], + })); + + const invalidErrors = service.publicDossierQualityErrors({ + company_id: company.id, + summary: body[2].text, + body, + citations, + }, company); + assert.ok(invalidErrors.some((error) => ( + error.includes("经营与业务动态") + && error.includes("测试科技有限公司山东分公司") + && error.includes("其他主体工商记录") + )), JSON.stringify(invalidErrors)); + assert.equal( + invalidErrors.includes("经营与业务动态必须优先引用语义匹配的专业数据库"), + false, + JSON.stringify(invalidErrors), + ); + + const explicitlyScopedBody = structuredClone(body); + explicitlyScopedBody[1].text = "经营与业务动态:测试科技有限公司山东分公司登记经营范围包括企业软件服务。"; + explicitlyScopedBody[1].segments[0].text = "测试科技有限公司山东分公司登记经营范围包括企业软件服务。"; + const scopedErrors = service.publicDossierQualityErrors({ + company_id: company.id, + summary: explicitlyScopedBody[2].text, + body: explicitlyScopedBody, + citations, + }, company); + assert.equal( + scopedErrors.some((error) => error.includes("测试科技有限公司山东分公司")), + false, + JSON.stringify(scopedErrors), + ); + + const overreachingRegistryBody = structuredClone(body); + overreachingRegistryBody[1] = { + text: "经营与业务动态:公司业务动作聚焦于企业软件服务,构成独立产品线,并具备直接开展跨境业务的经营条件。", + citation_ids: ["target-registry"], + segments: [{ + text: "公司业务动作聚焦于企业软件服务,构成独立产品线,并具备直接开展跨境业务的经营条件。", + citation_ids: ["target-registry"], + }], + }; + const overreachingErrors = service.publicDossierQualityErrors({ + company_id: company.id, + summary: overreachingRegistryBody[2].text, + body: overreachingRegistryBody, + citations, + }, company); + assert.ok(overreachingErrors.includes( + "经营与业务动态不能把静态工商登记范围提升为当前业务动作、独立产品线或现实经营能力", + )); +}); + +test("dossier Agent normalizes duplicate registry rows and excludes non-target entities", async () => { + const modelCalls = []; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callRequiredFunction(input) { + modelCalls.push(structuredClone(input)); + return { + ok: false, + error: { code: "test_stop", message: "context captured", retryable: false }, + }; + }, + }, + }); + await service.generateDossierWithModel(service.data.companies.company_1, { + evidence_hash: "mismatched-registry-dataset", + items: [ + { + id: "target-registry", + label: "企业工商数据库 · 记录 1", + source_kind_label: "专业数据集", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件与知识库产品。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-target-registry", + }, + { + id: "mislabeled-target-registry", + label: "科研学术数据搜索服务 · 记录 1", + source_kind_label: "专业数据集", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件与知识库产品。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-mislabeled-target", + }, + { + id: "mislabeled-branch-registry", + label: "科研学术数据搜索服务 · 记录 2", + source_kind_label: "专业数据集", + summary: "公司名称:测试科技有限公司山东分公司;统一社会信用代码:TEST0002;经营范围:企业软件服务。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-mislabeled-branch", + }, + { + id: "business-branch-registry", + label: "企业工商数据库 · 记录 2", + source_kind_label: "专业数据集", + summary: "公司名称:测试科技有限公司南京分公司;统一社会信用代码:TEST0003;经营范围:企业软件服务。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-business-branch", + }, + { + id: "research-evidence", + label: "科研学术数据搜索服务 · 记录 3", + source_kind_label: "专业数据集", + summary: "测试科技有限公司持续开展企业知识库、内容检索和智能协作相关技术研发。", + provider: "datapro", + quality_tier: 1, + independence_key: "datapro-research", + }, + { + id: "public-event", + label: "测试科技有限公司产品升级公告", + source_kind_label: "联网搜索", + summary: "2026年7月,测试科技有限公司发布企业知识库产品升级公告。", + provider: "web_search", + url: "https://test-company.test/news/product-update", + published_at: "2026-07-20T09:00:00.000Z", + quality_tier: 2, + independence_key: "test-company.test", + }, + ], + }, []); + + assert.equal(modelCalls.length, 1); + const serializedContext = JSON.stringify(modelCalls[0].payload); + assert.doesNotMatch( + serializedContext, + /mislabeled-target-registry|mislabeled-branch-registry|business-branch-registry|测试科技有限公司山东分公司|测试科技有限公司南京分公司/u, + ); + assert.match(serializedContext, /research-evidence|智能协作相关技术研发/u); + assert.equal( + modelCalls[0].payload.source_selection_policy.market_database_ids.includes( + "mislabeled-branch-registry", + ), + false, + ); + assert.deepEqual( + modelCalls[0].payload.source_selection_policy.business_dynamics_ids, + ["research-evidence"], + ); +}); + +test("public dossiers are rejected when a section depends on a discarded placeholder citation", () => { + const fixture = createWorkflowService(); + const publicDossier = fixture.service.publicDossier({ + id: "punctuation_dossier_1", + company_id: "company_1", + title: "测试科技有限公司销售情报报告", + summary: "企业近期发布产品升级公告。", + body: [ + { + text: "企业与业务概览:测试科技有限公司(简称:“测试科技”,TEST.SZ)主营企业软件,面向销售团队提供知识库产品;", + citation_ids: ["professional_main"], + }, + { + text: "经营与业务动态:公司于2026年7月发布产品升级公告,产品使用率达到25%,587Ah 规格已进入交付阶段,相关收入为2,769.17万元;", + citation_ids: ["public_business", "public_untitled"], + }, + { + text: "近期公开动态:公司官网于2026年7月披露合作计划,将推进客户服务场景落地;", + citation_ids: ["public_latest"], + }, + { + text: "风险与关注事项:公开公告提示部分项目交付周期可能延长,需核实实施排期;", + citation_ids: ["public_risk"], + }, + { + text: "销售机会判断:产品升级形成明确切入场景,可优先确认试点部门与预算窗口;", + citation_ids: ["professional_main", "public_business"], + }, + { + text: "建议行动:1. 联系销售运营负责人; 2. 核实试点范围; 3. 准备交付计划;", + citation_ids: ["professional_main", "public_business"], + }, + ], + citations: [ + { + id: "professional_main", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "测试科技有限公司主营企业软件,面向销售团队提供知识库产品。", + }, + { + id: "public_business", + label: "测试科技有限公司产品升级公告", + source_kind: "联网搜索", + summary: "公司于2026年7月发布产品升级公告,产品使用率达到25%,587Ah 规格已进入交付阶段,相关收入为2,769.17万元。", + url: "https://news.test/product-update", + }, + { + id: "public_latest", + label: "测试科技有限公司合作计划", + source_kind: "联网搜索", + summary: "公司官网于2026年7月披露合作计划,将推进客户服务场景落地。", + url: "https://news.test/cooperation", + }, + { + id: "public_risk", + label: "测试科技有限公司项目交付公告", + source_kind: "联网搜索", + summary: "公开公告提示部分项目交付周期可能延长,需核实实施排期。", + url: "https://news.test/delivery", + }, + { + id: "public_untitled", + label: "Untitled", + source_kind: "联网搜索", + summary: "无有效标题的搜索结果。", + url: "https://news.test/untitled", + }, + ], + }); + + assert.deepEqual(publicDossier.body, []); + assert.deepEqual(publicDossier.citations, []); + assert.equal(publicDossier.summary, ""); +}); + +test("strict dossier runtime rejects evidence that cannot anchor the target legal entity", async () => { + let modelCalls = 0; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callRequiredFunction() { + modelCalls += 1; + return { ok: false, error: { code: "should_not_be_called" } }; + }, + }, + }); + + await assert.rejects( + () => service.generateDossierWithModel(service.data.companies.company_1, { + evidence_hash: "bad-public-evidence", + items: [ + { + id: "professional_1", + label: "企业工商数据库", + source_kind_label: "专业数据集", + summary: "该记录描述企业软件与知识库产品,但没有返回可核对的法定名称或统一社会信用代码。", + independence_key: "datapro-business", + }, + { + id: "professional_2", + label: "金融数据库", + source_kind_label: "专业数据集", + summary: "该记录描述内容检索与协作管理业务,但没有返回可核对的法定主体。", + independence_key: "datapro-market", + }, + { + id: "public_1", + label: "测试科技有限公司安全验证", + source_kind_label: "联网搜索", + summary: "请完成人机验证后查看更多相关内容。", + url: "https://blocked.test/verify", + independence_key: "blocked.test", + }, + { + id: "public_2", + label: "测试科技有限公司网站建设案例", + source_kind_label: "联网搜索", + summary: "网站建设服务商展示测试科技有限公司官网改版案例。", + url: "https://agency.test/case", + independence_key: "agency.test", + }, + ], + }, []), + (error) => error.status === 422 + && error.code === "evidence_quality_insufficient" + && error.details.validation_errors.some((item) => item.includes("目标法定主体")), + ); + assert.equal(modelCalls, 0); +}); + +test("public dossiers are rejected when a section cites a removed website-production case study", () => { + const fixture = createWorkflowService(); + const publicDossier = fixture.service.publicDossier({ + id: "source_quality_dossier_1", + company_id: "company_1", + title: "测试科技有限公司销售情报报告", + summary: "测试科技有限公司近期披露客户服务产品合作计划。", + body: [ + { + text: "企业与业务概览:测试科技有限公司主营企业软件与知识库产品。", + citation_ids: ["professional_main"], + }, + { + text: "经营与业务动态:测试科技有限公司持续推进企业软件与客户服务产品。", + citation_ids: ["professional_main"], + }, + { + text: "近期公开动态:经过项目团队数月建设,测试科技有限公司全新品牌官网上线;测试科技有限公司于2026年7月签署客户服务产品合作协议。", + citation_ids: ["website_case", "official_cooperation"], + }, + { + text: "风险与关注事项:商务推进应确认数据合规、合同责任与交付排期。", + citation_ids: ["professional_main"], + }, + { + text: "销售机会判断:客户服务产品合作形成了可继续核验的业务切入点。", + citation_ids: ["professional_main", "official_cooperation"], + }, + { + text: "建议行动:1. 确认合作项目牵头部门。\n2. 核验采购范围与预算窗口。\n3. 准备客户服务产品方案。", + citation_ids: ["professional_main", "official_cooperation"], + }, + ], + citations: [ + { + id: "professional_main", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司;经营范围:企业软件与知识库产品。", + }, + { + id: "website_case", + label: "测试科技有限公司网站建设|企业官网全面焕新", + source_kind: "联网搜索", + summary: "经过项目团队数月建设,测试科技有限公司全新品牌官网上线,这是网站建设服务商的客户案例。", + url: "https://agency.test/cases/test-company", + published_at: "2026-07-18T09:00:00.000Z", + }, + { + id: "generic_homepage", + label: "测试科技有限公司 · TEST", + source_kind: "联网搜索", + summary: "测试科技有限公司面向企业客户提供软件与知识库产品。", + url: "https://test-company.test/", + }, + { + id: "official_product", + label: "测试科技有限公司发布企业知识库产品升级公告", + source_kind: "联网搜索", + summary: "测试科技有限公司于2026年7月发布企业知识库产品升级公告,新增面向销售团队的协作能力。", + url: "https://test-company.test/news/product-update", + published_at: "2026-07-20T09:00:00.000Z", + auth_level: 3, + }, + { + id: "official_cooperation", + label: "测试科技有限公司客户服务产品合作公告", + source_kind: "联网搜索", + summary: "测试科技有限公司于2026年7月签署客户服务产品合作协议,双方将推进知识库产品在客户服务场景落地。", + url: "https://test-company.test/news/cooperation", + published_at: "2026-07-21T09:00:00.000Z", + auth_level: 3, + }, + ], + }); + + const serialized = JSON.stringify(publicDossier); + assert.doesNotMatch(serialized, /网站建设|官网全面焕新|项目团队数月建设|网站建设服务商/); + assert.deepEqual(publicDossier.body, []); + assert.deepEqual(publicDossier.citations, []); + assert.equal(publicDossier.summary, ""); +}); + +test("public dossiers discard strongly sensationalized self-media sources", () => { + const { service } = createWorkflowService(); + const body = [ + "企业与业务概览:测试科技有限公司的登记范围包括企业软件与知识库产品。", + "经营与业务动态:登记信息可作为业务对接范围的核验起点。", + "近期公开动态:自媒体声称测试科技有限公司涉及一项市场事件。", + "风险与关注事项:对接前应确认登记主体、业务范围和责任边界。", + "销售机会判断:登记范围可作为企业软件场景的待确认对接方向。", + "建议行动:联系相关负责人确认业务范围、项目边界和下一步安排。", + ].map((text, index) => ({ + text, + citation_ids: [index === 2 ? "sensational" : "registry"], + })); + const view = service.publicDossier({ + id: "sensational-source-dossier", + company_id: "company_1", + summary: body[2].text, + body, + citations: [ + { + id: "registry", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件与知识库产品。", + }, + { + id: "sensational", + label: "杀人诛心!一句话让对方下不来台", + source_kind: "联网搜索", + summary: "自媒体声称测试科技有限公司涉及一项市场事件。", + url: "https://self-media.test/story", + }, + ], + }); + + assert.deepEqual(view.body, []); + assert.deepEqual(view.citations, []); +}); + +test("public dossiers reject similarly named legal entities without relationship evidence", () => { + const fixture = createWorkflowService(); + const publicDossier = fixture.service.publicDossier({ + id: "similar_entity_dossier_1", + company_id: "company_1", + title: "测试科技有限公司销售情报报告", + summary: "测试科技有限公司近期披露产品升级进展。", + body: [ + { text: "企业与业务概览:测试科技有限公司从事企业软件开发。", citation_ids: ["target_business"] }, + { text: "经营与业务动态:山西测试科技有限公司开展网络建设业务。", citation_ids: ["similar_business"] }, + { text: "近期公开动态:2026年7月,测试科技有限公司披露产品升级进展。", citation_ids: ["public_event"] }, + { text: "风险与关注事项:对接前应核验产品升级的实施范围。", citation_ids: ["public_event"] }, + { text: "销售机会判断:产品升级为技术交流提供切入点,但不代表已有采购意向。", citation_ids: ["public_event"] }, + { text: "建议行动:联系产品负责人核验升级范围并准备能力说明材料。", citation_ids: ["public_event"] }, + ], + citations: [ + { + id: "target_business", + label: "企业工商数据库 · 记录 1", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司;经营范围:企业软件开发。", + }, + { + id: "similar_business", + label: "企业工商数据库 · 记录 2", + source_kind: "专业数据集", + summary: "公司名称:山西测试科技有限公司;经营范围:网络建设。", + }, + { + id: "public_event", + label: "测试科技有限公司产品升级公告", + source_kind: "联网搜索", + summary: "2026年7月,测试科技有限公司披露产品升级进展。", + url: "https://test-company.test/news/upgrade", + published_at: "2026-07-20T09:00:00.000Z", + }, + ], + }); + + assert.deepEqual(publicDossier.body, []); + assert.deepEqual(publicDossier.citations, []); + assert.equal(publicDossier.summary, ""); +}); + +test("public dossiers reject malformed snippets instead of reconstructing them from other sources", () => { + const fixture = createWorkflowService(); + const publicDossier = fixture.service.publicDossier({ + id: "malformed_dossier_1", + company_id: "company_1", + title: "测试科技有限公司销售情报报告", + summary: "企业近期发布产品升级公告。", + body: [ + { text: "企业与业务概览:测试科技有限公司持续经营企业软件业务。", citation_ids: ["professional_main"] }, + { text: "经营与业务动态:测试科技有限公司(简称:“测试科技”,TEST.SZ)发布产品升级公告,将面向销售团队推出知识库协作功能;", citation_ids: ["public_business", "public_untitled"] }, + { text: "近期公开动态:媒体 作者 7月25日 测试科技有限公司(简称“测试科技”。", citation_ids: ["public_business"] }, + { text: "风险与关注事项:公开摘要显示净利润432。", citation_ids: ["public_risk"] }, + { text: "销售机会判断:可围绕企业软件产品升级验证销售知识库场景。", citation_ids: ["professional_main", "public_business"] }, + { text: "建议行动:1. 联系产品负责人。2. 核实试点范围。3. 准备交付计划。", citation_ids: ["professional_main", "public_business"] }, + ], + citations: [ + { + id: "professional_main", + label: "企业工商数据库 · 记录 1", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;法定代表人:张三;注册地址:北京市海淀区;成立日期:2020-01-01。", + }, + { + id: "professional_branch", + label: "企业工商数据库 · 记录 2", + source_kind: "专业数据集", + summary: "公司名称:测试科技有限公司上海分公司;统一社会信用代码:BRANCH0001;法定代表人:李四;注册地址:上海市徐汇区;成立日期:2023-01-01。", + }, + { + id: "public_business", + label: "测试科技有限公司于2026年7月发布销售知识库产品升级公告_产业观察", + source_kind: "联网搜索", + summary: "媒体 作者 7月25日 测试科技有限公司(简称“测试科技”,立即注册查看更多相关信息。", + url: "https://news.test/product-update", + }, + { + id: "public_risk", + label: "测试科技有限公司核心组件交付延期公告", + source_kind: "联网搜索", + summary: "公司公告披露部分核心组件交付周期延长,可能影响重点项目的实施排期。", + url: "https://news.test/delivery-risk", + }, + { + id: "public_untitled", + label: "Untitled", + source_kind: "联网搜索", + summary: "无有效标题的搜索结果。", + url: "https://news.test/untitled", + }, + ], + }); + + const serialized = JSON.stringify(publicDossier); + assert.doesNotMatch(serialized, /立即注册|查看更多|净利润432|上海分公司|BRANCH0001|Untitled/); + assert.deepEqual(publicDossier.body, []); + assert.deepEqual(publicDossier.citations, []); + assert.equal(publicDossier.summary, ""); +}); + +test("public dossiers reject repeated sections and risks attributed to another company", () => { + const fixture = createWorkflowService(); + fixture.service.data.companies.company_1 = { + ...fixture.service.data.companies.company_1, + name: "宁德时代新能源科技股份有限公司", + aliases: ["宁德时代"], + industry: "新能源", + }; + const repeatedBusinessPoint = "宁德时代于2026年7月披露储能合作和产线建设进展,相关项目处于持续推进阶段。"; + const publicDossier = fixture.service.publicDossier({ + id: "cross_entity_risk_dossier_1", + company_id: "company_1", + title: "宁德时代新能源科技股份有限公司销售情报报告", + summary: "宁德时代近期披露多项储能合作和产线建设进展。", + body: [ + { + text: "企业与业务概览:宁德时代新能源科技股份有限公司主营动力电池、储能电池及相关系统产品。", + citation_ids: ["professional_main"], + }, + { + text: `经营与业务动态:近期公开披露的业务动作包括:${repeatedBusinessPoint}`, + citation_ids: ["public_business"], + }, + { + text: `近期公开动态:${repeatedBusinessPoint}`, + citation_ids: ["public_business", "public_metadata"], + }, + { + text: "风险与关注事项:北京永勤律师事务所律师表示,相关投资者可以请求赔偿。", + citation_ids: ["public_wrong_risk", "professional_main"], + }, + { + text: "销售机会判断:储能合作和产线建设为设备、系统集成和供应链协同提供了跟进场景。", + citation_ids: ["professional_main", "public_business"], + }, + { + text: "建议行动:1. 核验项目阶段。2. 联系采购负责人。3. 准备供应方案。", + citation_ids: ["professional_main", "public_business"], + }, + ], + citations: [ + { + id: "professional_main", + label: "企业工商数据库", + source_kind: "专业数据集", + summary: "公司名称:宁德时代新能源科技股份有限公司;统一社会信用代码:TESTCATL001;经营范围:动力电池、储能电池及相关系统产品。", + }, + { + id: "public_business", + label: "宁德时代披露储能合作和产线建设进展", + source_kind: "联网搜索", + summary: repeatedBusinessPoint, + url: "https://news.test/catl-business", + }, + { + id: "public_metadata", + label: "1000Wh时代!宁德时代即将迈入", + source_kind: "联网搜索", + summary: "1000Wh时代!宁德时代即将迈入 2026年06月28日 23:53 市场资讯 (来源:连线新能源 NELinked) 近日,宁德时代发布新一代储能电池产品。", + url: "https://news.test/catl-storage", + }, + { + id: "public_wrong_risk", + label: "1200亿“画饼”宁德时代被罚,容百科技投资者可以索赔了!", + source_kind: "联网搜索", + summary: "文章标题提到宁德时代被罚,但北京永勤律师事务所金融律师表示,实际索赔对象为容百科技部分投资者。", + url: "https://news.test/other-company-risk", + }, + ], + }); + + const serialized = JSON.stringify(publicDossier); + assert.equal(publicDossier.body.length, 0); + assert.equal(publicDossier.summary, ""); + assert.equal(publicDossier.citations.length, 0); + assert.doesNotMatch(serialized, /市场资讯|来源:连线新能源|北京永勤|容百科技|请求赔偿/); +}); + +test("dossier Agent does not persist repetitive low-quality plans", async () => { + const modelCalls = []; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callRequiredFunction(input) { + modelCalls.push(input); + const invalid = stagedDossierPlan(input); + const duplicateText = "企业近期发布产品升级公告并需要销售团队继续关注。"; + Object.values(invalid.sections).forEach((section) => { + section.text = duplicateText; + }); + return { + ok: true, + parsed: invalid, + usage: { prompt_tokens: 30, completion_tokens: 20, total_tokens: 50 }, + raw_ref: `model:invalid-${modelCalls.length}`, + }; + }, + }, + }); + const company = service.data.companies.company_1; + const dossier = await service.generateDossierWithModel(company, { + professional: [ + { + label: "企业工商数据库", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件与知识库产品。", + }, + { + label: "企业风险数据库", + summary: "风险状态需要结合公开公告持续关注。", + }, + ], + public_sources: [ + { + label: "测试科技有限公司产品升级公告", + summary: "测试科技有限公司于2026年7月发布销售知识库产品升级公告。", + url: "https://news.test/product-update", + published_at: "2026-07-20T09:00:00.000Z", + }, + { + label: "测试科技有限公司核心组件交付延期公告", + summary: "企业公告披露部分核心组件交付周期延长,可能影响重点项目的实施排期。", + url: "https://news.test/delivery-risk", + published_at: "2026-07-21T09:00:00.000Z", + }, + ], + }, []); + + assert.equal(modelCalls.length, 3); + assert.equal(dossier, null); + assert.equal(modelCalls[0].operation, "sales_dossier_agent_plan"); + assert.equal(modelCalls[1].operation, "sales_dossier_agent_replan"); + assert.equal(modelCalls[2].operation, "sales_dossier_agent_replan"); + assert.ok(modelCalls[1].payload.planning_errors.length > 0); +}); + +test("dossier Agent fails closed when all bounded revision calls fail", async () => { + const modelCalls = []; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callRequiredFunction(input) { + modelCalls.push(input); + if (input.operation === "sales_dossier_agent_plan") { + const invalid = stagedDossierPlan(input); + invalid.sections.company_overview.text = "搜索标题"; + return { + ok: true, + parsed: invalid, + raw_ref: "model:invalid-plan", + }; + } + return { + ok: false, + error: { + code: "incomplete_response", + message: "The revision response was truncated.", + retryable: true, + }, + }; + }, + }, + }); + const company = { + ...service.data.companies.company_1, + name: "宁德时代新能源科技股份有限公司", + aliases: ["宁德时代"], + industry: "新能源", + }; + await assert.rejects( + () => service.generateDossierWithModel(company, { + professional: [ + { + label: "企业工商数据库", + summary: "公司名称:宁德时代新能源科技股份有限公司;统一社会信用代码:TESTCATL001;经营范围:动力电池、储能电池及相关系统产品。", + }, + { + label: "金融数据库", + summary: "宁德时代新能源科技股份有限公司持续开展动力电池、储能系统及相关产业链业务。", + }, + { + label: "企业风险数据库", + summary: "宁德时代新能源科技股份有限公司的供应链履约、项目交付与合同责任需要持续核验。", + }, + ], + public_sources: [ + { + label: "宁德时代与大连德泰签署战略合作协议", + summary: "宁德时代新能源科技股份有限公司与大连德泰有限公司签署战略合作协议,双方将推进储能项目建设与运营。", + url: "https://news.test/catl-deta-cooperation", + published_at: "2026-07-23T09:00:00.000Z", + }, + { + label: "宁德时代披露储能项目交付进展", + summary: "宁德时代新能源科技股份有限公司披露储能项目交付进展,并说明后续建设与运营计划。", + url: "https://official.test/catl-storage-delivery", + published_at: "2026-07-24T09:00:00.000Z", + }, + ], + }, []), + (error) => error.status === 503 && error.code === "model_unavailable", + ); + + assert.equal(modelCalls.length, 3); + assert.equal(modelCalls[1].operation, "sales_dossier_agent_replan"); + assert.equal(modelCalls[2].operation, "sales_dossier_agent_replan"); + assert.ok(modelCalls[1].payload.planning_errors.length > 0); +}); + +test("dossier Agent ignores empty specialized databases when enforcing section sources", async () => { + const modelCalls = []; + const service = new SalesService({ + env: envReader({ APP_WORKSPACE_ID: "workspace-test" }), + runtimePolicy: permissiveTestPolicy, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callRequiredFunction(input) { + modelCalls.push(structuredClone(input)); + if ( + input.operation === "sales_dossier_agent_plan" + || input.operation === "sales_dossier_agent_replan" + ) { + const planned = stagedDossierPlan(input); + const recentEvidence = input.payload.evidence_by_section.recent_public_updates.allowed_evidence; + const distinctRecent = recentEvidence.find((item) => ( + item.id !== planned.sections.business_dynamics.evidence_ids[0] + )); + if (distinctRecent) { + planned.sections.recent_public_updates = { + text: /[。!?]$/u.test(distinctRecent.quote) + ? distinctRecent.quote + : `${distinctRecent.quote}。`, + evidence_ids: [distinctRecent.id], + }; + } + const riskEvidence = input.payload.evidence_by_section.risk_attention.allowed_evidence; + planned.sections.risk_attention = { + text: "公开公告显示部分核心组件交付周期延长,项目实施排期需要提前确认。", + evidence_ids: [riskEvidence[0].id], + }; + planned.sections.recommended_actions = { + text: "销售人员应联系项目负责人确认核心组件交付排期。", + evidence_ids: [riskEvidence[0].id], + }; + return { + ok: true, + parsed: planned, + raw_ref: "model:specialized-plan", + }; + } + throw new Error("deterministic compilation must not request a writer call"); + }, + }, + }); + const company = service.data.companies.company_1; + const dossier = await service.generateDossierWithModel(company, { + professional: [ + { + label: "企业工商数据库", + summary: "公司名称:测试科技有限公司;统一社会信用代码:TEST0001;经营范围:企业软件与知识库产品。", + }, + { + label: "企业风险数据库", + summary: "本次未检索到可核验的司法、处罚或失信记录。", + }, + { + label: "金融数据库", + summary: "企业ID(关联主键):254716。", + }, + ], + public_sources: [ + { + label: "测试科技有限公司产品升级公告", + summary: "测试科技有限公司于2026年7月发布销售知识库产品升级公告。", + url: "https://news.test/product-update", + published_at: "2026-07-20T09:00:00.000Z", + }, + { + label: "测试科技有限公司核心组件交付延期公告", + summary: "企业公告披露部分核心组件交付周期延长,可能影响重点项目的实施排期。", + url: "https://news.test/delivery-risk", + published_at: "2026-07-21T09:00:00.000Z", + }, + ], + }, []); + + assert.ok(dossier, JSON.stringify(modelCalls.map((call) => ({ + operation: call.operation, + planning_errors: call.payload?.planning_errors || [], + source_selection_policy: call.payload?.source_selection_policy || {}, + })))); + assert.equal(dossier.body.length, 6); + assert.deepEqual(modelCalls[0].payload.source_selection_policy.risk_database_ids, []); + assert.deepEqual(modelCalls[0].payload.source_selection_policy.market_database_ids, []); + assert.ok(modelCalls[0].payload.source_selection_policy.business_dynamics_ids.length > 0); + assert.ok(modelCalls[0].payload.source_selection_policy.business_dynamics_ids.every((id) => ( + modelCalls[0].payload.source_selection_policy.web_search_ids.includes(id) + ))); + assert.ok(dossier.body[1].citation_ids.every((id) => ( + dossier.citations.find((citation) => citation.id === id)?.source_kind === "联网搜索" + ))); + assert.ok(modelCalls.every((call) => ( + call.operation === "sales_dossier_agent_plan" + || call.operation === "sales_dossier_agent_replan" + ))); + assert.doesNotMatch(JSON.stringify(dossier.body), /企业ID|本次未检索到/); + assert.ok(dossier.body.every((paragraph) => ( + paragraph.text + .split(/\n+/u) + .filter(Boolean) + .every((line) => /[。!?]$/u.test(line)) + ))); +}); + +test("runtime rejects a QA answer that fabricates citation identifiers", async () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callJson() { + return { + ok: true, + parsed: { + paragraphs: [{ text: "这是一个没有真实来源的结论。", citation_ids: ["invented-source"] }], + insufficient: false, + }, + }; + }, + }, + }); + + await assert.rejects( + () => service.generateQaAnswer( + service.data.companies.company_1, + "测试问题", + null, + [], + [{ id: "evidence_real", label: "真实来源", source_kind: "专业数据集", summary: "真实内容" }], + ), + (error) => error.status === 503 + && error.code === "model_unavailable" + && error.details.validation_errors.some((item) => item.includes("无效引用")), + ); +}); + +test("runtime repairs a malformed QA JSON response from the original model output", async () => { + const calls = []; + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callJson(input) { + calls.push(structuredClone(input)); + if (calls.length === 1) { + return { + ok: false, + error: { + code: "invalid_json", + message: "Unterminated string in JSON response.", + }, + invalid_content: "{\"paragraphs\":[{\"text\":\"结论:企业正在推进扩产计划", + }; + } + return { + ok: true, + parsed: { + paragraphs: [ + { + text: "结论:现有资料显示企业正在推进扩产计划。", + citation_ids: ["evidence_real"], + }, + { + text: "下一步:核验采购时间表和预算窗口。", + citation_ids: ["evidence_real"], + }, + ], + insufficient: false, + }, + usage: { prompt_tokens: 160, completion_tokens: 80, total_tokens: 240 }, + raw_ref: "model:qa-retry", + }; + }, + }, + }); + + const answer = await service.generateQaAnswer( + service.data.companies.company_1, + "扩产计划和下一步行动是什么?", + null, + [], + [{ + id: "evidence_real", + label: "企业档案", + source_kind: "企业档案", + summary: "企业正在推进扩产计划,下一步需核验采购时间表和预算窗口。", + }], + ); + + assert.equal(calls.length, 2); + assert.equal(calls[0].operation, "sales_qa"); + assert.equal(calls[0].maxTokens, 1600); + assert.equal(calls[1].operation, "sales_qa_json_repair"); + assert.equal(calls[1].maxTokens, 2200); + assert.equal( + calls[1].payload.invalid_json_content, + "{\"paragraphs\":[{\"text\":\"结论:企业正在推进扩产计划", + ); + assert.equal(answer.insufficient, false); + assert.match(answer.text, /扩产计划/); + assert.deepEqual(answer.citation_ids, ["evidence_real"]); +}); + +test("runtime retries a QA answer that omits explicit table items", async () => { + const calls = []; + const evidence = [{ + id: "evidence_capabilities", + label: "个人投资助手 CookBook", + source_kind: "飞书云文档", + retrieval_score: 0.9, + summary: [ + "| 能力点 | 说明 |", + "|-|-|", + "| 语言模型 | 完成需求理解 |", + "| Claude code/ Agent 能力 | 负责任务编排 |", + "| 联网搜索 | 补充公开动态 |", + "| Data MCP:股票金融数据/国内企业工商数据 | 查询专业数据 |", + "| 多工具兼容 | 支持多个 Agent 平台 |", + "| 消耗统一计量 | 控制台查看消耗 |", + ].join(" "), + }]; + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callJson(input) { + calls.push(structuredClone(input)); + const complete = input.operation === "sales_qa_quality_retry"; + return { + ok: true, + parsed: { + paragraphs: [{ + text: complete + ? "文档列出的能力包括语言模型、Claude Code/Agent 能力、联网搜索、Data MCP、多工具兼容和消耗统一计量。" + : "文档列出的能力包括语言模型、Claude Code、联网搜索和 Data MCP。", + citation_ids: ["evidence_capabilities"], + }], + insufficient: false, + }, + usage: { prompt_tokens: 160, completion_tokens: 80, total_tokens: 240 }, + raw_ref: complete ? "model:qa-quality-retry" : "model:qa-incomplete", + }; + }, + }, + }); + + const answer = await service.generateQaAnswer( + service.data.companies.company_1, + "这份文档明确使用了哪些核心能力?", + null, + [], + evidence, + ); + + assert.equal(calls.length, 2); + assert.equal(calls[0].operation, "sales_qa"); + assert.equal(calls[1].operation, "sales_qa_quality_retry"); + assert.deepEqual( + calls[0].payload.enumeration_requirements.map((item) => item.label), + ["语言模型", "Claude code/ Agent 能力", "联网搜索", "Data MCP:股票金融数据/国内企业工商数据", "多工具兼容", "消耗统一计量"], + ); + assert.ok(calls[1].payload.validation_feedback.some((item) => item.includes("多工具兼容"))); + assert.match(answer.text, /消耗统一计量/); +}); + +test("runtime retries a QA answer with invalid citations and keeps fail-closed validation", async () => { + const calls = []; + const service = new SalesService({ + env: envReader(), + runtimePolicy: strictRuntimePolicy, + seed: seed(), + modelProvider: { + isRunEnabled: () => true, + async callJson(input) { + calls.push(structuredClone(input)); + const corrected = input.operation === "sales_qa_quality_retry"; + return { + ok: true, + parsed: { + paragraphs: [{ + text: corrected + ? "Trace 通过唯一 Trace ID 串联一次完整调用,Span 表示其中的单个执行节点。" + : "Trace 通过唯一 Trace ID 串联一次完整调用,Span 表示其中的单个执行节点。", + citation_ids: [corrected ? "evidence_trace" : "1"], + }], + insufficient: false, + }, + usage: { prompt_tokens: 160, completion_tokens: 80, total_tokens: 240 }, + raw_ref: corrected ? "model:qa-citation-retry" : "model:qa-invalid-citation", + }; + }, + }, + }); + + const answer = await service.generateQaAnswer( + service.data.companies.company_1, + "Trace 和 Span 分别承担什么作用?", + null, + [], + [{ + id: "evidence_trace", + label: "方舟全链路数据体系建设研讨会", + source_kind: "飞书云文档", + summary: "Trace 通过唯一 Trace ID 串联一次完整调用;每个执行节点对应一个 Span。", + }], + ); + + assert.equal(calls.length, 2); + assert.equal(calls[0].operation, "sales_qa"); + assert.equal(calls[1].operation, "sales_qa_quality_retry"); + assert.ok(calls[1].payload.validation_feedback.some((item) => item.includes("无效引用"))); + assert.deepEqual(answer.citation_ids, ["evidence_trace"]); + assert.equal(answer.citations[0].label, "方舟全链路数据体系建设研讨会"); +}); + +test("QA workflow preserves bounded citation validation diagnostics in the failed provider run", async () => { + const fixture = createWorkflowService(); + fixture.service.modelProvider = { + isRunEnabled: () => true, + async callJson() { + return { + ok: true, + parsed: { + paragraphs: [{ + text: "客户希望先验证知识库问答,并确认数据权限边界。", + citation_ids: ["invented-source"], + }], + insufficient: false, + }, + }; + }, + }; + const generateQaAnswer = fixture.service.generateQaAnswer.bind(fixture.service); + fixture.service.generateQaAnswer = async (...args) => { + const previousPolicy = fixture.service.runtimePolicy; + fixture.service.runtimePolicy = { ...previousPolicy, fail_closed: true }; + try { + return await generateQaAnswer(...args); + } finally { + fixture.service.runtimePolicy = previousPolicy; + } + }; + + await assert.rejects( + () => fixture.service.askQuestion("company_1", { question: "客户希望先验证什么?" }), + (error) => error.code === "model_unavailable", + ); + + const [run] = await fixture.service.listProviderRuns({ + operation: "sales_qa", + entity_id: "company_1", + }); + assert.equal(run.status, "failed"); + assert.ok(run.error.validation_errors.some((item) => item.includes("无效引用"))); + assert.equal((await fixture.service.getJob(run.job_id)).status, "failed"); +}); + +test("cancelled jobs remain cancelled when a late workflow completion arrives", async () => { + const fixture = createWorkflowService(); + const job = await fixture.service.startJob({ + job_type: "sales_dossier_generation", + entity_type: "target_enterprise", + entity_id: "company_1", + max_attempts: 3, + request: {}, + }); + + const cancelled = await fixture.service.cancelJob(job.id); + assert.equal(cancelled.status, "cancelled"); + assert.ok(cancelled.cancel_requested_at); + + await fixture.service.completeJob(job.id, { result_ref: "late-result" }); + await fixture.service.failJob(job.id, { code: "late-error", message: "late error" }); + const afterLateWrites = await fixture.service.getJob(job.id); + assert.equal(afterLateWrites.status, "cancelled"); + assert.equal(afterLateWrites.result_ref, null); + assert.equal(afterLateWrites.error, null); +}); + +test("failed jobs remain failed when a late workflow completion arrives", async () => { + const fixture = createWorkflowService(); + const job = await fixture.service.startJob({ + job_type: "sales_dossier_generation", + entity_type: "target_enterprise", + entity_id: "company_1", + max_attempts: 3, + request: {}, + }); + + await fixture.service.failJob(job.id, { + code: "provider_timeout", + message: "provider timeout", + retryable: true, + }); + await fixture.service.completeJob(job.id, { result_ref: "late-result" }); + + const afterLateCompletion = await fixture.service.getJob(job.id); + assert.equal(afterLateCompletion.status, "failed"); + assert.equal(afterLateCompletion.result_ref, null); + assert.equal(afterLateCompletion.error.code, "provider_timeout"); +}); + +test("manual retry reuses a failed dossier job and increments its attempt", async () => { + const fixture = createWorkflowService(); + const job = await fixture.service.startJob({ + job_type: "sales_dossier_generation", + entity_type: "target_enterprise", + entity_id: "company_1", + max_attempts: 3, + request: {}, + }); + await fixture.service.failJob(job.id, { + code: "temporary_provider_error", + message: "temporary provider error", + retryable: true, + }); + + const result = await fixture.service.retryJob(job.id); + const retried = await fixture.service.getJob(job.id); + assert.equal(result.job_id, job.id); + assert.equal(result.action, "created"); + assert.equal(retried.status, "succeeded"); + assert.equal(retried.attempt_count, 2); + assert.equal(retried.error, null); +}); + +test("manual retry rejects terminal success and exhausted attempts", async () => { + const fixture = createWorkflowService(); + const succeeded = await fixture.service.startJob({ + job_type: "sales_dossier_generation", + entity_type: "target_enterprise", + entity_id: "company_1", + max_attempts: 3, + }); + await fixture.service.completeJob(succeeded.id); + await assert.rejects( + () => fixture.service.retryJob(succeeded.id), + (error) => error.status === 409 && error.code === "job_not_retryable", + ); + + const exhausted = await fixture.service.startJob({ + job_type: "sales_dossier_generation", + entity_type: "target_enterprise", + entity_id: "company_1", + max_attempts: 1, + }); + await fixture.service.failJob(exhausted.id, { code: "failed", message: "failed" }); + await assert.rejects( + () => fixture.service.retryJob(exhausted.id), + (error) => error.status === 409 && error.code === "job_attempts_exhausted", + ); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/setupSupabasePolicy.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/setupSupabasePolicy.test.mjs new file mode 100644 index 00000000..61fe223c --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/setupSupabasePolicy.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const backendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const rootDir = path.resolve(backendDir, ".."); +const sourcePath = path.join(rootDir, "skills", "sales-intelligence-workbench", "scripts", "setup-supabase.mjs"); +const source = await fs.readFile(sourcePath, "utf8").catch((error) => { + if (error?.code === "ENOENT") return ""; + throw error; +}); +const sourceOnly = { skip: source ? false : "Skill policy is outside the standalone runtime package." }; + +test("Supabase setup rejects ordinary pay-as-you-go workspaces", sourceOnly, () => { + assert.match(source, /"projects", "list"/); + assert.match(source, /"--detail"/); + assert.match(source, /workspace\?\.is_agent_plan/); + assert.match(source, /workspace\?\.is_agent_plan_instance/); + assert.match(source, /目标不是 AI Native 应用开发底座(Supabase)的 Agent Plan Workspace/); +}); + +test("Supabase setup supports an explicit CLI profile without leaking static credentials", sourceOnly, () => { + assert.match(source, /SUPABASE_CLI_PROFILE/); + assert.match(source, /delete environment\.VOLCENGINE_ACCESS_KEY/); + assert.match(source, /delete environment\.VOLCENGINE_SECRET_KEY/); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/staticFrontend.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/staticFrontend.test.mjs new file mode 100644 index 00000000..868ebedf --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/staticFrontend.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createStaticFrontend } from "../src/frontend/staticFrontend.js"; + +function createResponse() { + return { + body: null, + headers: {}, + statusCode: null, + setHeader(name, value) { + this.headers[name.toLowerCase()] = value; + }, + writeHead(statusCode) { + this.statusCode = statusCode; + }, + end(body = null) { + this.body = body; + }, + }; +} + +async function withFrontend(run) { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "sales-frontend-")); + try { + await writeFile(path.join(rootDir, "index.html"), "Sales"); + await writeFile(path.join(rootDir, "app.js"), "window.sales = true;"); + await run(createStaticFrontend({ rootDir })); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } +} + +test("serves the workbench index from the root path", async () => { + await withFrontend(async (serve) => { + const response = createResponse(); + assert.equal(await serve({ method: "GET" }, response, "/"), true); + assert.equal(response.statusCode, 200); + assert.equal(response.headers["content-type"], "text/html; charset=utf-8"); + assert.equal(response.headers["cache-control"], "no-store"); + assert.match(response.body.toString(), /Sales/); + }); +}); + +test("serves assets without returning a response body for HEAD", async () => { + await withFrontend(async (serve) => { + const response = createResponse(); + assert.equal(await serve({ method: "HEAD" }, response, "/app.js"), true); + assert.equal(response.statusCode, 200); + assert.equal(response.headers["content-type"], "text/javascript; charset=utf-8"); + assert.equal(response.headers["cache-control"], "no-store"); + assert.equal(response.body, null); + }); +}); + +test("does not handle API paths", async () => { + await withFrontend(async (serve) => { + const response = createResponse(); + assert.equal(await serve({ method: "GET" }, response, "/api/health"), false); + assert.equal(response.statusCode, null); + }); +}); + +test("rejects encoded parent-directory traversal", async () => { + await withFrontend(async (serve) => { + const response = createResponse(); + assert.equal(await serve({ method: "GET" }, response, "/%2e%2e/secret.txt"), false); + assert.equal(response.statusCode, null); + }); +}); + +test("returns control to the API router for missing files", async () => { + await withFrontend(async (serve) => { + const response = createResponse(); + assert.equal(await serve({ method: "GET" }, response, "/missing.js"), false); + assert.equal(response.statusCode, null); + }); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseBackup.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseBackup.test.mjs new file mode 100644 index 00000000..b69894ac --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseBackup.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + BACKUP_FORMAT_VERSION, + prepareRowsForRestore, + validateBackupPackage, +} from "../src/backup/supabaseBackup.js"; + +test("restore preparation remaps tenancy and removes environment-bound fields", () => { + const rows = prepareRowsForRestore("sales_companies", [{ + id: "company-1", + workspace_id: "source-workspace", + name: "Example", + normalized_name: "example", + created_by: "source-user", + updated_by: "source-user", + }], "target-workspace"); + + assert.equal(rows[0].workspace_id, "target-workspace"); + assert.equal(rows[0].created_by, null); + assert.equal(rows[0].updated_by, null); + assert.equal(Object.hasOwn(rows[0], "normalized_name"), false); +}); + +test("restore preparation never carries provider secret references", () => { + const rows = prepareRowsForRestore("provider_connections", [{ + id: "provider-1", + workspace_id: "source-workspace", + status: "configured", + secret_ref: "secret://source/provider", + }], "target-workspace"); + + assert.equal(rows[0].secret_ref, null); + assert.equal(rows[0].status, "needs_reconfiguration"); +}); + +test("backup validation checks row counts and file hashes", () => { + const directory = mkdtempSync(join(tmpdir(), "sales-backup-test-")); + const dataPath = join(directory, "data.json"); + const data = { + format_version: BACKUP_FORMAT_VERSION, + backup_id: "backup-1", + tables: { sales_goals: [{ id: "goal-1" }] }, + }; + const content = `${JSON.stringify(data)}\n`; + writeFileSync(dataPath, content); + const manifest = { + format_version: BACKUP_FORMAT_VERSION, + backup_id: "backup-1", + row_counts: { sales_goals: 1 }, + files: [{ + path: "data.json", + sha256: createHash("sha256").update(content).digest("hex"), + }], + }; + + assert.equal(validateBackupPackage(directory, manifest, data), true); + manifest.row_counts.sales_goals = 2; + assert.throws(() => validateBackupPackage(directory, manifest, data), /row count mismatch/i); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseDataProvider.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseDataProvider.test.mjs new file mode 100644 index 00000000..0006fd09 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseDataProvider.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SupabaseDataProvider } from "../src/providers/supabaseDataProvider.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + number(name, fallback) { + const value = Number(this.value(name, fallback)); + return Number.isFinite(value) ? value : fallback; + }, + }; +} + +test("Supabase live probe checks the runtime Data API without control-plane credentials", async () => { + const calls = []; + const provider = new SupabaseDataProvider({ + env: envReader({ + SUPABASE_API_URL: "https://database.example.test", + SUPABASE_SERVICE_ROLE_KEY: "test-service-role", + SUPABASE_RUN_ENABLED: "true", + }), + fetchImpl: async (url, options) => { + calls.push({ url: String(url), options }); + return new Response(JSON.stringify([{ id: "workspace-1" }]), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + const result = await provider.probe(); + + assert.deepEqual(result, { ok: true, row_count: 1 }); + assert.equal(provider.isConfigured(), true); + assert.equal(provider.isRunEnabled(), true); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://database.example.test/rest/v1/app_workspaces?select=id&limit=1"); + assert.equal(calls[0].options.method, "GET"); + assert.equal(calls[0].options.headers.apikey, "test-service-role"); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseDataRepository.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseDataRepository.test.mjs new file mode 100644 index 00000000..cd96e792 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseDataRepository.test.mjs @@ -0,0 +1,256 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SupabaseDataRepository } from "../src/repositories/supabaseDataRepository.js"; + +const workspaceId = "54768bef-53aa-47d0-a9e3-bbca4593cf58"; + +function createProvider(options = {}) { + const calls = []; + return { + calls, + isConfigured: () => true, + async select(table, query) { + calls.push({ method: "select", table, query }); + if (table === "schema_migrations") return [{ version: "202607300001" }]; + if (table === "app_workspaces") return [{ id: workspaceId }]; + return options.select?.(table, query) || []; + }, + async update(table, values, filters) { + calls.push({ method: "update", table, values, filters }); + return options.update?.(table, values, filters) || []; + }, + async insert(table, rows) { + calls.push({ method: "insert", table, rows }); + return Array.isArray(rows) ? rows : [rows]; + }, + async rpc(name, body) { + calls.push({ method: "rpc", name, body }); + return options.rpc?.(name, body) || { ok: true }; + }, + }; +} + +test("Data API state reads scope every sales table to the application workspace", async () => { + const provider = createProvider(); + const repository = new SupabaseDataRepository({ supabaseDataProvider: provider, workspaceId }); + + const state = await repository.getSalesState(); + assert.deepEqual(state, { + goals: [], + companies: {}, + dossiers: {}, + materials: {}, + qa_messages: {}, + sync_sources: {}, + sync_checkpoints: {}, + jobs: {}, + }); + + const businessReads = provider.calls.filter((call) => call.method === "select") + .filter((call) => !["schema_migrations", "app_workspaces"].includes(call.table)); + assert.equal(businessReads.length, 11); + assert.ok(businessReads.every((call) => call.query.filters.workspace_id === `eq.${workspaceId}`)); + assert.equal(businessReads.some((call) => call.table === "sales_qa_messages"), false); +}); + +test("material sync metadata is persisted inside the application workspace", async () => { + const provider = createProvider(); + const repository = new SupabaseDataRepository({ supabaseDataProvider: provider, workspaceId }); + const syncedAt = "2026-07-21T10:00:00.000Z"; + + await repository.persistSyncSource({ + id: "sync-1", + source_type: "feishu_doc", + external_id: "doc-1", + display_name: "测试文档", + status: "active", + last_synced_at: syncedAt, + }); + await repository.persistSyncCheckpoint({ + id: "checkpoint-1", + source_id: "sync-1", + checkpoint_key: "revision_id", + checkpoint_value: "12", + content_hash: "hash-1", + last_success_at: syncedAt, + }); + await repository.persistSalesMaterial({ + id: "material-1", + company_id: "company-1", + title: "测试文档", + source_id: "sync-1", + source_version: "12", + content_hash: "hash-1", + last_synced_at: syncedAt, + }); + + const inserts = provider.calls.filter((call) => call.method === "insert"); + assert.deepEqual(inserts.map((call) => call.table), ["sync_sources", "sync_checkpoints", "sales_materials"]); + assert.ok(inserts.every((call) => call.rows.workspace_id === workspaceId)); + assert.equal(inserts.at(-1).rows.source_id, "sync-1"); + assert.equal(inserts.at(-1).rows.source_version, "12"); + assert.equal(inserts.at(-1).rows.summary, ""); + assert.equal(Object.hasOwn(inserts.at(-1).rows.payload_json, "text"), false); + assert.equal(Object.hasOwn(inserts.at(-1).rows.payload_json, "source_items"), false); +}); + +test("Data API upserts never update an identifier outside the application workspace", async () => { + const provider = createProvider(); + const repository = new SupabaseDataRepository({ supabaseDataProvider: provider, workspaceId }); + const goal = { + id: "goal-data-api", + name: "Data API Goal", + description: "test", + keywords: [], + created_at: "2026-07-21T00:00:00.000Z", + updated_at: "2026-07-21T00:00:00.000Z", + }; + + await repository.persistSalesGoal(goal); + + const update = provider.calls.find((call) => call.method === "update" && call.table === "sales_goals"); + const insert = provider.calls.find((call) => call.method === "insert" && call.table === "sales_goals"); + assert.deepEqual(update.filters, { workspace_id: `eq.${workspaceId}`, id: "eq.goal-data-api" }); + assert.equal(insert.rows.workspace_id, workspaceId); +}); + +test("multi-table writes use RPCs and provider runs retain their persistent job", async () => { + const provider = createProvider(); + const repository = new SupabaseDataRepository({ supabaseDataProvider: provider, workspaceId }); + const dossier = { id: "dossier-1", company_id: "company-1", citations: [] }; + const job = { id: "job-1", job_type: "dossier.generate", status: "running" }; + const run = { id: "run-1", job_id: job.id, operation: "test", status: "running", steps: [] }; + + await repository.persistJob(job); + await repository.persistSalesDossier(dossier); + await repository.persistProviderRun(run); + + const rpcCalls = provider.calls.filter((call) => call.method === "rpc"); + assert.deepEqual(rpcCalls.map((call) => call.name), ["persist_sales_dossier", "persist_provider_run"]); + assert.ok(rpcCalls.every((call) => call.body.p_workspace_id === workspaceId)); + assert.equal(rpcCalls.at(-1).body.p_run.job_id, job.id); +}); + +test("paid workflow reservations and releases use atomic workspace RPCs", async () => { + const provider = createProvider({ + rpc(name, body) { + if (name === "reserve_paid_workflow") { + return { job: body.p_job, budget: { running: 1, used_today: 1 } }; + } + if (name === "get_paid_workflow_usage") return { running: 0, used_today: 1 }; + return body.p_job || { ok: true }; + }, + }); + const repository = new SupabaseDataRepository({ supabaseDataProvider: provider, workspaceId }); + const candidate = { id: "job-budget", job_type: "sales_qa", status: "running", is_paid: true }; + const limits = { max_concurrent: 2, daily_limit: 50, timezone: "Asia/Shanghai", stale_after_seconds: 1800 }; + + const reserved = await repository.reservePaidWorkflow(candidate, "reservation-1", limits); + await repository.finishPaidWorkflow({ ...reserved.job, status: "succeeded" }, "reservation-1"); + const usage = await repository.getPaidWorkflowUsage("Asia/Shanghai"); + + assert.equal(reserved.budget.running, 1); + assert.equal(usage.used_today, 1); + const calls = provider.calls.filter((call) => call.method === "rpc").slice(-3); + assert.deepEqual(calls.map((call) => call.name), [ + "reserve_paid_workflow", + "finish_paid_workflow", + "get_paid_workflow_usage", + ]); + assert.ok(calls.every((call) => call.body.p_workspace_id === workspaceId)); +}); + +test("asynchronous jobs enqueue, claim, heartbeat, cancel safely, release and retry through atomic RPCs", async () => { + const provider = createProvider({ + rpc(name, body) { + if (name === "claim_sales_job") { + return { + ...body, + id: "job-async", + job_type: "sales_dossier_generation", + status: "running", + stage: "starting", + progress: 1, + attempt_count: 1, + max_attempts: 3, + payload_json: { request: {} }, + }; + } + return { + id: "job-async", + job_type: "sales_dossier_generation", + status: name === "enqueue_sales_job" || name === "retry_sales_job" + ? "queued" + : name === "acknowledge_cancel_sales_job" ? "cancelled" : "running", + stage: name === "enqueue_sales_job" || name === "retry_sales_job" + ? "queued" + : name === "request_cancel_sales_job" ? "cancelling" + : name === "acknowledge_cancel_sales_job" ? "cancelled" : "collecting_evidence", + progress: name === "enqueue_sales_job" || name === "retry_sales_job" ? 0 : 20, + attempt_count: name === "enqueue_sales_job" ? 0 : 1, + max_attempts: 3, + payload_json: body.p_job || { request: {} }, + }; + }, + }); + const repository = new SupabaseDataRepository({ supabaseDataProvider: provider, workspaceId }); + const queued = await repository.enqueueJob({ + id: "job-async", + job_type: "sales_dossier_generation", + status: "queued", + request: {}, + }); + const claimed = await repository.claimNextJob("worker-1", ["sales_dossier_generation"], 600); + const heartbeat = await repository.heartbeatJob(claimed.id, "worker-1", "collecting_evidence", 20, 600); + const checkpointed = await repository.saveJobCheckpoint( + claimed.id, + "worker-1", + { + dossier: { + schema_version: 1, + company_id: "company-1", + evidence_collection: { completed_query_keys: ["datapro:business"] }, + }, + }, + { + stage: "collecting_professional", + progress: 24, + detail: { current: 1, total: 2, message: "正在核验专业资料 1/2" }, + lease_seconds: 600, + }, + ); + const cancelling = await repository.requestJobCancellation(claimed.id); + const cancelled = await repository.acknowledgeJobCancellation(claimed.id, "worker-1"); + await repository.releaseJobClaim(claimed.id, "worker-1", { code: "temporary" }, { retry: true, delay_seconds: 5 }); + const retried = await repository.retryQueuedJob(claimed.id); + + assert.equal(queued.status, "queued"); + assert.equal(claimed.status, "running"); + assert.equal(heartbeat.progress, 20); + assert.equal(checkpointed.progress, 20); + assert.equal(cancelling.stage, "cancelling"); + assert.equal(cancelled.status, "cancelled"); + assert.equal(retried.status, "queued"); + const rpcCalls = provider.calls.filter((call) => call.method === "rpc").slice(-8); + assert.deepEqual(rpcCalls.map((call) => call.name), [ + "enqueue_sales_job", + "claim_sales_job", + "heartbeat_sales_job", + "checkpoint_sales_job", + "request_cancel_sales_job", + "acknowledge_cancel_sales_job", + "release_sales_job_claim", + "retry_sales_job", + ]); + assert.ok(rpcCalls.every((call) => call.body.p_workspace_id === workspaceId)); + const checkpointCall = rpcCalls.find((call) => call.name === "checkpoint_sales_job"); + assert.equal(checkpointCall.body.p_worker_id, "worker-1"); + assert.deepEqual(checkpointCall.body.p_progress_detail, { + current: 1, + total: 2, + message: "正在核验专业资料 1/2", + }); + assert.deepEqual(checkpointCall.body.p_checkpoint_patch.dossier.evidence_collection.completed_query_keys, [ + "datapro:business", + ]); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseProvider.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseProvider.test.mjs new file mode 100644 index 00000000..e7ef40db --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseProvider.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SupabaseProvider } from "../src/providers/supabaseProvider.js"; + +function envReader(values = {}) { + return { + value(name, fallback = "") { + return Object.hasOwn(values, name) ? values[name] : fallback; + }, + number(name, fallback = 0) { + const value = Number(Object.hasOwn(values, name) ? values[name] : fallback); + return Number.isFinite(value) ? value : fallback; + }, + }; +} + +const configured = { + SUPABASE_WORKSPACE_ID: "workspace-test", + SUPABASE_BRANCH_ID: "branch-test", + SUPABASE_CLI_BIN: "fake-supabase-cli", + VOLCENGINE_ACCESS_KEY: "test-access-key", + VOLCENGINE_SECRET_KEY: "test-secret-key", +}; + +test("Supabase provider parses the official CLI rows envelope", async () => { + let invocation = null; + const provider = new SupabaseProvider({ + env: envReader({ ...configured, SUPABASE_READ_ONLY: "false" }), + execFile: async (command, args, options) => { + invocation = { command, args, options }; + return { + stdout: JSON.stringify({ boundary: "test", rows: [{ answer: 42 }], warning: "" }), + stderr: "", + }; + }, + }); + + const result = await provider.executeSql("select 42 as answer;"); + assert.equal(result.ok, true); + assert.deepEqual(result.rows, [{ answer: 42 }]); + assert.equal(invocation.command, "fake-supabase-cli"); + assert.ok(invocation.args.includes("workspace-test")); + assert.ok(invocation.args.includes("branch-test")); + assert.equal(invocation.options.env.VOLCENGINE_ACCESS_KEY, "test-access-key"); +}); + +test("Supabase provider blocks writes locally when read-only mode is enabled", async () => { + let called = false; + const provider = new SupabaseProvider({ + env: envReader({ ...configured, SUPABASE_READ_ONLY: "true" }), + execFile: async () => { + called = true; + return { stdout: "[]", stderr: "" }; + }, + }); + + const result = await provider.executeSql("update public.sales_goals set name = 'blocked';"); + assert.equal(result.ok, false); + assert.equal(result.error.code, "read_only"); + assert.equal(called, false); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseSecurityBoundary.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseSecurityBoundary.test.mjs new file mode 100644 index 00000000..0897f2dd --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/supabaseSecurityBoundary.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const migrationPath = path.join( + root, + "supabase", + "migrations", + "202607280002_secure_internal_tables.sql", +); + +test("internal metadata tables are fail-closed for ordinary roles", () => { + const sql = fs.readFileSync(migrationPath, "utf8"); + + assert.match(sql, /alter table public\.schema_migrations enable row level security/i); + assert.match(sql, /revoke all on table public\.schema_migrations from public, anon, authenticated/i); + assert.match(sql, /grant all on table public\.schema_migrations to service_role/i); + assert.doesNotMatch(sql, /alter table public\.health_check/i); + assert.match(sql, /values \('202607280002'/i); + assert.doesNotMatch(sql, /\b(?:drop|truncate|delete)\b/i); +}); + +test("live verifier treats platform-owned health checks as a separate fail-closed boundary", () => { + const verifier = fs.readFileSync( + path.join(root, "backend", "scripts", "verify-supabase-security-boundary.mjs"), + "utf8", + ); + + assert.match(verifier, /platformManagedTables = new Set\(\["health_check"\]\)/); + assert.match(verifier, /platform_managed_tables_fail_closed/); + assert.match(verifier, /project_public_tables_use_rls/); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/workspaceExport.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/workspaceExport.test.mjs new file mode 100644 index 00000000..2d2b5b8d --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/workspaceExport.test.mjs @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SalesService } from "../src/services/salesService.js"; + +const FORBIDDEN_KEYS = new Set([ + "access_token", + "api_key", + "lease_token", + "openviking_ref", + "openviking_uri", + "password", + "prompt", + "raw_ref", + "refresh_token", + "reservation_id", + "secret", + "service_role_key", + "worker_id", +]); + +function privatePaths(value, current = "$", found = []) { + if (Array.isArray(value)) { + value.forEach((item, index) => privatePaths(item, `${current}[${index}]`, found)); + return found; + } + if (!value || typeof value !== "object") return found; + for (const [key, item] of Object.entries(value)) { + const next = `${current}.${key}`; + if (FORBIDDEN_KEYS.has(key.toLowerCase())) found.push(next); + privatePaths(item, next, found); + } + return found; +} + +function envReader() { + return { + value(name, fallback = "") { + return name === "APP_WORKSPACE_ID" ? "workspace-test" : fallback; + }, + }; +} + +test("workspace export retains portable business content and removes runtime internals", () => { + const service = new SalesService({ + env: envReader(), + runtimePolicy: { + fail_closed: false, + }, + seed: { + goals: [{ + id: "goal-1", + name: "授权客户跟进", + description: "测试目标", + keywords: ["知识库"], + company_ids: ["company-1"], + candidate_ids: ["company-1"], + created_at: "2026-07-23T08:00:00.000Z", + updated_at: "2026-07-23T08:00:00.000Z", + }], + companies: { + "company-1": { + id: "company-1", + name: "测试科技有限公司", + industry: "企业软件", + identity_status: "verified", + progress: { label: "需求确认", summary: "确认数据边界", evidence: "会议纪要" }, + dossier_ids: ["dossier-1"], + material_ids: ["material-1"], + qa_session_id: "qa-company-1", + }, + }, + dossiers: { + "dossier-1": { + id: "dossier-1", + company_id: "company-1", + title: "测试科技有限公司最新档案", + summary: "已确认企业主体。", + body: [{ text: "企业主体已核验。", citation_ids: ["source-1"] }], + citations: [{ + id: "source-1", + label: "专业数据库", + source_kind: "专业数据集", + raw_ref: "must-not-export", + }], + openviking_uri: "viking://must-not-export", + version_no: 1, + created_at: "2026-07-23T08:10:00.000Z", + }, + }, + materials: { + "material-1": { + id: "material-1", + company_id: "company-1", + title: "获授权会议纪要", + summary: "客户要求明确数据边界。", + text: "客户要求明确数据边界,并确认后续试点范围。", + source_type: "feishu_chat", + source_id: "source-material-1", + source_external_id: "chat-stable-id", + source_version: "v1", + source_items: [{ + id: "message-1", + sender: "授权测试用户", + content: "请先确认数据边界。", + occurred_at: "2026-07-23T08:05:00.000Z", + }], + openviking_uri: "viking://must-not-export/material", + openviking_ref: "must-not-export", + updated_at: "2026-07-23T08:05:00.000Z", + }, + }, + qa_messages: { + "company-1": [{ + id: "qa-1", + role: "assistant", + text: "客户关注数据边界。", + citation_ids: ["material:material-1"], + citations: [], + raw_ref: "must-not-export", + created_at: "2026-07-23T08:20:00.000Z", + }], + }, + sync_sources: { + "source-material-1": { + id: "source-material-1", + source_type: "feishu_chat", + external_id: "chat-stable-id", + display_name: "获授权会议纪要", + status: "active", + secret_ref: "must-not-export", + updated_at: "2026-07-23T08:05:00.000Z", + }, + }, + sync_checkpoints: {}, + jobs: { + "job-private": { + id: "job-private", + worker_id: "must-not-export", + reservation_id: "must-not-export", + }, + }, + }, + }); + + const exported = service.exportWorkspaceData(); + + assert.equal(exported.format_version, 1); + assert.equal(exported.contains_private_business_data, true); + assert.deepEqual(exported.goals[0].target_enterprise_ids, ["company-1"]); + assert.equal(exported.enterprises[0].materials[0].raw_text, "客户要求明确数据边界,并确认后续试点范围。"); + assert.equal(exported.enterprises[0].materials[0].source_items[0].id, "message-1"); + assert.equal(exported.enterprises[0].qa.messages[0].text, "客户关注数据边界。"); + assert.deepEqual(privatePaths(exported), []); + assert.doesNotMatch(JSON.stringify(exported), /viking:\/\/|must-not-export|job-private/); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/frontend/app.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/frontend/app.js new file mode 100644 index 00000000..5bb7e585 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/frontend/app.js @@ -0,0 +1,1996 @@ +(function () { + const $ = (selector, root = document) => root.querySelector(selector); + const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector)); + const defaultApiBase = ["http:", "https:"].includes(window.location.protocol) + ? `${window.location.origin}/api` + : "http://127.0.0.1:8787/api"; + const API_BASE = (window.SALES_WORKBENCH_API_BASE || defaultApiBase).replace(/\/$/, ""); + const TARGET_STATUS_FILTERS = ["全部", "新商机", "初步接触", "需求确认", "商务推进", "成交归档"]; + const MATERIAL_FILTERS = ["全部", "档案", "飞书会话", "云文档"]; + const DOSSIER_SECTION_TITLES = [ + "企业与业务概览", + "经营与业务动态", + "近期公开动态", + "风险与关注事项", + "销售机会判断", + "建议行动", + ]; + const QA_SECTION_HEADING_SOURCE = "结论|依据(?:[((][^))]+[))])?|当前情况|关键发现|风险|建议|下一步|行动(?:项)?|资料缺口|补充说明"; + const QA_SECTION_HEADING_PATTERN = new RegExp(`^(${QA_SECTION_HEADING_SOURCE})[::]\\s*([\\s\\S]+)$`); + const { + collapseRepeatedCitationRuns, + dedupeCitationEntries, + normalizeChineseTypography, + splitReadableBlocks, + } = window.SalesTextFormat; + + let goals = []; + let companies = {}; + + const state = { + activeGoalId: "", + activeCompanyId: "", + selectedDossierId: "", + targetStatusFilter: "全部", + materialFilter: "全部", + supportView: "library", + query: "", + hasSearched: false, + showNewGoal: false, + bootLoading: true, + bootError: "", + auth: { + checked: false, + enabled: false, + authenticated: false, + bootstrapRequired: false, + user: null, + }, + authBusy: "", + authError: "", + authNotice: "", + feishuImportOpen: false, + feishuImportAvailable: null, + feishuImportKind: "conversation", + feishuImportDraft: { target: "", start: "", end: "" }, + feishuImportTask: null, + feishuImportError: "", + busy: "", + qaPendingCompanyId: "", + notice: "", + sidebarNotice: "", + jobsByCompany: {}, + mobileNavigationOpen: false, + qaMessages: [], + qaMessagesByCompany: {}, + }; + let bootGeneration = 0; + let feishuImportPollToken = 0; + const jobPollTokens = new Map(); + + function resetConnectedState() { + goals = []; + companies = {}; + state.activeGoalId = ""; + state.activeCompanyId = ""; + state.selectedDossierId = ""; + state.qaMessages = []; + state.qaMessagesByCompany = {}; + state.jobsByCompany = {}; + state.feishuImportOpen = false; + state.feishuImportAvailable = null; + state.feishuImportTask = null; + state.feishuImportError = ""; + } + + function cookieValue(name) { + const prefix = `${name}=`; + for (const item of String(document.cookie || "").split(";")) { + const trimmed = item.trim(); + if (!trimmed.startsWith(prefix)) continue; + try { + return decodeURIComponent(trimmed.slice(prefix.length)); + } catch { + return trimmed.slice(prefix.length); + } + } + return ""; + } + + async function api(path, options = {}) { + const method = options.method || "GET"; + const headers = { ...(options.headers || {}) }; + if (options.body) headers["Content-Type"] = "application/json"; + if (!["GET", "HEAD"].includes(method)) { + const csrfToken = cookieValue("siw_csrf"); + if (csrfToken) headers["X-CSRF-Token"] = csrfToken; + } + const response = await fetch(`${API_BASE}${path}`, { + method, + credentials: "same-origin", + headers: Object.keys(headers).length ? headers : undefined, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const error = new Error(payload.error?.message || `请求失败:${response.status}`); + error.status = response.status; + error.code = payload.error?.code || "api_error"; + error.details = payload.error?.details || null; + error.requestId = payload.meta?.request_id || ""; + if (response.status === 401 && !options.skipAuthRedirect) { + state.auth.checked = true; + state.auth.enabled = true; + state.auth.authenticated = false; + state.auth.user = null; + queueMicrotask(render); + } + throw error; + } + return payload.data; + } + + function goalPlaceholder(goal) { + const keyword = (goal.keywords || [])[0] || "行业、区域或公司"; + return `输入${keyword}关键词`; + } + + function mapCompanyFromApi(item) { + if (!item) return null; + return { + id: item.id, + name: item.name, + initial: item.initial || item.name?.slice(0, 1) || "企", + location: item.location || "", + industry: item.industry || "企业", + tags: item.tags || [item.industry, item.location].filter(Boolean), + status: item.status, + progress: item.progress, + evidence: item.evidence, + progressLevel: item.progress_level || progressLevelFromStatus(item.status), + updatedAt: formatTime(item.updated_at, item.updatedAt || "尚未更新"), + updates: item.updates || [], + library: item.library || [], + qaAnswer: item.qaAnswer || "", + }; + } + + function mapDossierFromApi(item, options = {}) { + return { + id: item.id, + title: item.title, + summary: item.summary || "", + body: "", + bodyParagraphs: (Array.isArray(item.body) ? item.body : []).map((paragraph) => ({ + text: paragraph.text, + citationIds: paragraph.citation_ids || [], + segments: (paragraph.segments || []).map((segment) => ({ + text: segment.text || "", + citationIds: segment.citation_ids || [], + })), + })), + citations: (item.citations || []).map((source) => ({ + id: source.id, + label: source.label, + kind: source.source_kind, + url: isPlaceholderUrl(source.url) ? "" : source.url || "", + summary: source.summary || source.excerpt || "", + siteName: source.site_name || "", + publishedAt: source.published_at || null, + })), + versionNo: Number(item.version_no || 1), + previousDossierId: item.previous_dossier_id || null, + changeStatus: item.change_status || "initial", + dataAsOf: item.data_as_of ?? null, + generatedAt: item.generated_at || item.created_at || null, + date: formatTime(item.generated_at || item.created_at, item.date || ""), + detailLoadError: Boolean(options.detailLoadError), + }; + } + + function mapMaterialFromApi(item) { + return { + id: item.id, + title: item.title, + summary: item.summary || "", + time: formatTime(item.updated_at, ""), + sourceType: inferMaterialType(item.title, item.source_type), + }; + } + + function mapQaMessage(message) { + const citationEntries = (message.citations || []) + .map((item) => (typeof item === "string" + ? { id: "", label: item } + : { id: String(item.id || ""), label: item.label || "" })) + .filter((item) => item.label); + return { + role: message.role, + text: message.text, + paragraphs: (message.paragraphs || []) + .map((paragraph) => ({ + text: paragraph.text || "", + citationIds: (paragraph.citation_ids || paragraph.citationIds || []).map(String), + })) + .filter((paragraph) => paragraph.text), + citations: citationEntries.map((item) => item.label), + citationEntries, + }; + } + + function apiErrorMessage(_error, fallback) { + return fallback || "操作没有完成,请稍后重试。"; + } + + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function isPlaceholderUrl(value) { + return /(^https?:\/\/)?(www\.)?example\.(com|test)\b/i.test(String(value || "")); + } + + function splitDisplayParagraphs(value, maxLength = 180) { + return splitReadableBlocks(value, maxLength); + } + + function formatTime(value, fallback = "") { + if (!value) return fallback; + const text = String(value); + const normalized = text + .replace(" ", "T") + .replace(/([+-]\d{2})$/, "$1:00"); + const date = new Date(normalized); + if (Number.isNaN(date.getTime())) return fallback || text; + return date.toLocaleString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).replace(/\//g, "-"); + } + + function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + function dossierJobForCompany(companyId) { + return state.jobsByCompany[companyId] || null; + } + + function isActiveJob(job) { + return ["queued", "running"].includes(String(job?.status || "")); + } + + function rememberDossierJob(job, companyId = job?.entity_id) { + if (!job?.id || !companyId || job.job_type !== "sales_dossier_generation") return null; + state.jobsByCompany[companyId] = job; + return job; + } + + function makeIdempotencyKey(action, entityId) { + const random = window.crypto?.randomUUID?.() + || `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `${action}:${entityId}:${random}`; + } + + function dossierRequestStorageKey(companyId) { + return `sales-workbench:dossier-request:${companyId}`; + } + + function dossierRequestIdempotencyKey(companyId) { + const storageKey = dossierRequestStorageKey(companyId); + try { + const existing = window.sessionStorage.getItem(storageKey); + if (existing) return existing; + const created = makeIdempotencyKey("dossier", companyId); + window.sessionStorage.setItem(storageKey, created); + return created; + } catch { + return makeIdempotencyKey("dossier", companyId); + } + } + + function clearDossierRequestIdempotencyKey(companyId) { + try { + window.sessionStorage.removeItem(dossierRequestStorageKey(companyId)); + } catch { + // Storage can be unavailable in hardened browser contexts. + } + } + + async function loadLatestDossierJob(companyId) { + if (!companyId) return null; + const jobs = await api(`/jobs?job_type=sales_dossier_generation&entity_id=${encodeURIComponent(companyId)}&limit=1`); + const latest = Array.isArray(jobs) ? jobs[0] || null : null; + if (latest) { + clearDossierRequestIdempotencyKey(companyId); + rememberDossierJob(latest, companyId); + if (isActiveJob(latest)) monitorDossierJob(latest, companyId); + } + return latest; + } + + function stopJobMonitor(jobId) { + const token = jobPollTokens.get(jobId); + if (token) token.active = false; + jobPollTokens.delete(jobId); + } + + function monitorDossierJob(initialJob, companyId) { + if (!initialJob?.id || !isActiveJob(initialJob) || jobPollTokens.has(initialJob.id)) return; + const token = { active: true }; + jobPollTokens.set(initialJob.id, token); + + void (async () => { + let job = initialJob; + let failures = 0; + try { + while (token.active && isActiveJob(job)) { + await wait(1200); + if (!token.active) return; + try { + job = await api(`/jobs/${encodeURIComponent(job.id)}`); + failures = 0; + } catch (error) { + failures += 1; + if (failures < 5) continue; + if (state.activeCompanyId === companyId) { + state.notice = apiErrorMessage(error, "任务仍在后台执行,但暂时无法更新进度。"); + render(); + } + return; + } + rememberDossierJob(job, companyId); + if (state.activeCompanyId === companyId) render(); + } + + if (!token.active) return; + if (job.status === "succeeded") { + await hydrateCompany(companyId, { loadJob: false }).catch(() => null); + if (state.activeCompanyId === companyId) { + if (job.result?.dossier_id) state.selectedDossierId = job.result.dossier_id; + state.notice = job.result?.action === "no_material_change" + ? "证据未变化,保留当前版本" + : job.result?.version_no + ? `已生成档案 V${job.result.version_no}` + : "已生成最新档案"; + render(); + } + return; + } + if (state.activeCompanyId === companyId) { + state.notice = job.status === "cancelled" + ? "档案生成任务已取消" + : "档案生成失败,可在此重试。"; + render(); + } + } finally { + if (jobPollTokens.get(initialJob.id) === token) jobPollTokens.delete(initialJob.id); + } + })(); + } + + function progressLevelFromStatus(status) { + const text = String(status || ""); + if (/签约|成交|已确认|方案|推进/.test(text)) return 78; + if (/需求确认/.test(text)) return 58; + if (/初步|接触/.test(text)) return 34; + if (/暂无|不足/.test(text)) return 12; + if (/新商机/.test(text)) return 22; + return 42; + } + + function salesStatus(status) { + const text = String(status || ""); + if (/签约|成交|归档|已成交/.test(text)) return "成交归档"; + if (/方案|报价|商务|推进/.test(text)) return "商务推进"; + if (/需求确认|需求/.test(text)) return "需求确认"; + if (/初步|接触/.test(text)) return "初步接触"; + return "新商机"; + } + + function conciseProgressText(item) { + const status = salesStatus(item.status); + const text = String(item.progress || "").replace(/\s+/g, " ").trim(); + if (text && text.length <= 28 && !/最近档案|企业情况|近期动态|销售判断|下一步建议|专业数据库|联网搜索|但|需要/.test(text)) { + return text; + } + const fallback = { + 新商机: "已加入目标企业池,当前无历史资料,待生成最新档案。", + 初步接触: "已完成基础信息了解,尚未形成明确采购计划。", + 需求确认: "已识别数据安全与私有化部署需求,待确认预算和排期。", + 商务推进: "已进入方案沟通阶段,待确认商务条件和决策流程。", + 成交归档: "已完成合作归档,后续关注续约和扩展机会。", + }; + return fallback[status] || "当前进度待补充。"; + } + + function goalStats(count) { + return `${Number(count) || 0} 家企业`; + } + + function sourceRank(source) { + const text = `${source.kind || ""} ${source.label || ""}`; + if (/专业数据|专业数据库|工商|招投标/.test(text)) return 0; + if (/联网搜索|公开|新闻|公告|媒体|官网/.test(text)) return 1; + return 2; + } + + function displaySourceKind(kind) { + return /专业数据|专业数据库|工商|招投标/.test(String(kind || "")) + ? "专业数据集(DataPro)" + : /联网搜索|公开|新闻|公告|媒体|官网/.test(String(kind || "")) + ? "联网搜索" + : kind || "来源"; + } + + function sourceSiteName(source) { + if (source.siteName) return String(source.siteName).trim(); + try { + return new URL(source.url).hostname.replace(/^www\./i, ""); + } catch { + return "公开网页"; + } + } + + function sourcePublishLabel(source) { + const publishedAt = formatTime(source.publishedAt, ""); + return publishedAt ? `发布于 ${publishedAt}` : "未标注发布时间"; + } + + function professionalSourceDetails(source) { + const knownFieldPattern = /^(?:公司名称|企业名称|统一社会信用代码|注册号|法定代表人|法人姓名|公司组织类型|企业类型|注册地址|成立日期|注册资本|实缴资本|经营状态|登记状态|经营范围|所属行业|参保人数|核准日期|营业期限|自身风险|关联风险|司法案件|涉诉关系|立案信息|开庭公告|法院公告|行政处罚|经营异常|失信被执行人|被执行人|知识产权|专利|商标|著作权|分支机构|股东|主要人员)$/; + const details = []; + const parts = String(source.summary || "") + .split(/[;;]\s*/) + .map((item) => item.trim()) + .filter(Boolean); + for (const item of parts) { + const match = item.match(/^([^::]{1,28})[::]\s*(.+)$/); + const label = match?.[1]?.trim() || ""; + if (match && knownFieldPattern.test(label)) { + details.push({ label, value: match[2].trim() }); + } else if (details.length) { + details[details.length - 1].value += `;${item}`; + } else { + details.push({ label: "数据项", value: item }); + } + } + return details.map((item) => { + const cleanValue = item.value.replace(/[((]\s*$/, "").trim(); + return { + ...item, + value: /日期|时间/.test(item.label) ? formatTime(cleanValue, cleanValue) : cleanValue, + }; + }); + } + + function inferMaterialType(title, explicitType = "") { + const explicit = String(explicitType || "").trim(); + const identity = `${explicit} ${title || ""}`.toLowerCase(); + if (/feishu_(?:p2p|chat|search)|单聊|群聊|消息|会话|沟通|摘录/.test(identity)) { + return "飞书会话"; + } + if (/feishu_doc|云文档|文档|会议|纪要|方案|草案/.test(identity)) { + return "云文档"; + } + return "云文档"; + } + + function normalizeDossierDisplay(sources, paragraphs) { + const orderedSources = [...sources] + .map((source, index) => ({ ...source, oldId: String(source.id || index + 1) })) + .sort((a, b) => sourceRank(a) - sourceRank(b)); + const idMap = new Map(orderedSources.map((source, index) => [source.oldId, String(index + 1)])); + return { + sources: orderedSources.map((source, index) => ({ + ...source, + id: String(index + 1), + kind: displaySourceKind(source.kind), + oldId: undefined, + })), + paragraphs: paragraphs.map((paragraph) => ({ + ...paragraph, + citationIds: (paragraph.citationIds || []) + .map((id) => idMap.get(String(id)) || null) + .filter(Boolean), + segments: (paragraph.segments || []).map((segment) => ({ + ...segment, + citationIds: (segment.citationIds || []) + .map((id) => idMap.get(String(id)) || null) + .filter(Boolean), + })), + })), + }; + } + + function materialRecords(item) { + return item.library || []; + } + + function historicalDossierRecords(item) { + return (item.updates || []).map((dossier) => ({ + id: dossier.id, + title: dossier.title, + time: dossier.date, + sourceType: "档案", + versionNo: dossier.versionNo || 1, + isDossier: true, + })); + } + + async function loadSalesData() { + const apiGoals = await api("/sales-goals"); + const enriched = []; + for (const goal of apiGoals) { + const targets = await api(`/sales-goals/${encodeURIComponent(goal.id)}/target-enterprises`); + targets.forEach((item) => { + const mapped = mapCompanyFromApi(item); + if (mapped) companies[mapped.id] = { ...(companies[mapped.id] || {}), ...mapped }; + }); + enriched.push({ + id: goal.id, + name: goal.name, + stats: goalStats(targets.length), + placeholder: goalPlaceholder(goal), + related: [], + pool: targets.map((item) => item.id), + }); + } + if (enriched.length) goals = enriched; + if (!goals.some((goal) => goal.id === state.activeGoalId)) state.activeGoalId = goals[0]?.id || ""; + await hydrateVisibleCompany(); + } + + async function loadGoalCompanies(goalId, query = "") { + const goal = goals.find((item) => item.id === goalId); + if (!goal) return; + const normalizedQuery = query.trim(); + const [targets, candidates] = await Promise.all([ + api(`/sales-goals/${encodeURIComponent(goalId)}/target-enterprises`), + normalizedQuery + ? api(`/sales-goals/${encodeURIComponent(goalId)}/company-search`, { method: "POST", body: { query: normalizedQuery } }) + : Promise.resolve([]), + ]); + [...targets, ...candidates].forEach((item) => { + const mapped = mapCompanyFromApi(item); + if (mapped) companies[mapped.id] = { ...(companies[mapped.id] || {}), ...mapped }; + }); + goal.pool = targets.map((item) => item.id); + goal.related = candidates.map((item) => item.id); + goal.stats = goalStats(goal.pool.length); + } + + async function loadDossierDetail(record, attempts = 3) { + let lastError = null; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + return mapDossierFromApi(await api(`/dossiers/${encodeURIComponent(record.id)}`)); + } catch (error) { + lastError = error; + if (attempt + 1 < attempts) await wait(350 * (attempt + 1)); + } + } + throw lastError; + } + + async function hydrateCompany(companyId, options = {}) { + if (!companyId) return null; + const detail = await api(`/target-enterprises/${encodeURIComponent(companyId)}`); + const mapped = mapCompanyFromApi(detail); + if (!mapped) return null; + const existingUpdates = companies[companyId]?.updates || []; + let dossierDetailFailures = 0; + const dossierDetails = await Promise.all((detail.dossiers || []).map(async (record) => { + try { + return await loadDossierDetail(record); + } catch (error) { + dossierDetailFailures += 1; + return existingUpdates.find((item) => item.id === record.id && item.bodyParagraphs?.length) + || mapDossierFromApi(record, { detailLoadError: true }); + } + })); + mapped.updates = dossierDetails; + mapped.library = (detail.materials || []).map(mapMaterialFromApi); + mapped.qaAnswer = detail.qa?.messages?.find((message) => message.role === "assistant")?.text || mapped.qaAnswer || ""; + companies[mapped.id] = { ...(companies[mapped.id] || {}), ...mapped }; + rememberCompanyQa(mapped.id, (detail.qa?.messages || qaMessagesForCompany(mapped)).map(mapQaMessage)); + if (state.activeCompanyId === mapped.id + && (!state.selectedDossierId || !mapped.updates.some((item) => item.id === state.selectedDossierId))) { + state.selectedDossierId = mapped.updates[0]?.id || ""; + } + if (state.activeCompanyId === mapped.id && dossierDetailFailures) { + state.notice = "部分档案详情暂时未加载,系统已自动重试;请稍后刷新页面。"; + } + if (options.loadJob !== false) await loadLatestDossierJob(mapped.id).catch(() => null); + return mapped; + } + + async function hydrateVisibleCompany() { + const current = visibleCompany(); + if (current?.id) await hydrateCompany(current.id).catch(() => null); + } + + function activeGoal() { + return goals.find((goal) => goal.id === state.activeGoalId) || goals[0] || { + id: "", + name: "", + stats: "0 家企业", + placeholder: "请先创建销售目标", + related: [], + pool: [], + }; + } + + function company(id) { + return companies[id] || null; + } + + function visibleCompany() { + const goal = activeGoal(); + if (!goal.pool.includes(state.activeCompanyId)) { + state.activeCompanyId = goal.pool[0] || ""; + } + return company(state.activeCompanyId); + } + + function qaMessagesForCompany(item) { + if (!item?.id) return []; + if (!state.qaMessagesByCompany[item.id]) { + state.qaMessagesByCompany[item.id] = []; + } + return state.qaMessagesByCompany[item.id]; + } + + function rememberCompanyQa(companyId, messages) { + if (!companyId) return; + state.qaMessagesByCompany[companyId] = messages || []; + if (state.activeCompanyId === companyId) { + state.qaMessages = state.qaMessagesByCompany[companyId]; + } + } + + function activateCompanyQa(companyId) { + const item = company(companyId); + state.qaMessages = qaMessagesForCompany(item); + } + + function activePool() { + return activeGoal().pool + .map(company) + .filter(Boolean) + .filter((item) => state.targetStatusFilter === "全部" || salesStatus(item.status) === state.targetStatusFilter); + } + + function relatedCompanies() { + const goal = activeGoal(); + const normalizedQuery = state.query.trim().toLowerCase(); + return goal.related + .map(company) + .filter(Boolean) + .filter((item) => { + if (!normalizedQuery) return true; + return [item.name, item.industry, item.location].join(" ").toLowerCase().includes(normalizedQuery); + }); + } + + function render() { + if (state.auth.checked && state.auth.enabled && !state.auth.authenticated) { + $("#app").innerHTML = renderAuthScreen(); + bindAuthEvents(); + return; + } + if (state.bootLoading || state.bootError) { + $("#app").innerHTML = ` +
+ ${renderTopbar()} +
+

${state.bootLoading ? "正在连接销售工作台" : "销售工作台暂不可用"}

+

${escapeHtml(state.bootLoading ? "正在加载工作台数据。" : state.bootError)}

+ ${state.bootError ? `` : ""} +
+
+ `; + bindConnectionEvents(); + return; + } + const goal = activeGoal(); + const selected = visibleCompany(); + $("#app").innerHTML = ` +
+ ${renderTopbar()} +
+ ${renderSidebar(goal)} + ${renderWorkspace(goal, selected)} +
+ ${renderFeishuImportModal(selected)} +
+ `; + bindEvents(); + } + + function renderTopbar() { + const user = state.auth.user; + const displayName = user?.display_name || "本地用户"; + const avatar = String(displayName || "工").slice(0, 1).toUpperCase(); + return ` +
+
+ + 销售智能工作台 +
+
+ ${state.auth.authenticated && !state.bootLoading ? ` + + ` : ""} + ${escapeHtml(avatar)} + ${escapeHtml(displayName)} + ${state.auth.enabled ? `` : ""} +
+
+ `; + } + + function renderAuthScreen() { + const bootstrap = state.auth.bootstrapRequired; + const content = ` +
+

${bootstrap ? "设置本机管理员" : "登录工作台"}

+

${bootstrap ? "首次使用只需设置一个用户名和密码。" : "使用本机管理员账号继续。"}

+
+
+ + + ${state.authError ? `` : ""} + ${state.authNotice ? `

${escapeHtml(state.authNotice)}

` : ""} + +
+ `; + return ` +
+
+ + 销售智能工作台 +
+
+
+ ${content} +
+
+
+ `; + } + + function renderFeishuImportModal(item) { + if (!state.feishuImportOpen || !item?.id) return ""; + const task = state.feishuImportTask; + const active = ["queued", "running"].includes(task?.status); + const completed = task?.status === "succeeded"; + const draft = state.feishuImportDraft; + const conversation = state.feishuImportKind === "conversation"; + return ` +
+ +
+ `; + } + + function renderSidebar(goal) { + return ` + + `; + } + + function renderPageNotice() { + if (state.bootLoading) return `
正在加载销售资料...
`; + if (state.bootError) return `
${escapeHtml(state.bootError)}
`; + return ""; + } + + function renderSideLoading(text) { + return `
${escapeHtml(text)}
`; + } + + function renderSearchResults() { + if (state.busy === "search") return renderSideLoading("正在查找企业"); + if (!state.hasSearched) return `
输入关键词搜索后显示企业
`; + const items = relatedCompanies(); + return items.length + ? items.map(renderRelatedCompany).join("") + : `
没有找到匹配企业
`; + } + + function renderGoalItem(goal) { + const active = goal.id === state.activeGoalId; + return ` + + `; + } + + function renderTargetStatusFilters() { + return ` +
+ ${TARGET_STATUS_FILTERS.map((status) => ` + + `).join("")} +
+ `; + } + + function renderRelatedCompany(item) { + const goal = activeGoal(); + const inPool = goal.pool.includes(item.id); + const adding = state.busy === `add:${item.id}`; + return ` + + `; + } + + function renderTargetCompany(item) { + const selected = item.id === state.activeCompanyId; + return ` + + `; + } + + function renderWorkspace(goal, item) { + if (!item) { + return ` +
+
+

选择一个目标企业

+

先在左侧查找公司并加入目标企业池。

+ +
+
+ `; + } + + return ` +
+ ${renderCompanyHeader(goal, item)} + ${renderProgress(item)} + ${renderRecentDossier(item)} + ${renderSupportArea(item)} +
+ `; + } + + function renderCompanyHeader(goal, item) { + const job = dossierJobForCompany(item.id); + return ` +
+
+ +
+

${escapeHtml(item.name)}

+

目标企业 · ${escapeHtml(goal.name)}

+
+ ${(item.tags || [item.industry, item.location]).map((tag) => `${escapeHtml(tag)}`).join("")} +
+
+
+
+ ${renderDossierJobControl(job)} + ${state.notice ? escapeHtml(state.notice) : `更新于:${escapeHtml(item.updatedAt || "尚未更新")}`} +
+
+ `; + } + + function compactDossierStageLabel(job) { + const detailMessage = String(job?.stage_detail?.message || "").replace(/\s+/g, " ").trim(); + if (detailMessage) return detailMessage; + const labels = { + queued: "正在准备档案", + retry_wait: "正在等待自动重试", + starting: "正在准备档案", + collecting_evidence: "正在查找资料", + collecting_professional: "正在核验专业资料", + collecting_public: "正在检索公开资料", + building_evidence: "正在整理可信资料", + validating_evidence: "正在核验资料", + generating_dossier: "正在整理档案", + validating_dossier: "正在核验档案", + persisting_result: "正在保存结果", + cancelling: "正在取消", + }; + return labels[job?.stage] || "正在生成档案"; + } + + function renderDossierJobControl(job) { + if (!job || job.status === "succeeded") { + return ` + + `; + } + + const active = isActiveJob(job); + const retry = !active && job.retryable + ? `` + : ""; + const cancel = active && job.stage !== "cancelling" + ? `` + : ""; + + if (!active) { + return retry || ` + + `; + } + + return ` +
+ + ${cancel} +
+ `; + } + + function renderProgress(item) { + return ` +
+
+

当前进度

+ ${escapeHtml(salesStatus(item.status))} +
+

${escapeHtml(conciseProgressText(item))}

+
+ `; + } + + function renderRecentDossier(item) { + const updates = item.updates || []; + const selected = selectedDossier(updates); + return ` +
+
+

最近档案

+ ${updates.length ? ` +
+ ${updates.map((update, index) => ` + + `).join("")} +
+ ` : ""} +
+ ${selected ? renderDossierDetail(selected) : `
暂无最近档案。
`} +
+ `; + } + + function renderSupportArea(item) { + const libraryActive = state.supportView !== "qa"; + return ` +
+
+ + +
+
+ ${renderLibrary(item)} +
+
+ ${renderQa(item)} +
+
+ `; + } + + function selectedDossier(updates) { + if (!updates.length) return null; + return updates.find((update) => update.id === state.selectedDossierId) || updates[0]; + } + + function dossierSources(update) { + if (!update) return []; + if (update.citations?.length) return update.citations; + return []; + } + + function renderDossierDetail(update) { + if (!update) return ""; + const sources = dossierSources(update); + const rawParagraphs = update.bodyParagraphs?.length + ? update.bodyParagraphs + : update.body + ? [{ text: update.body, citationIds: [] }] + : []; + const { sources: orderedSources, paragraphs } = normalizeDossierDisplay(sources, rawParagraphs); + return ` +
+
+ 档案详情 · V${escapeHtml(update.versionNo || 1)} + + ${escapeHtml(update.date)} + +
+

${escapeHtml(update.title)}

+

资料截至 ${escapeHtml(formatTime(update.dataAsOf, "未知"))} · 生成于 ${escapeHtml(formatTime(update.generatedAt, update.date || "未知"))}

+
+ ${paragraphs.length + ? paragraphs.map(renderDossierParagraph).join("") + : `
${update.detailLoadError + ? "档案详情暂时无法加载,请稍后刷新页面重试。系统不会用摘要冒充正文。" + : "档案正文暂未加载,请稍后重新打开该企业。"}
`} +
+
+
+ 资料来源 + 正文中的编号对应下列来源 +
+ ${orderedSources.length + ? renderCitationGroups(orderedSources) + : `${update.detailLoadError ? "档案详情尚未加载,暂不能展示引用。" : "暂无可验证的引用来源。"}`} +
+
+ `; + } + + function renderDossierParagraph(paragraph) { + const raw = String(paragraph.text || ""); + const sectionMatch = raw.match(/^([^::\n]{1,24})[::]\s*([\s\S]*)$/); + const heading = normalizeChineseTypography(sectionMatch?.[1] || ""); + const content = sectionMatch?.[2] || raw; + const renderTextWithCitations = (text, citationIds) => { + const citations = (citationIds || []) + .map((id) => `[${escapeHtml(id)}]`) + .join(""); + const displayParagraphs = splitDisplayParagraphs(text); + return displayParagraphs.map((displayText, index) => { + const references = index === displayParagraphs.length - 1 && citations ? ` ${citations}` : ""; + return `

${escapeHtml(displayText)}${references}

`; + }).join(""); + }; + const paragraphHtml = paragraph.segments?.length + ? paragraph.segments + .map((segment) => renderTextWithCitations(segment.text, segment.citationIds)) + .join("") + : renderTextWithCitations(content, paragraph.citationIds); + if (DOSSIER_SECTION_TITLES.includes(heading)) { + return ` +
+

${escapeHtml(heading)}

+
+ ${paragraphHtml} +
+
+ `; + } + return paragraphHtml; + } + + function renderCitationGroups(sources) { + const groups = [ + { + kind: "professional", + title: "专业数据集(DataPro)", + items: sources.filter((source) => displaySourceKind(source.kind) === "专业数据集(DataPro)"), + }, + { + kind: "web", + title: "联网搜索", + items: sources.filter((source) => displaySourceKind(source.kind) === "联网搜索"), + }, + ].filter((group) => group.items.length); + return groups.map((group) => ` +
+
+ ${escapeHtml(group.title)} + ${group.items.length} 条 +
+
+ ${group.items.map((source) => renderCitation(source, group.kind)).join("")} +
+
+ `).join(""); + } + + function renderCitation(source, groupKind) { + const title = source.label || displaySourceKind(source.kind); + if (groupKind === "professional") { + const details = professionalSourceDetails(source); + return ` +
+ [${escapeHtml(source.id)}] +
+ ${escapeHtml(title)} + ${details.length + ? `
+ 查看数据明细 +
+ ${details.map((item) => ` +
+
${escapeHtml(item.label)}
+
${escapeHtml(item.value)}
+
+ `).join("")} +
+
` + : `当前记录没有可展示的字段明细`} +
+
+ `; + } + const siteName = sourceSiteName(source); + const publishLabel = sourcePublishLabel(source); + return ` +
+ [${escapeHtml(source.id)}] +
+ ${source.url && !isPlaceholderUrl(source.url) + ? `${escapeHtml(title)} ↗` + : `${escapeHtml(title)}`} + ${escapeHtml(siteName)} · ${escapeHtml(publishLabel)} +
+
+ `; + } + + function renderLibrary(item) { + const materialRows = materialRecords(item); + const dossierRows = historicalDossierRecords(item); + const allRecords = [...dossierRows, ...materialRows]; + const records = allRecords.filter((record) => { + if (state.materialFilter === "全部") return true; + if (state.materialFilter === "档案") return record.isDossier; + if (record.isDossier) return false; + return inferMaterialType(record.title, record.sourceType).includes(state.materialFilter); + }); + return ` +
+
+
+

历史资料 ${allRecords.length}

+
+
+
+ ${MATERIAL_FILTERS.map((type) => ` + + `).join("")} +
+
+ +
+
+
+ ${ + records.length + ? `
+
资料名称来源更新时间
+ ${records.map((record) => ` +
+ ${record.isDossier + ? `` + : `${escapeHtml(record.title)}`} + ${record.isDossier ? `档案 V${escapeHtml(record.versionNo)}` : escapeHtml(inferMaterialType(record.title, record.sourceType))} + ${escapeHtml(record.time)} +
+ `).join("")} +
` + : `
${state.materialFilter === "档案" ? "暂无历史档案。" : "暂无历史资料。"}
` + } +
+ `; + } + + function renderQa(item) { + const hasMaterials = materialRecords(item).length > 0; + const messages = qaMessagesForCompany(item); + const qaNote = hasMaterials + ? "仅根据当前企业档案和用户导入的飞书资料回答。" + : "当前企业暂无飞书资料;问答仅根据当前企业档案回答。"; + const qaPlaceholder = hasMaterials ? "询问历史沟通、当前进展或资料缺口" : "询问当前进展或资料缺口"; + return ` +
+
+

资料问答

+
+

${escapeHtml(qaNote)}

+
+ ${messages.length ? messages.map(renderMessage).join("") : `
暂无历史问答。
`} + ${state.busy === "qa" && state.qaPendingCompanyId === item.id + ? `
正在检索档案与飞书资料
` + : ""} +
+
+ + +
+
+ `; + } + + function renderMessage(message) { + const rawCitationEntries = message.citationEntries?.length + ? message.citationEntries + : (message.citations || []).map((label) => ({ id: "", label })); + const citationDisplay = dedupeCitationEntries(rawCitationEntries); + const citationEntries = citationDisplay.entries; + const citationNumbers = new Map(Object.entries(citationDisplay.citationNumbers)); + const paragraphs = collapseRepeatedCitationRuns(qaAnswerParagraphs(message)); + return ` +
+ ${message.role === "assistant" + ? `
${paragraphs.map((paragraph) => renderQaAnswerParagraph(paragraph, citationNumbers)).join("")}
` + : `

${escapeHtml(message.text)}

`} + ${citationEntries.length + ? `
${citationEntries.map((item, index) => `[${index + 1}]${escapeHtml(item.label)}`).join("")}
` + : ""} +
+ `; + } + + function qaAnswerParagraphs(message) { + const source = message.paragraphs?.length + ? message.paragraphs + : [{ text: message.text || "", citationIds: [] }]; + return source.flatMap((paragraph, citationGroup) => splitQaAnswerText(paragraph.text).map((text) => ({ + text, + citationIds: paragraph.citationIds || [], + citationGroup, + }))); + } + + function splitQaAnswerText(value) { + const normalized = normalizeChineseTypography(value); + if (!normalized) return []; + const afterSentence = new RegExp(`([。;!?])\\s*(?=(?:${QA_SECTION_HEADING_SOURCE})[::])`, "g"); + const afterWhitespace = new RegExp(`[ \\t\\n]+(?=(?:${QA_SECTION_HEADING_SOURCE})[::])`, "g"); + const structured = normalized + .replace(afterSentence, "$1\n\n") + .replace(afterWhitespace, "\n\n"); + return splitReadableBlocks(structured, 220); + } + + function renderQaAnswerParagraph(paragraph, citationNumbers) { + const match = paragraph.text.match(QA_SECTION_HEADING_PATTERN); + const heading = match?.[1] || ""; + const body = match?.[2] || paragraph.text; + const references = [...new Set( + (paragraph.displayCitationIds || []) + .map((id) => citationNumbers.get(String(id))) + .filter(Boolean), + )]; + return ` +
+ ${heading ? `

${escapeHtml(heading)}

` : ""} +

${escapeHtml(body).replace(/\n/g, "
")}${references.length ? `${references.map((number) => `[${number}]`).join("")}` : ""}

+
+ `; + } + + function scrollQaToBottom() { + queueMicrotask(() => { + const chatArea = $(".chat-area"); + if (chatArea) chatArea.scrollTop = chatArea.scrollHeight; + }); + } + + function bindConnectionEvents() { + $("#retryBoot")?.addEventListener("click", () => { + if (state.bootLoading) return; + boot(); + }); + } + + function bindAuthEvents() { + $("#authForm")?.addEventListener("submit", async (event) => { + event.preventDefault(); + if (state.authBusy) return; + const form = new FormData(event.currentTarget); + const bootstrap = state.auth.bootstrapRequired; + const body = { + username: String(form.get("username") || "").trim(), + password: String(form.get("password") || ""), + }; + state.authBusy = bootstrap ? "bootstrap" : "login"; + state.authError = ""; + state.authNotice = ""; + render(); + try { + const result = await api(bootstrap ? "/auth/bootstrap" : "/auth/login", { + method: "POST", + body, + skipAuthRedirect: true, + }); + state.auth.checked = true; + state.auth.enabled = true; + state.auth.authenticated = true; + state.auth.bootstrapRequired = false; + state.auth.user = result.user || null; + state.authBusy = ""; + await boot(); + } catch (error) { + state.authBusy = ""; + state.authError = apiErrorMessage(error, bootstrap ? "管理员设置失败,请重试。" : "登录失败,请检查用户名和密码。"); + render(); + } + }); + } + + function closeFeishuImport() { + state.feishuImportOpen = false; + state.feishuImportError = ""; + render(); + } + + async function monitorFeishuImport(initialTask, companyId) { + const token = ++feishuImportPollToken; + let task = initialTask; + try { + while (token === feishuImportPollToken && ["queued", "running"].includes(task?.status)) { + await wait(900); + if (token !== feishuImportPollToken) return; + task = await api(`/target-enterprises/${encodeURIComponent(companyId)}/materials/feishu-import/${encodeURIComponent(task.id)}`); + state.feishuImportTask = task; + render(); + } + if (token !== feishuImportPollToken || !task) return; + if (task.status === "succeeded") { + await hydrateCompany(companyId, { loadJob: false }); + state.notice = "飞书资料已导入"; + state.materialFilter = task.source_kind === "document" ? "云文档" : "飞书会话"; + } else { + state.feishuImportError = "飞书资料导入没有完成,请检查输入后重试。"; + } + } catch (error) { + if (token !== feishuImportPollToken) return; + state.feishuImportError = apiErrorMessage(error, "暂时无法获取飞书资料导入进度。"); + } + render(); + } + + function bindEvents() { + const setMobileNavigation = (open) => { + state.mobileNavigationOpen = Boolean(open); + render(); + }; + $("#mobileNavigationToggle")?.addEventListener("click", () => { + setMobileNavigation(!state.mobileNavigationOpen); + }); + $("#emptyOpenMobileNavigation")?.addEventListener("click", () => { + setMobileNavigation(true); + }); + $("#openFeishuImport")?.addEventListener("click", async () => { + const current = visibleCompany(); + if (!current?.id) return; + state.feishuImportOpen = true; + state.feishuImportAvailable = null; + state.feishuImportError = ""; + if (!["queued", "running"].includes(state.feishuImportTask?.status)) { + state.feishuImportTask = null; + } + render(); + try { + const status = await api("/feishu-import/status"); + state.feishuImportAvailable = Boolean(status.available); + } catch (error) { + state.feishuImportAvailable = false; + state.feishuImportError = apiErrorMessage(error, "暂时无法确认飞书资料导入状态。"); + } + render(); + }); + $("#closeFeishuImport")?.addEventListener("click", closeFeishuImport); + $("#cancelFeishuImport")?.addEventListener("click", closeFeishuImport); + $("#feishuImportBackdrop")?.addEventListener("click", (event) => { + if (event.target.id === "feishuImportBackdrop") closeFeishuImport(); + }); + $$("[data-feishu-kind]").forEach((button) => { + button.addEventListener("click", () => { + if (["queued", "running"].includes(state.feishuImportTask?.status)) return; + state.feishuImportKind = button.dataset.feishuKind; + state.feishuImportDraft = { target: "", start: "", end: "" }; + state.feishuImportTask = null; + state.feishuImportError = ""; + render(); + }); + }); + $("#feishuImportForm")?.addEventListener("submit", async (event) => { + event.preventDefault(); + const current = visibleCompany(); + if (!current?.id || ["queued", "running"].includes(state.feishuImportTask?.status)) return; + const form = new FormData(event.currentTarget); + state.feishuImportDraft = { + target: String(form.get("target") || "").trim(), + start: String(form.get("start") || ""), + end: String(form.get("end") || ""), + }; + state.feishuImportError = ""; + if (state.feishuImportKind === "conversation" + && /^ou_[A-Za-z0-9_-]+$/i.test(state.feishuImportDraft.target)) { + state.feishuImportError = "飞书会话请填写联系人姓名或 oc_ 开头的会话 ID,不支持 Open ID。"; + render(); + return; + } + if (state.feishuImportKind === "document" + && !/^https:\/\/\S+$/i.test(state.feishuImportDraft.target)) { + state.feishuImportError = "请粘贴完整的 https:// 飞书云文档链接。"; + render(); + return; + } + state.feishuImportTask = { + status: "queued", + summary: "正在创建导入任务。", + source_kind: state.feishuImportKind, + }; + render(); + try { + const task = await api(`/target-enterprises/${encodeURIComponent(current.id)}/materials/feishu-import`, { + method: "POST", + body: { + source_kind: state.feishuImportKind, + target: state.feishuImportDraft.target, + start: state.feishuImportDraft.start, + end: state.feishuImportDraft.end, + }, + }); + state.feishuImportTask = task; + render(); + monitorFeishuImport(task, current.id); + } catch (error) { + state.feishuImportTask = null; + state.feishuImportError = apiErrorMessage(error, "飞书资料导入任务创建失败。"); + render(); + } + }); + $("#logoutButton")?.addEventListener("click", async () => { + if (state.authBusy) return; + state.authBusy = "logout"; + try { + await api("/auth/logout", { method: "POST", skipAuthRedirect: true }); + } catch { + // Local session is cleared by the server whenever it can be reached. + } + resetConnectedState(); + state.auth = { + checked: true, + enabled: true, + authenticated: false, + bootstrapRequired: false, + user: null, + }; + state.authBusy = ""; + state.authError = ""; + state.authNotice = ""; + render(); + }); + $("#toggleNewGoal")?.addEventListener("click", () => { + if (state.busy) return; + state.showNewGoal = !state.showNewGoal; + render(); + }); + + $("#newGoalForm")?.addEventListener("submit", async (event) => { + event.preventDefault(); + if (state.busy) return; + const name = $("#newGoalInput").value.trim(); + if (!name) return; + state.busy = "createGoal"; + state.notice = ""; + state.sidebarNotice = ""; + render(); + try { + const created = await api("/sales-goals", { method: "POST", body: { name } }); + goals.unshift({ + id: created.id, + name: created.name, + stats: goalStats(0), + placeholder: goalPlaceholder(created), + related: [], + pool: [], + }); + state.activeGoalId = created.id; + state.activeCompanyId = ""; + state.showNewGoal = false; + state.notice = "已新增销售目标"; + } catch (error) { + state.showNewGoal = false; + state.sidebarNotice = apiErrorMessage(error, "暂时没能创建销售目标,请稍后再试。"); + } finally { + state.busy = ""; + } + render(); + }); + + $$("[data-goal]").forEach((button) => { + button.addEventListener("click", async () => { + if (state.busy) return; + state.activeGoalId = button.dataset.goal; + state.activeCompanyId = ""; + state.targetStatusFilter = "全部"; + state.materialFilter = "全部"; + state.query = ""; + state.hasSearched = false; + state.notice = ""; + state.sidebarNotice = ""; + state.busy = `goal:${state.activeGoalId}`; + render(); + try { + await loadGoalCompanies(state.activeGoalId); + await hydrateVisibleCompany(); + } catch { + state.sidebarNotice = "暂时没能加载这个销售目标,请稍后再试。"; + } finally { + state.busy = ""; + } + render(); + }); + }); + + $("#companySearch")?.addEventListener("submit", async (event) => { + event.preventDefault(); + if (state.busy) return; + state.query = $("#companyQuery").value.trim(); + state.hasSearched = Boolean(state.query); + if (!state.query) { + const goal = activeGoal(); + goal.related = []; + state.sidebarNotice = "请输入行业、区域或企业关键词后搜索。"; + render(); + return; + } + state.busy = "search"; + state.sidebarNotice = ""; + state.notice = ""; + render(); + try { + await loadGoalCompanies(state.activeGoalId, state.query); + state.sidebarNotice = state.query ? "已更新相关公司" : ""; + } catch { + state.sidebarNotice = "暂时没能查到相关公司,可以换个关键词再试。"; + } finally { + state.busy = ""; + } + render(); + }); + + $$("[data-add]").forEach((button) => { + button.addEventListener("click", async () => { + if (state.busy) return; + const goal = activeGoal(); + const id = button.dataset.add; + state.busy = `add:${id}`; + state.sidebarNotice = ""; + state.notice = ""; + render(); + try { + const detail = await api(`/sales-goals/${encodeURIComponent(goal.id)}/target-enterprises`, { method: "POST", body: { company_id: id } }); + const mapped = mapCompanyFromApi(detail); + if (mapped) companies[mapped.id] = { ...(companies[mapped.id] || {}), ...mapped }; + if (!goal.pool.includes(id)) goal.pool.push(id); + goal.stats = goalStats(goal.pool.length); + state.activeCompanyId = id; + state.mobileNavigationOpen = false; + await hydrateCompany(id); + state.notice = "已加入目标企业池"; + } catch { + state.sidebarNotice = "暂时没能加入目标企业池,请稍后再试。"; + } finally { + state.busy = ""; + } + render(); + }); + }); + + $$("[data-company]").forEach((button) => { + button.addEventListener("click", async () => { + if (state.busy) return; + state.activeCompanyId = button.dataset.company; + state.mobileNavigationOpen = false; + state.materialFilter = "全部"; + activateCompanyQa(state.activeCompanyId); + state.selectedDossierId = company(state.activeCompanyId)?.updates?.[0]?.id || ""; + state.notice = ""; + state.sidebarNotice = ""; + state.busy = `company:${state.activeCompanyId}`; + render(); + try { + await hydrateCompany(state.activeCompanyId); + } catch { + state.notice = "暂时没能刷新企业资料,已保留当前档案。"; + } finally { + state.busy = ""; + } + render(); + }); + }); + + $$('[data-dossier]').forEach((button) => { + button.addEventListener("click", () => { + if (state.busy) return; + state.selectedDossierId = button.dataset.dossier; + render(); + }); + }); + + $$("[data-status-filter]").forEach((button) => { + button.addEventListener("click", () => { + if (state.busy) return; + state.targetStatusFilter = button.dataset.statusFilter; + const pool = activePool(); + if (pool.length && !pool.some((item) => item.id === state.activeCompanyId)) { + state.activeCompanyId = pool[0].id; + state.selectedDossierId = company(state.activeCompanyId)?.updates?.[0]?.id || ""; + } + render(); + }); + }); + + $$("[data-material-filter]").forEach((button) => { + button.addEventListener("click", () => { + if (state.busy) return; + state.materialFilter = button.dataset.materialFilter; + render(); + }); + }); + + $$("[data-support-view]").forEach((button) => { + button.addEventListener("click", () => { + const nextView = button.dataset.supportView; + if (!["library", "qa"].includes(nextView) || state.supportView === nextView) return; + state.supportView = nextView; + render(); + if (nextView === "qa") scrollQaToBottom(); + }); + }); + + $("#refreshCompany")?.addEventListener("click", async () => { + if (state.busy) return; + const current = visibleCompany(); + if (isActiveJob(dossierJobForCompany(current?.id))) return; + state.busy = "refresh"; + state.notice = ""; + render(); + try { + if (current?.id) { + const created = await api(`/target-enterprises/${encodeURIComponent(current.id)}/dossiers`, { + method: "POST", + body: { idempotency_key: dossierRequestIdempotencyKey(current.id) }, + }); + clearDossierRequestIdempotencyKey(current.id); + if (created?.job_type === "sales_dossier_generation" && created?.id) { + rememberDossierJob(created, current.id); + state.notice = "任务已提交,可继续浏览其他企业。"; + state.busy = ""; + render(); + monitorDossierJob(created, current.id); + return; + } + state.selectedDossierId = created?.record?.id || created?.detail?.id || state.selectedDossierId; + await hydrateCompany(current.id); + const version = created?.record?.version_no || created?.detail?.version_no; + state.notice = created?.action === "no_material_change" + ? "证据未变化,保留当前版本" + : version ? `已生成档案 V${version}` : "已生成最新档案"; + } + } catch (error) { + state.notice = apiErrorMessage(error, "暂时没能获取最新档案,已保留当前档案。"); + } finally { + state.busy = ""; + render(); + } + }); + + $$('[data-cancel-dossier-job]').forEach((button) => { + button.addEventListener("click", async () => { + if (state.busy) return; + const current = visibleCompany(); + const jobId = button.dataset.cancelDossierJob; + if (!current?.id || !jobId) return; + state.busy = `cancel-job:${jobId}`; + render(); + try { + const job = await api(`/jobs/${encodeURIComponent(jobId)}/cancel`, { method: "POST" }); + rememberDossierJob(job, current.id); + if (isActiveJob(job)) { + state.notice = "正在等待当前步骤安全结束后取消"; + monitorDossierJob(job, current.id); + } else { + stopJobMonitor(jobId); + state.notice = "档案生成任务已取消"; + } + } catch (error) { + state.notice = apiErrorMessage(error, "暂时无法取消任务,请稍后重试。"); + } finally { + state.busy = ""; + render(); + } + }); + }); + + $$('[data-retry-dossier-job]').forEach((button) => { + button.addEventListener("click", async () => { + if (state.busy) return; + const current = visibleCompany(); + const jobId = button.dataset.retryDossierJob; + if (!current?.id || !jobId) return; + state.busy = `retry-job:${jobId}`; + state.notice = ""; + render(); + try { + const job = await api(`/jobs/${encodeURIComponent(jobId)}/retry`, { method: "POST" }); + rememberDossierJob(job, current.id); + state.notice = "任务已重新提交"; + state.busy = ""; + render(); + monitorDossierJob(job, current.id); + return; + } catch (error) { + state.notice = apiErrorMessage(error, "暂时无法重试任务,请稍后再试。"); + } finally { + state.busy = ""; + render(); + } + }); + }); + + $("#qaForm")?.addEventListener("submit", async (event) => { + event.preventDefault(); + if (state.busy) return; + const question = $("#qaQuestion").value.trim(); + if (!question) return; + const current = visibleCompany(); + if (!current?.id) return; + const pendingMessages = [ + ...qaMessagesForCompany(current), + { role: "user", text: question }, + ]; + rememberCompanyQa(current.id, pendingMessages); + state.busy = "qa"; + state.qaPendingCompanyId = current.id; + state.notice = ""; + render(); + scrollQaToBottom(); + try { + const result = await api(`/target-enterprises/${encodeURIComponent(current.id)}/qa`, { method: "POST", body: { question } }); + const resolvedMessages = (result.messages || []).map(mapQaMessage); + const includesSubmittedQuestion = resolvedMessages.some( + (message) => message.role === "user" && message.text === question, + ); + rememberCompanyQa( + current.id, + resolvedMessages.length + ? (includesSubmittedQuestion ? resolvedMessages : [...pendingMessages, ...resolvedMessages]) + : pendingMessages, + ); + } catch (error) { + state.notice = apiErrorMessage(error, "问答服务暂不可用,本次问题没有生成回答。"); + } finally { + state.busy = ""; + state.qaPendingCompanyId = ""; + } + render(); + scrollQaToBottom(); + }); + } + + async function boot() { + const generation = ++bootGeneration; + state.bootLoading = true; + state.bootError = ""; + render(); + let settled = false; + const loadTask = (async () => { + const authStatus = await api("/auth/status", { skipAuthRedirect: true }); + if (generation !== bootGeneration) return; + state.auth = { + checked: true, + enabled: Boolean(authStatus.enabled), + authenticated: Boolean(authStatus.authenticated), + bootstrapRequired: Boolean(authStatus.bootstrap_required), + user: authStatus.user || null, + }; + if (state.auth.enabled && !state.auth.authenticated) { + settled = true; + state.bootLoading = false; + state.bootError = ""; + render(); + return; + } + await loadSalesData(); + })() + .then(() => { + if (generation !== bootGeneration) return; + settled = true; + state.bootLoading = false; + state.bootError = ""; + render(); + }) + .catch((error) => { + if (generation !== bootGeneration) return; + settled = true; + state.bootLoading = false; + state.bootError = "工作台暂时无法加载,请确认服务正在运行后重试。"; + render(); + }); + await Promise.race([ + loadTask, + wait(6000).then(() => { + if (settled || generation !== bootGeneration) return; + state.bootLoading = false; + state.bootError = "工作台加载时间较长,请稍后重试。"; + render(); + }), + ]); + } + + boot(); +})(); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/frontend/index.html b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/frontend/index.html new file mode 100644 index 00000000..532d5d79 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + 销售智能工作台 + + + +
+ + + + diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/frontend/styles.css b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/frontend/styles.css new file mode 100644 index 00000000..a038db50 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/frontend/styles.css @@ -0,0 +1,2834 @@ +:root { + color-scheme: light; + --bg: #fbfcff; + --surface: #ffffff; + --surface-soft: #f7f8ff; + --surface-tint: #f2f0ff; + --line: #dfe4f2; + --line-soft: #edf0f7; + --ink: #111827; + --text: #4b5568; + --muted: #7b8497; + --blue: #2f53ff; + --blue-dark: #1e37c7; + --purple: #6d45f5; + --orange: #f17822; + --orange-soft: #fff3e7; + --green: #22a66b; + --shadow: 0 18px 42px rgba(31, 45, 93, 0.08); + --radius: 8px; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + "PingFang SC", "Microsoft YaHei", sans-serif; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + min-height: 100%; + margin: 0; +} + +body { + overflow-x: hidden; + color: var(--text); + background: var(--bg); +} + +.auth-shell { + min-height: 100vh; + display: grid; + grid-template-rows: 62px 1fr; + background: #f7f8fc; +} + +.auth-brand { + display: flex; + align-items: center; + gap: 12px; + padding: 0 20px; + border-bottom: 1px solid var(--line); + background: var(--surface); + color: var(--ink); +} + +.auth-brand strong { + font-size: 20px; +} + +.auth-main { + display: grid; + place-items: center; + padding: 32px 20px; +} + +.auth-panel { + width: min(420px, 100%); + padding: 30px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); +} + +.auth-heading h1 { + margin-bottom: 8px; + color: var(--ink); + font-size: 22px; + line-height: 1.35; +} + +.auth-heading p { + margin-bottom: 24px; + color: var(--muted); + font-size: 14px; + line-height: 1.6; +} + +.auth-form { + display: grid; + gap: 16px; +} + +.auth-form label { + display: grid; + gap: 7px; + color: var(--ink); + font-size: 13px; + font-weight: 700; +} + +.auth-form input { + width: 100%; + height: 42px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--ink); + background: var(--surface); + outline: none; +} + +.auth-form input:focus { + border-color: var(--blue); + box-shadow: 0 0 0 3px rgba(47, 83, 255, 0.1); +} + +.auth-error { + margin: 0; + padding: 10px 12px; + border: 1px solid #fed7aa; + border-radius: 6px; + color: #9a3412; + background: var(--orange-soft); + font-size: 13px; + line-height: 1.5; +} + +.auth-notice { + margin: 0; + padding: 10px 12px; + border: 1px solid #bbf7d0; + border-radius: 6px; + color: #166534; + background: #f0fdf4; + font-size: 13px; + line-height: 1.5; +} + +.auth-submit { + min-height: 42px; + border: 0; + border-radius: 6px; + color: #fff; + background: var(--blue); + font-weight: 800; +} + +.auth-submit:hover:not(:disabled) { + background: var(--blue-dark); +} + +.connection-state { + min-height: calc(100vh - 74px); + display: grid; + place-content: center; + gap: 10px; + padding: 32px; + text-align: center; + color: #1f2937; + background: #f7f8fc; +} + +.connection-state h1 { + margin: 0; + font-size: 22px; + line-height: 1.35; +} + +.connection-state p { + margin: 0; + color: #697386; +} + +.connection-retry { + width: max-content; + min-width: 112px; + margin: 8px auto 0; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: default; + opacity: 0.58; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +.sales-platform { + min-height: 100vh; + background: var(--bg); +} + +.sales-topbar { + height: 62px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + border-bottom: 1px solid var(--line); + background: rgba(255, 255, 255, 0.96); + position: sticky; + top: 0; + z-index: 10; +} + +.brand, +.topbar-right, +.company-title, +.side-heading, +.related-row, +.target-row, +.header-actions, +.chip-row, +.citation-row { + display: flex; + align-items: center; +} + +.brand { + gap: 12px; +} + +.brand-icon, +.company-logo, +.company-token, +.user-avatar, +.doc-icon { + flex: 0 0 auto; + display: grid; + place-items: center; + color: #fff; + background: linear-gradient(145deg, var(--blue), var(--purple)); + font-weight: 900; +} + +.brand-icon { + width: 32px; + height: 32px; + border-radius: 7px; +} + +.brand strong { + color: var(--ink); + font-size: 22px; + letter-spacing: 0; +} + +.topbar-right { + gap: 12px; + color: var(--ink); + font-size: 14px; +} + +.user-name { + display: grid; + gap: 1px; +} + +.user-name small { + color: var(--muted); + font-size: 11px; + font-weight: 500; +} + +.logout-button { + min-width: 48px; + height: 32px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + background: var(--surface); + font-size: 13px; +} + +.logout-button:hover { + border-color: var(--blue); + color: var(--blue); +} + +.dialog-backdrop { + position: fixed; + inset: 0; + z-index: 40; + display: grid; + place-items: center; + padding: 24px; + background: rgba(17, 24, 39, 0.38); +} + +.dialog-modal { + width: min(920px, 100%); + max-height: min(760px, calc(100vh - 48px)); + overflow: auto; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + box-shadow: 0 28px 72px rgba(17, 24, 39, 0.2); +} + +.dialog-modal-header { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 68px; + padding: 14px 18px; + border-bottom: 1px solid var(--line); + background: var(--surface); +} + +.dialog-modal-header h2, +.dialog-modal-header p { + margin: 0; +} + +.dialog-modal-header h2 { + color: var(--ink); + font-size: 18px; +} + +.dialog-modal-header p { + margin-top: 3px; + color: var(--muted); + font-size: 12px; +} + +.dialog-modal-close { + width: 34px; + height: 34px; + border: 0; + border-radius: 6px; + color: var(--text); + background: transparent; + font-size: 24px; + line-height: 1; +} + +.dialog-modal-close:hover { + background: var(--surface-soft); +} + +.feishu-import-modal { + width: min(580px, 100%); +} + +.feishu-import-form { + display: grid; + gap: 16px; + padding: 20px; +} + +.feishu-import-kind { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.feishu-import-kind button { + height: 38px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + background: var(--surface); + font-weight: 700; +} + +.feishu-import-kind button.is-active { + border-color: var(--blue); + color: var(--blue); + background: var(--surface-tint); +} + +.feishu-import-form label { + display: grid; + gap: 7px; + color: var(--ink); + font-size: 13px; + font-weight: 700; +} + +.feishu-import-form label small { + color: var(--muted); + font-size: 12px; + font-weight: 500; + line-height: 1.6; +} + +.feishu-import-form input { + width: 100%; + height: 42px; + padding: 0 11px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--ink); + background: var(--surface); +} + +.feishu-import-dates { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.feishu-import-status { + margin: 0; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + background: var(--surface-soft); + font-size: 13px; + line-height: 1.5; +} + +.feishu-import-status.is-success { + border-color: #bbf7d0; + color: #166534; + background: #f0fdf4; +} + +.feishu-import-status.is-error { + border-color: #fed7aa; + color: #9a3412; + background: var(--orange-soft); +} + +.feishu-import-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.feishu-import-actions button { + min-width: 92px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.logout-icon, +.mobile-navigation-toggle, +.mobile-navigation-empty-action { + display: none; +} + +.runtime-status { + min-height: 28px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 4px 9px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + background: var(--surface-soft); + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.runtime-status i { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--muted); +} + +.runtime-status.ready i { + background: var(--green); +} + +.runtime-status.warning i { + background: var(--orange); +} + +.icon-button { + width: 34px; + height: 34px; + border: 0; + border-radius: 50%; + color: var(--text); + background: transparent; + font-size: 18px; +} + +.user-avatar { + width: 30px; + height: 30px; + border-radius: 50%; +} + +.sales-layout { + min-height: calc(100vh - 62px); + display: grid; + grid-template-columns: 376px minmax(0, 1fr); +} + +.sales-sidebar { + min-width: 0; + padding: 18px 16px 24px; + border-right: 1px solid var(--line); + background: #fff; +} + +.side-section { + margin-bottom: 22px; +} + +.page-notice, +.side-tip, +.side-loading { + color: var(--text); + background: var(--surface-soft); + border: 1px solid var(--line-soft); +} + +.page-notice { + margin-bottom: 14px; + padding: 10px 12px; + border-radius: var(--radius); + font-size: 13px; + line-height: 1.5; +} + +.page-notice.is-error { + color: #9a3412; + background: var(--orange-soft); + border-color: #fed7aa; +} + +.side-tip { + margin: 10px 0 0; + padding: 8px 10px; + border-radius: 6px; + font-size: 12px; + line-height: 1.5; +} + +.side-loading { + min-height: 70px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--muted); + font-size: 13px; +} + +.side-loading span { + width: 14px; + height: 14px; + border: 2px solid var(--line); + border-top-color: var(--blue); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.side-heading { + justify-content: space-between; + gap: 12px; +} + +.side-section h2, +.side-heading h2 { + margin: 0 0 10px; + color: var(--ink); + font-size: 16px; + line-height: 1.35; +} + +.text-action, +.link-action { + border: 0; + color: var(--blue); + background: transparent; + font-size: 13px; + font-weight: 700; + white-space: nowrap; +} + +.filter-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 10px 0 12px; +} + +.filter-row button { + min-height: 28px; + padding: 4px 10px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + background: #fff; + font-size: 12px; + font-weight: 700; +} + +.filter-row button.is-active { + border-color: var(--blue); + color: #fff; + background: var(--blue); +} + +.goal-list, +.company-list, +.target-list { + border: 1px solid var(--line-soft); + border-radius: var(--radius); + overflow: hidden; + background: #fff; +} + +.goal-item { + width: 100%; + min-height: 62px; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: center; + padding: 11px 12px; + border: 0; + border-bottom: 1px solid var(--line-soft); + color: var(--text); + background: #fff; + text-align: left; +} + +.goal-item:last-child, +.related-row:last-child, +.target-row:last-child { + border-bottom: 0; +} + +.goal-item.is-active { + background: linear-gradient(135deg, rgba(47, 83, 255, 0.12), rgba(109, 69, 245, 0.06)); +} + +.goal-item strong, +.related-row strong, +.target-row strong, +.library-row strong { + display: block; + min-width: 0; + overflow: hidden; + color: var(--ink); + font-size: 14px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.goal-item em, +.related-row em, +.target-row em, +.header-actions em, +.source-cell em { + display: block; + margin-top: 3px; + color: var(--muted); + font-size: 12px; + font-style: normal; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #8a92a6; +} + +.goal-item.is-active .dot { + background: var(--blue); +} + +.new-goal, +.company-search { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; +} + +.new-goal { + margin-top: 10px; +} + +input, +textarea { + min-width: 0; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--ink); + background: #fff; + outline: 0; +} + +input { + height: 40px; +} + +textarea { + min-height: 128px; + padding-top: 12px; + line-height: 1.6; + resize: vertical; +} + +input:focus, +textarea:focus { + border-color: rgba(47, 83, 255, 0.68); + box-shadow: 0 0 0 3px rgba(47, 83, 255, 0.1); +} + +.company-search button, +.new-goal button, +.primary-button, +.secondary-button, +.material-import button, +.qa-input button { + min-height: 40px; + border: 0; + border-radius: 6px; + color: #fff; + background: linear-gradient(135deg, var(--blue), var(--purple)); + box-shadow: 0 12px 28px rgba(47, 83, 255, 0.2); + font-weight: 800; +} + +.company-search button, +.new-goal button { + padding: 0 16px; +} + +.related-row, +.target-row { + width: 100%; + min-height: 58px; + gap: 10px; + padding: 10px 12px; + border: 0; + border-bottom: 1px solid var(--line-soft); + background: #fff; + text-align: left; +} + +.related-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; +} + +.target-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; +} + +.target-row.is-selected { + background: var(--surface-soft); +} + +.company-token { + width: 28px; + height: 28px; + border-radius: 6px; + font-size: 13px; +} + +.status-pill, +.progress-status { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 4px 10px; + border-radius: 6px; + color: var(--orange); + background: var(--orange-soft); + font-size: 13px; + font-weight: 800; + white-space: nowrap; +} + +.mini-progress { + width: 100%; + height: 5px; + display: block; + margin-top: 8px; + overflow: hidden; + border-radius: 999px; + background: #eceff7; +} + +.mini-progress i { + height: 100%; + display: block; + border-radius: inherit; + background: linear-gradient(90deg, var(--blue), var(--green)); +} + +.empty { + min-height: 70px; + display: grid; + place-items: center; + color: var(--muted); + font-size: 13px; +} + +.empty.large { + min-height: 220px; + border: 1px dashed var(--line); + border-radius: var(--radius); + background: var(--surface-soft); +} + +.workspace, +.workspace-empty { + min-width: 0; + padding: 30px 42px 56px; +} + +.workspace-empty { + display: grid; + place-items: center; + color: var(--muted); + text-align: center; +} + +.empty-workspace { + display: grid; + grid-template-rows: auto minmax(280px, 1fr); + gap: 20px; +} + +.workspace-empty-message { + display: grid; + place-content: center; + color: var(--muted); + text-align: center; +} + +.provider-diagnostics, +.provider-diagnostics-empty { + margin-bottom: 20px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; +} + +.provider-diagnostics.has-issues { + border-color: #efd7a4; +} + +.provider-diagnostics-empty { + padding: 13px 15px; + color: var(--muted); + font-size: 13px; +} + +.provider-diagnostics summary { + min-height: 52px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 15px; + cursor: pointer; + list-style: none; +} + +.provider-diagnostics summary::-webkit-details-marker { + display: none; +} + +.provider-diagnostics summary > span:first-child { + min-width: 0; + display: grid; + gap: 2px; +} + +.provider-diagnostics summary strong { + color: var(--ink); + font-size: 14px; +} + +.provider-diagnostics summary em, +.provider-diagnostics summary > span:last-child { + color: var(--muted); + font-size: 11px; + font-style: normal; +} + +.provider-diagnostics.has-issues summary > span:last-child { + color: #8a5800; + font-weight: 800; +} + +.provider-diagnostic-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid var(--line-soft); +} + +.provider-diagnostic-row { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 7px 12px; + padding: 13px 15px; + border-right: 1px solid var(--line-soft); + border-bottom: 1px solid var(--line-soft); +} + +.provider-diagnostic-row:nth-child(2n) { + border-right: 0; +} + +.provider-diagnostic-row > div { + min-width: 0; + display: grid; + gap: 3px; +} + +.provider-diagnostic-row strong, +.provider-diagnostic-row span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.provider-diagnostic-row strong { + color: var(--ink); + font-size: 13px; +} + +.provider-diagnostic-row span, +.provider-diagnostic-row p, +.provider-diagnostic-row > em { + font-size: 11px; +} + +.provider-diagnostic-row span { + color: var(--muted); +} + +.provider-diagnostic-row > em { + align-self: start; + color: var(--text); + font-style: normal; + font-weight: 800; +} + +.provider-diagnostic-row.is-ready > em { + color: var(--green); +} + +.provider-diagnostic-row.is-warning > em { + color: #8a5800; +} + +.provider-diagnostic-row.is-error > em { + color: #b42318; +} + +.provider-diagnostic-row p { + grid-column: 1 / -1; + margin: 0; + color: var(--text); + line-height: 1.5; +} + +.company-header { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: flex-start; + margin-bottom: 24px; +} + +.company-title { + min-width: 0; + gap: 18px; +} + +.company-logo { + width: 64px; + height: 64px; + border-radius: var(--radius); + box-shadow: var(--shadow); + font-size: 34px; +} + +.company-title h1 { + margin: 0 0 7px; + color: var(--ink); + font-size: 30px; + line-height: 1.15; +} + +.company-title p { + margin-bottom: 8px; + color: var(--muted); + font-size: 14px; +} + +.chip-row { + flex-wrap: wrap; + gap: 8px; +} + +.chip-row span { + min-height: 26px; + display: inline-flex; + align-items: center; + padding: 4px 11px; + border-radius: 6px; + color: #5d6678; + background: #f0f2f7; + font-size: 13px; + font-weight: 700; +} + +.header-actions { + flex-direction: column; + align-items: flex-end; + gap: 8px; + white-space: nowrap; +} + +.header-actions em.is-notice { + color: var(--blue); + font-weight: 700; +} + +.dossier-job-control { + width: min(320px, 100%); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.dossier-job-running { + position: relative; + min-width: 180px; + overflow: hidden; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; +} + +.dossier-job-running:disabled { + opacity: 1; + cursor: default; +} + +.dossier-job-spinner { + width: 14px; + height: 14px; + flex: 0 0 auto; + border: 2px solid rgba(255, 255, 255, 0.45); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.dossier-job-flow { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 3px; + overflow: hidden; +} + +.dossier-job-flow::after { + position: absolute; + width: 42%; + height: 100%; + content: ""; + background: rgba(255, 255, 255, 0.82); + transform: translateX(-120%); + animation: dossier-job-flow 1.35s ease-in-out infinite; +} + +.dossier-job-retry { + white-space: nowrap; +} + +.job-inline-action { + min-height: 28px; + padding: 0 8px; + color: var(--blue); + background: transparent; + border: 1px solid var(--line); + border-radius: 5px; + font-size: 12px; + font-weight: 700; +} + +.job-inline-action:hover { + background: #f4f6fb; +} + +.primary-button { + padding: 0 20px; +} + +.secondary-button { + padding: 0 16px; + color: var(--blue); + background: #fff; + border: 1px solid var(--line); + box-shadow: none; +} + +.progress-card { + min-height: 102px; + display: grid; + grid-template-columns: 160px minmax(0, 1fr); + gap: 18px; + align-items: center; + margin-bottom: 26px; + padding: 24px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; +} + +.operation-strip { + margin-bottom: 16px; + border: 1px solid var(--line); + border-left: 3px solid var(--blue); + border-radius: var(--radius); + background: #fff; +} + +.operation-strip.is-success { + border-left-color: var(--green); +} + +.operation-strip.is-error { + border-left-color: #c2410c; +} + +.operation-strip.is-empty { + padding: 12px 14px; + border-left-color: var(--line); +} + +.operation-strip.is-empty div { + display: flex; + align-items: center; + gap: 12px; +} + +.operation-strip.is-empty strong, +.operation-strip.is-empty span { + font-size: 13px; +} + +.operation-strip.is-empty span { + color: var(--muted); +} + +.operation-strip summary { + min-height: 52px; + display: grid; + grid-template-columns: 8px minmax(0, 1fr) auto; + gap: 11px; + align-items: center; + padding: 9px 14px; + cursor: pointer; + list-style: none; +} + +.operation-strip summary::-webkit-details-marker, +.citation-item summary::-webkit-details-marker { + display: none; +} + +.operation-marker { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--blue); +} + +.operation-strip.is-success .operation-marker { + background: var(--green); +} + +.operation-strip.is-error .operation-marker { + background: #c2410c; +} + +.operation-main { + min-width: 0; + display: flex; + align-items: baseline; + gap: 9px; +} + +.operation-main strong { + color: var(--ink); + font-size: 14px; +} + +.operation-main em, +.operation-token { + color: var(--muted); + font-size: 12px; + font-style: normal; +} + +.operation-token { + color: var(--ink); + font-weight: 800; +} + +.operation-detail { + padding: 0 14px 14px 33px; + border-top: 1px solid var(--line-soft); +} + +.operation-detail > p { + margin: 10px 0 0; + font-size: 12px; +} + +.operation-detail > p span { + display: inline-block; + width: 42px; + color: var(--muted); +} + +.operation-detail code { + overflow-wrap: anywhere; + color: var(--text); +} + +.operation-detail .operation-error { + color: #9a3412; +} + +.operation-steps { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 8px; + margin-top: 12px; +} + +.operation-steps div { + min-width: 0; + display: grid; + gap: 3px; + padding: 9px 10px; + border: 1px solid var(--line-soft); + border-radius: 6px; + background: var(--surface-soft); +} + +.operation-steps strong, +.operation-steps span, +.operation-steps em { + overflow-wrap: anywhere; + font-size: 12px; + font-style: normal; +} + +.operation-steps strong { + color: var(--ink); +} + +.operation-steps span, +.operation-steps em { + color: var(--muted); +} + +.progress-card h2 { + margin: 0 0 10px; + color: var(--ink); + font-size: 16px; + line-height: 1.35; +} + +.progress-card p { + margin: 0; + color: var(--ink); + font-size: 15px; + line-height: 1.65; +} + +.recent-section { + margin-bottom: 16px; + padding: 22px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; +} + +.section-title, +.support-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.section-title h2, +.support-heading h2 { + margin: 0; + color: var(--ink); + font-size: 18px; + line-height: 1.35; +} + +.version-tabs { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.version-tabs button { + min-width: 38px; + height: 30px; + padding: 0 9px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + background: #fff; + font-size: 12px; + font-weight: 800; +} + +.version-tabs button.is-active { + border-color: var(--blue); + color: var(--blue); + background: var(--surface-tint); +} + +.support-heading h2 span { + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.support-heading .filter-row { + margin: 0; + justify-content: flex-end; +} + +.library-heading { + display: grid; + gap: 12px; + min-width: 0; +} + +.library-heading > * { + min-width: 0; +} + +.library-heading-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.library-control-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + max-width: 100%; + min-width: 0; +} + +.library-tools { + flex: 0 0 auto; +} + +.material-filter { + display: grid; + grid-template-columns: repeat(4, minmax(92px, 108px)); + width: auto; + min-width: 0; + overflow-x: auto; + overscroll-behavior-inline: contain; + scrollbar-width: thin; +} + +.material-filter button { + min-width: 92px; + min-height: 34px; + white-space: nowrap; +} + +.library-import-button { + flex: 0 0 auto; + min-height: 36px; +} + +.recent-section .dossier-detail { + position: static; + padding: 0; + border: 0; + box-shadow: none; +} + +.recent-section .dossier-detail h2 { + font-size: 20px; +} + +.support-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + align-items: start; +} + +.support-tabs { + display: flex; + align-items: flex-end; + gap: 28px; + min-width: 0; + border-bottom: 1px solid var(--line); +} + +.support-tabs button { + position: relative; + min-height: 46px; + padding: 0 4px 12px; + border: 0; + color: var(--text); + background: transparent; + font-size: 17px; + font-weight: 800; + white-space: nowrap; +} + +.support-tabs button::after { + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 3px; + border-radius: 3px 3px 0 0; + background: transparent; + content: ""; +} + +.support-tabs button.is-active { + color: var(--blue); +} + +.support-tabs button.is-active::after { + background: var(--blue); +} + +.support-tab-panel { + min-width: 0; +} + +.support-tab-panel[hidden] { + display: none; +} + +.content-tabs { + height: 48px; + display: flex; + align-items: flex-end; + gap: 26px; + border-bottom: 1px solid var(--line); +} + +.content-tabs button { + height: 48px; + padding: 0 10px; + border: 0; + border-bottom: 3px solid transparent; + color: var(--text); + background: transparent; + font-size: 16px; + font-weight: 700; +} + +.content-tabs button.is-active { + border-color: var(--blue); + color: var(--blue); +} + +.updates-panel { + padding-top: 22px; +} + +.library-panel, +.qa-panel { + min-width: 0; + padding: 20px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; +} + +.dossier-layout { + display: grid; + grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); + gap: 24px; + align-items: start; +} + +.dossier-history { + min-width: 0; + border: 1px solid var(--line); + border-radius: var(--radius); + border-top: 1px solid var(--line-soft); + overflow: hidden; + background: #fff; +} + +.update-row { + min-height: 72px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 12px 14px; + border-bottom: 1px solid var(--line-soft); +} + +.update-row.is-selected { + background: var(--surface-soft); + box-shadow: inset 3px 0 0 var(--blue); +} + +.doc-icon { + width: 46px; + height: 46px; + border-radius: 7px; + background: linear-gradient(145deg, #eef2ff, #fafbff); + color: var(--blue); + border: 1px solid var(--line); + box-shadow: none; +} + +.update-copy h3 { + margin: 0 0 5px; + color: var(--ink); + font-size: 14px; + line-height: 1.35; +} + +.update-copy p { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.source-cell { + display: grid; + gap: 5px; + justify-items: start; + color: var(--text); + font-size: 14px; +} + +.detail-button { + padding: 0 0 0 6px; + border: 0; + color: var(--blue); + background: transparent; + font-size: 13px; + font-weight: 800; + text-decoration: none; + white-space: nowrap; +} + +.dossier-detail { + min-width: 0; + padding: 24px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; + box-shadow: var(--shadow); + position: sticky; + top: 84px; +} + +.detail-head { + display: flex; + justify-content: space-between; + gap: 14px; + margin-bottom: 12px; + color: var(--muted); + font-size: 13px; + font-weight: 800; +} + +.detail-head em { + font-style: normal; +} + +.detail-meta { + display: flex; + align-items: center; + gap: 12px; +} + +.dossier-timing { + margin: -10px 0 18px; + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + +.dossier-detail h2 { + margin: 0 0 18px; + color: var(--ink); + font-size: 24px; + line-height: 1.35; +} + +.dossier-body { + display: grid; + gap: 12px; +} + +.dossier-report-section { + display: grid; + gap: 10px; + padding: 16px 0; + border-top: 1px solid var(--line); +} + +.dossier-report-section:first-child { + padding-top: 0; + border-top: 0; +} + +.dossier-report-section h3 { + margin: 0; + color: var(--ink); + font-size: 15px; + font-weight: 800; + line-height: 1.5; +} + +.dossier-report-content { + display: grid; + gap: 10px; +} + +.dossier-body p { + margin: 0; + color: var(--ink); + font-size: 15px; + line-height: 2; + text-wrap: pretty; +} + +.dossier-body sup { + margin-left: 2px; + color: var(--blue); + font-weight: 900; +} + +.inline-empty { + padding: 12px; + border: 1px dashed var(--line); + border-radius: 6px; + color: var(--muted); + background: var(--surface-soft); + font-size: 13px; + line-height: 1.6; +} + +.citation-block { + display: grid; + gap: 12px; + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--line-soft); +} + +.citation-block-head, +.citation-group-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.citation-block-head strong { + color: var(--ink); + font-size: 14px; +} + +.citation-block-head span, +.citation-group-head span { + color: var(--muted); + font-size: 12px; +} + +.citation-group { + overflow: hidden; + border: 1px solid var(--line-soft); + border-radius: 8px; + background: var(--surface-soft); +} + +.citation-group-head { + padding: 9px 11px; + border-bottom: 1px solid var(--line-soft); + background: var(--surface); +} + +.citation-group-head strong { + color: var(--text); + font-size: 13px; +} + +.citation-list { + display: grid; +} + +.citation-source-row { + display: grid; + grid-template-columns: 30px minmax(0, 1fr); + gap: 8px; + padding: 10px 11px; + border-bottom: 1px solid var(--line-soft); +} + +.citation-source-row:last-child { + border-bottom: 0; +} + +.citation-source-row > b { + color: var(--blue); + font-size: 12px; + line-height: 1.6; +} + +.citation-source-row > div { + display: grid; + gap: 4px; + min-width: 0; +} + +.citation-source-row strong, +.citation-source-row a { + overflow-wrap: anywhere; + color: var(--text); + font-size: 13px; + font-weight: 700; + line-height: 1.5; + text-decoration: none; +} + +.citation-source-row a { + color: var(--blue); +} + +.citation-source-row span { + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + +.professional-source-details { + margin-top: 2px; +} + +.professional-source-details summary { + width: max-content; + color: var(--blue); + font-size: 12px; + font-weight: 700; + line-height: 1.6; + cursor: pointer; +} + +.professional-source-details dl { + display: grid; + gap: 0; + margin: 8px 0 0; + overflow: hidden; + border: 1px solid var(--line-soft); + border-radius: 6px; + background: var(--surface); +} + +.professional-source-details dl > div { + display: grid; + grid-template-columns: minmax(96px, 0.28fr) minmax(0, 1fr); + gap: 10px; + padding: 7px 9px; + border-bottom: 1px solid var(--line-soft); +} + +.professional-source-details dl > div:last-child { + border-bottom: 0; +} + +.professional-source-details dt, +.professional-source-details dd { + margin: 0; + overflow-wrap: anywhere; + font-size: 12px; + line-height: 1.55; +} + +.professional-source-details dt { + color: var(--muted); +} + +.professional-source-details dd { + color: var(--text); +} + +.citation-plain { + padding: 8px 10px; + border: 1px solid var(--line-soft); + border-radius: 6px; + background: var(--surface-soft); + color: var(--text); + font-size: 13px; + line-height: 1.5; +} + +.more-button { + display: block; + margin: 26px auto 0; + border: 0; + color: var(--blue); + background: transparent; + font-weight: 800; +} + +.library-toolbar { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + margin-bottom: 14px; +} + +.library-toolbar p { + margin: 0; + color: var(--muted); + font-size: 14px; +} + +.material-import { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(220px, 320px) auto; + gap: 10px; + margin-bottom: 16px; + padding: 14px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; +} + +.material-import textarea { + grid-column: 1 / -1; +} + +.material-import button { + padding: 0 18px; +} + +.library-table { + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; + background: #fff; +} + +.library-head, +.library-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 96px 136px; + gap: 14px; + align-items: center; + min-height: 52px; + padding: 0 16px; + border-bottom: 1px solid var(--line-soft); +} + +.library-head { + color: var(--text); + background: var(--surface-soft); + font-size: 13px; + font-weight: 800; +} + +.library-row:last-child { + border-bottom: 0; +} + +.library-row span { + color: var(--text); + font-size: 14px; +} + +.library-dossier-link { + min-width: 0; + padding: 0; + overflow: hidden; + border: 0; + background: transparent; + color: var(--ink); + font: inherit; + font-weight: 800; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.library-dossier-link:hover { + color: var(--primary); + text-decoration: underline; + text-underline-offset: 3px; +} + +.qa-note { + margin: -4px 0 16px; + color: var(--text); + font-size: 13px; + line-height: 1.5; +} + +.chat-area { + min-height: 250px; + max-height: 360px; + display: grid; + align-content: start; + gap: 14px; + overflow: auto; +} + +.chat-message { + max-width: min(820px, 88%); + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; +} + +.chat-message.user { + justify-self: end; + max-width: 360px; + border-color: transparent; + background: var(--surface-tint); +} + +.chat-message p { + margin: 0; + color: var(--ink); + font-size: 14px; + line-height: 1.85; + text-wrap: pretty; +} + +.chat-message.user > p { + white-space: pre-wrap; +} + +.qa-answer-body { + display: grid; + gap: 14px; +} + +.qa-answer-paragraph { + display: grid; + gap: 7px; +} + +.qa-answer-paragraph + .qa-answer-paragraph { + padding-top: 11px; + border-top: 1px solid var(--line); +} + +.qa-answer-paragraph h3 { + margin: 0; + color: var(--ink); + font-size: 14px; + line-height: 1.5; +} + +.chat-message.is-pending { + display: inline-flex; + align-items: center; + width: fit-content; + color: var(--text); + background: var(--surface-soft); +} + +.chat-message.is-pending span { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 13px; + line-height: 1.5; +} + +.chat-message.is-pending span::before { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--primary); + box-shadow: 0 0 0 0 rgba(79, 70, 229, 0.28); + content: ""; + animation: qa-pending-pulse 1.4s ease-out infinite; +} + +@keyframes qa-pending-pulse { + 70% { + box-shadow: 0 0 0 7px rgba(79, 70, 229, 0); + } + + 100% { + box-shadow: 0 0 0 0 rgba(79, 70, 229, 0); + } +} + +.qa-citation-anchor { + white-space: nowrap; +} + +.qa-answer-refs { + display: inline-flex; + gap: 3px; + margin-left: 4px; + vertical-align: super; + line-height: 1; +} + +.qa-answer-refs span { + color: var(--blue); + font-size: 11px; + font-weight: 800; +} + +.citation-row { + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.citation-row span { + min-height: 26px; + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 6px; + color: var(--blue); + background: var(--surface-tint); + font-size: 13px; + font-weight: 800; +} + +.citation-row span b { + margin-right: 5px; +} + +.qa-input { + display: grid; + grid-template-columns: minmax(0, 1fr) 86px; + gap: 10px; + margin-top: 16px; +} + +.qa-input input { + height: 48px; +} + +.qa-input button { + height: 48px; +} + +.management-section { + display: grid; + gap: 16px; + padding-top: 24px; + border-top: 1px solid var(--line); +} + +.operator-status { + display: grid; + gap: 10px; + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); +} + +.operator-status-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.operator-status-heading h2 { + margin: 0; + color: var(--ink); + font-size: 15px; +} + +.operator-status-heading > span { + color: var(--muted); + font-size: 11px; + font-weight: 800; +} + +.operator-status-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.operator-status-grid article { + min-width: 0; + display: grid; + gap: 4px; + padding: 10px 12px; + border-left: 3px solid var(--line); + background: var(--surface-soft); +} + +.operator-status-grid article.is-ready { + border-left-color: var(--green); +} + +.operator-status-grid article.is-warning { + border-left-color: #c48300; +} + +.operator-status-grid span, +.operator-status-grid em { + overflow: hidden; + color: var(--muted); + font-size: 11px; + font-style: normal; + text-overflow: ellipsis; + white-space: nowrap; +} + +.operator-status-grid strong { + overflow: hidden; + color: var(--ink); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.operator-status-error { + padding: 10px 12px; + border-left: 3px solid #d14343; + color: #9f2d2d; + background: #fff5f5; + font-size: 12px; +} + +.management-heading, +.management-panel-title, +.sync-source-actions, +.job-row summary, +.job-row summary > span { + display: flex; + align-items: center; +} + +.management-heading { + justify-content: space-between; + gap: 18px; +} + +.management-heading h2, +.management-panel-title h3 { + margin: 0; + color: var(--ink); +} + +.management-heading h2 { + font-size: 18px; +} + +.management-heading p { + margin: 5px 0 0; + color: var(--muted); + font-size: 13px; +} + +.management-error { + padding: 10px 12px; + border-left: 3px solid #d14343; + color: #9f2d2d; + background: #fff5f5; + font-size: 13px; + line-height: 1.5; +} + +.management-grid { + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr); + gap: 16px; + align-items: start; +} + +.sync-source-panel, +.job-panel { + min-width: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); +} + +.management-panel-title { + min-height: 52px; + justify-content: space-between; + padding: 0 16px; + border-bottom: 1px solid var(--line-soft); +} + +.management-panel-title h3 { + font-size: 15px; +} + +.management-panel-title span { + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.management-empty { + min-height: 112px; + display: grid; + place-content: center; + padding: 20px; + color: var(--muted); + font-size: 13px; + text-align: center; +} + +.sync-source-row { + min-height: 96px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: center; + padding: 14px 16px; + border-bottom: 1px solid var(--line-soft); +} + +.sync-source-row:last-child, +.job-row:last-child { + border-bottom: 0; +} + +.sync-source-row.is-error { + box-shadow: inset 3px 0 0 #d14343; +} + +.sync-source-main { + min-width: 0; + display: grid; + gap: 4px; +} + +.sync-source-main strong, +.sync-source-main span, +.sync-source-main em { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sync-source-main strong { + color: var(--ink); + font-size: 14px; +} + +.sync-source-main span, +.sync-source-main em, +.sync-source-main p { + font-size: 12px; +} + +.sync-source-main em { + color: var(--muted); + font-style: normal; +} + +.sync-source-main p { + margin: 2px 0 0; + color: #9f2d2d; +} + +.sync-source-actions { + justify-content: flex-end; + flex-wrap: wrap; + gap: 6px; +} + +.sync-source-actions button { + min-width: 52px; + height: 30px; + padding: 0 9px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + background: #fff; + font-size: 12px; + font-weight: 700; +} + +.sync-source-actions button:hover { + border-color: var(--blue); + color: var(--blue); +} + +.sync-source-actions .danger-action { + color: #b42318; +} + +.sync-source-actions .danger-action:hover { + border-color: #d14343; + color: #9f2d2d; +} + +.source-status { + min-height: 26px; + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 6px; + color: var(--text); + background: var(--surface-soft); + font-size: 11px; + font-weight: 800; +} + +.source-status.is-active { + color: #117a4f; + background: #eaf8f1; +} + +.source-status.is-paused { + color: #8a5800; + background: #fff5db; +} + +.source-status.is-error { + color: #9f2d2d; + background: #fff0f0; +} + +.job-row { + border-bottom: 1px solid var(--line-soft); +} + +.job-row summary { + min-height: 64px; + justify-content: space-between; + gap: 12px; + padding: 10px 16px; + cursor: pointer; + list-style: none; +} + +.job-row summary::-webkit-details-marker { + display: none; +} + +.job-row summary > span:first-child { + min-width: 0; + display: grid; + gap: 3px; +} + +.job-row summary strong { + overflow: hidden; + color: var(--ink); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.job-row summary em { + color: var(--muted); + font-size: 11px; + font-style: normal; +} + +.job-status { + flex: 0 0 auto; + font-size: 12px; + font-weight: 800; +} + +.job-row.is-success .job-status { + color: var(--green); +} + +.job-row.is-error .job-status, +.job-error { + color: #b42318; +} + +.job-row > div { + display: grid; + gap: 7px; + padding: 0 16px 14px; +} + +.job-row > div p { + min-width: 0; + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 8px; + margin: 0; + color: var(--muted); + font-size: 12px; +} + +.job-row code { + overflow-wrap: anywhere; + color: var(--text); +} + +.job-row > div .job-actions { + display: flex; + justify-content: flex-end; + gap: 6px; + padding-top: 3px; +} + +.job-actions button { + min-width: 52px; + height: 30px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + background: #fff; + font-size: 12px; + font-weight: 700; +} + +.job-actions button:hover { + border-color: var(--blue); + color: var(--blue); +} + +.job-actions .danger-action { + color: #b42318; +} + +.job-actions .danger-action:hover { + border-color: #d14343; + color: #9f2d2d; +} + +@media (max-width: 1180px) { + .sales-layout { + grid-template-columns: 330px minmax(0, 1fr); + } + + .workspace, + .workspace-empty { + padding: 26px 26px 48px; + } + + .progress-card { + grid-template-columns: 1fr; + gap: 10px; + } + + .support-grid { + grid-template-columns: 1fr; + } + + .management-grid { + grid-template-columns: 1fr; + } + + .operator-status-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .provider-diagnostic-grid { + grid-template-columns: 1fr; + } + + .provider-diagnostic-row, + .provider-diagnostic-row:nth-child(2n) { + border-right: 0; + } + + .update-row { + grid-template-columns: 52px minmax(0, 1fr); + } + + .dossier-layout { + grid-template-columns: 1fr; + } + + .dossier-detail { + position: static; + } + + .source-cell { + grid-column: 2; + grid-template-columns: repeat(3, auto); + gap: 14px; + } +} + +@media (max-width: 780px) { + .sales-topbar { + padding: 0 14px; + } + + .brand { + gap: 8px; + } + + .brand strong { + font-size: 18px; + } + + .topbar-right { + gap: 6px; + } + + .user-name { + display: none; + } + + .auth-panel { + padding: 24px 20px; + } + + .dialog-backdrop { + align-items: end; + padding: 0; + } + + .dialog-modal { + width: 100%; + max-height: 88vh; + border-right: 0; + border-bottom: 0; + border-left: 0; + border-radius: 8px 8px 0 0; + } + + .feishu-import-dates { + grid-template-columns: 1fr; + } + + .feishu-import-actions button { + flex: 1 1 0; + } + + .runtime-status { + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + } + + .operator-status-grid { + grid-template-columns: 1fr; + } + + .management-heading { + align-items: flex-start; + } + + .sync-source-row { + grid-template-columns: 1fr; + } + + .sync-source-actions { + justify-content: flex-start; + } + + .sales-layout { + grid-template-columns: 1fr; + } + + .sales-sidebar { + display: none; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .sales-layout.is-mobile-navigation-open .sales-sidebar { + display: block; + } + + .sales-layout.is-mobile-navigation-open .workspace { + display: none; + } + + .mobile-navigation-toggle { + display: grid; + width: 34px; + height: 34px; + flex: 0 0 auto; + place-items: center; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--ink); + background: var(--surface); + font-size: 20px; + line-height: 1; + } + + .mobile-navigation-empty-action { + display: block; + width: max-content; + margin: 14px auto 0; + } + + .logout-button { + width: 34px; + min-width: 34px; + padding: 0; + } + + .logout-label { + display: none; + } + + .logout-icon { + display: inline; + font-size: 18px; + } + + .workspace, + .workspace-empty { + padding: 22px 16px 40px; + } + + .company-header { + flex-direction: column; + } + + .operation-strip summary { + grid-template-columns: 8px minmax(0, 1fr); + } + + .operation-main { + align-items: flex-start; + flex-direction: column; + gap: 2px; + } + + .operation-token { + grid-column: 2; + } + + .operation-detail { + padding-left: 33px; + } + + .section-title { + align-items: flex-start; + flex-direction: column; + } + + .version-tabs { + justify-content: flex-start; + } + + .header-actions { + align-items: stretch; + width: 100%; + } + + .dossier-job-control { + width: 100%; + } + + .dossier-job-running, + .dossier-job-retry { + flex: 1; + } + + .company-title h1 { + font-size: 25px; + } + + .company-logo { + width: 54px; + height: 54px; + font-size: 28px; + } + + .citation-block-head { + align-items: flex-start; + flex-direction: column; + gap: 3px; + } + + .professional-source-details dl > div { + grid-template-columns: 1fr; + gap: 2px; + } + + .update-row, + .dossier-layout, + .support-heading, + .library-head, + .library-row, + .qa-input { + grid-template-columns: 1fr; + } + + .support-heading { + align-items: flex-start; + flex-direction: column; + } + + .support-heading .filter-row { + justify-content: flex-start; + } + + .library-heading { + width: 100%; + } + + .library-heading-row { + width: 100%; + align-items: center; + flex-direction: row; + } + + .library-control-row { + width: 100%; + align-items: stretch; + flex-direction: column; + gap: 12px; + } + + .material-filter { + grid-template-columns: repeat(4, 76px); + width: 100%; + max-width: 100%; + } + + .material-filter button { + min-width: 76px; + } + + .library-tools { + display: flex; + justify-content: flex-end; + order: -1; + } + + .library-heading .library-import-button { + width: auto; + } + + .doc-icon { + display: none; + } + + .source-cell { + grid-column: auto; + grid-template-columns: 1fr; + gap: 5px; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes dossier-job-flow { + to { + transform: translateX(340%); + } +} + +@media (prefers-reduced-motion: reduce) { + .dossier-job-spinner, + .dossier-job-flow::after { + animation-duration: 2.4s; + } +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/frontend/text-format.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/frontend/text-format.js new file mode 100644 index 00000000..c81575ee --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/frontend/text-format.js @@ -0,0 +1,189 @@ +(function (root) { + const HAN_CHARACTER = /[\u3400-\u9fff]/; + const HAN_NUMERAL_LIST = /^[一二三四五六七八九十]+、\s*\S/; + const HAN_ORDINAL_LIST = /^(?:第[一二三四五六七八九十]+[,、]|[一二三四五六七八九十]+是)\s*\S/; + + function isHanCharacter(value) { + return HAN_CHARACTER.test(String(value || "")); + } + + function normalizeChineseTypography(value) { + const source = String(value ?? "").replace(/\r/g, ""); + const characters = Array.from(source); + const normalized = characters.map((character, index) => { + const previous = characters[index - 1] || ""; + const next = characters[index + 1] || ""; + const touchesChinese = isHanCharacter(previous) || isHanCharacter(next); + if (!touchesChinese) return character; + if (character === ",") return ","; + if (character === "." && /\d/.test(previous) && !/\d/.test(next)) return "."; + if (character === ".") return "。"; + if (character === ";") return ";"; + if (character === "!") return "!"; + if (character === "?") return "?"; + if (character === ":") return ":"; + return character; + }).join(""); + + return normalized + .split("\n") + .map((line) => line.replace(/[ \t]+/g, " ").trim()) + .join("\n") + .replace(/(^|\n)((?:\d[ \t]*\n+)+)(?=\d)/gm, (_match, prefix, fragments) => ( + `${prefix}${fragments.replace(/\s+/g, "")}` + )) + .replace(/([^。!?;:\n])\n{2,}(?=\d)/g, "$1") + .replace(/[ \t]*([,。;!?:、])[ \t]*/g, "$1") + .replace(/([\u3400-\u9fff])([A-Za-z])/g, "$1 $2") + .replace(/([A-Za-z])([\u3400-\u9fff])/g, "$1 $2") + .replace(/[ \t]{2,}/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim(); + } + + function isNumberedListLine(value) { + const line = String(value || "").trim(); + if (!line) return false; + if (HAN_NUMERAL_LIST.test(line)) return true; + if (HAN_ORDINAL_LIST.test(line)) return true; + if (/^\d{1,2}[))、]\s*\S/.test(line)) return true; + if (/^\d{1,2}\.\s+\S/.test(line)) return true; + return /^\d{1,2}\.(?!\d)\S/.test(line); + } + + function structureInlineLists(value) { + return String(value || "") + .replace(/([。!?;:])\s*(?=第[一二三四五六七八九十]+[,、]\s*\S)/g, "$1\n\n") + .replace(/[ \t]+(?=第[一二三四五六七八九十]+[,、]\s*\S)/g, "\n\n") + .replace(/([。!?;:])\s*(?=[一二三四五六七八九十]+是\s*\S)/g, "$1\n\n") + .replace(/[ \t]+(?=[一二三四五六七八九十]+是\s*\S)/g, "\n\n") + .replace(/([。!?;:])\s*(?=\d{1,2}[))、]\s*\S)/g, "$1\n\n") + .replace(/[ \t]+(?=\d{1,2}[))、]\s*\S)/g, "\n\n") + .replace(/([。!?;:])\s*(?=\d{1,2}\.(?!\d)\s*\S)/g, "$1\n\n") + .replace(/[ \t]+(?=\d{1,2}\.(?!\d)\s*\S)/g, "\n\n"); + } + + function joinSoftWrappedLine(current, next) { + if (!current) return next; + if (!next) return current; + const needsSpace = /[A-Za-z]$/.test(current) && /^[A-Za-z]/.test(next); + return `${current}${needsSpace ? " " : ""}${next}`; + } + + function unwrapBlock(value) { + const lines = String(value || "") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + if (lines.length < 2) return lines; + + const segments = []; + let buffer = ""; + const flush = () => { + if (!buffer) return; + segments.push(buffer); + buffer = ""; + }; + + lines.forEach((line) => { + if (isNumberedListLine(line)) { + flush(); + segments.push(line); + return; + } + buffer = joinSoftWrappedLine(buffer, line); + }); + flush(); + return segments; + } + + function groupSentences(value, maxLength) { + const text = String(value || "").trim(); + if (!text || isNumberedListLine(text)) return text ? [text] : []; + const sentences = text.match(/[^。!?;]+[。!?;]?/g) + ?.map((item) => item.trim()) + .filter(Boolean) || []; + if (sentences.length < 2) return [text]; + + const paragraphs = []; + let buffer = ""; + sentences.forEach((sentence) => { + if (buffer && buffer.length + sentence.length > maxLength) { + paragraphs.push(buffer); + buffer = sentence; + return; + } + buffer += sentence; + }); + if (buffer) paragraphs.push(buffer); + return paragraphs; + } + + function splitReadableBlocks(value, maxLength = 180) { + const normalized = structureInlineLists(normalizeChineseTypography(value)); + if (!normalized) return []; + return normalized + .split(/\n{2,}/) + .flatMap(unwrapBlock) + .flatMap((item) => groupSentences(item, maxLength)) + .map((item) => item.trim()) + .filter(Boolean); + } + + function normalizedCitationIds(value) { + return [...new Set((value || []).map((item) => String(item || "").trim()).filter(Boolean))]; + } + + function citationSetKey(value) { + return normalizedCitationIds(value).sort().join("\u001f"); + } + + function collapseRepeatedCitationRuns(paragraphs) { + const items = (paragraphs || []).map((paragraph) => ({ + ...paragraph, + citationIds: normalizedCitationIds(paragraph.citationIds), + })); + return items.map((paragraph, index) => { + const currentKey = citationSetKey(paragraph.citationIds); + const nextKey = citationSetKey(items[index + 1]?.citationIds); + const currentGroup = String(paragraph.citationGroup ?? "default"); + const nextGroup = String(items[index + 1]?.citationGroup ?? "default"); + return { + ...paragraph, + displayCitationIds: currentKey && currentKey === nextKey && currentGroup === nextGroup + ? [] + : paragraph.citationIds, + }; + }); + } + + function dedupeCitationEntries(entries) { + const uniqueEntries = []; + const numberByKey = new Map(); + const citationNumbers = {}; + + (entries || []).forEach((entry) => { + const id = String(entry?.id || "").trim(); + const label = String(entry?.label || "").trim(); + if (!label) return; + const key = label.toLocaleLowerCase(); + let number = numberByKey.get(key); + if (!number) { + number = uniqueEntries.length + 1; + numberByKey.set(key, number); + uniqueEntries.push({ ...entry, id, label }); + } + if (id) citationNumbers[id] = number; + }); + + return { entries: uniqueEntries, citationNumbers }; + } + + root.SalesTextFormat = Object.freeze({ + collapseRepeatedCitationRuns, + dedupeCitationEntries, + normalizeChineseTypography, + splitReadableBlocks, + structureInlineLists, + }); +})(typeof window === "undefined" ? globalThis : window); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/functions/sales-cli-health-b1/index.ts b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/functions/sales-cli-health-b1/index.ts new file mode 100644 index 00000000..d14766c1 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/functions/sales-cli-health-b1/index.ts @@ -0,0 +1,17 @@ +Deno.serve((req) => { + const url = new URL(req.url); + const body = { + ok: true, + service: "sales-cli-health-b1", + scenario: "supabase-new-cli-sales-workbench-test", + method: req.method, + path: url.pathname, + checkedAt: new Date().toISOString(), + }; + + return new Response(JSON.stringify(body), { + headers: { + "content-type": "application/json; charset=utf-8", + }, + }); +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210001_stage2_core.sql b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210001_stage2_core.sql new file mode 100644 index 00000000..d2601ff1 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210001_stage2_core.sql @@ -0,0 +1,395 @@ +begin; + +create table if not exists public.schema_migrations ( + version text primary key, + description text not null, + applied_at timestamptz not null default now() +); + +create or replace function public.set_updated_at() +returns trigger +language plpgsql +set search_path = pg_catalog, public +as $$ +begin + new.updated_at = now(); + return new; +end; +$$; + +create table if not exists public.app_workspaces ( + id uuid primary key default gen_random_uuid(), + slug text not null unique, + name text not null, + plan_mode text not null default 'standard' check (plan_mode in ('standard', 'agent_plan')), + created_by uuid references auth.users(id) on delete set null, + settings_json jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.app_users ( + id uuid primary key references auth.users(id) on delete cascade, + display_name text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.app_workspace_members ( + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + role text not null default 'member' check (role in ('owner', 'admin', 'member', 'viewer')), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (workspace_id, user_id) +); + +create table if not exists public.provider_connections ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + provider text not null, + status text not null default 'configured', + secret_ref text, + config_json jsonb not null default '{}'::jsonb, + last_checked_at timestamptz, + created_by uuid references auth.users(id) on delete set null, + updated_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (workspace_id, provider) +); + +create table if not exists public.sales_goals ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + name text not null, + description text, + keywords jsonb not null default '[]'::jsonb, + created_by uuid references auth.users(id) on delete set null, + updated_by uuid references auth.users(id) on delete set null, + deleted_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id) +); + +create table if not exists public.sales_companies ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + name text not null, + normalized_name text generated always as (lower(btrim(name))) stored, + initial text, + industry text, + location text, + tags jsonb not null default '[]'::jsonb, + created_by uuid references auth.users(id) on delete set null, + updated_by uuid references auth.users(id) on delete set null, + deleted_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id), + unique (workspace_id, normalized_name) +); + +create table if not exists public.sales_target_enterprises ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + goal_id text not null, + company_id text not null, + status text not null default 'new', + created_by uuid references auth.users(id) on delete set null, + updated_by uuid references auth.users(id) on delete set null, + deleted_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id), + unique (workspace_id, goal_id, company_id), + foreign key (workspace_id, goal_id) references public.sales_goals(workspace_id, id) on delete cascade, + foreign key (workspace_id, company_id) references public.sales_companies(workspace_id, id) on delete cascade +); + +create table if not exists public.sales_company_search_results ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + goal_id text not null, + company_id text, + query text not null, + reason text, + created_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id), + foreign key (workspace_id, goal_id) references public.sales_goals(workspace_id, id) on delete cascade, + foreign key (workspace_id, company_id) references public.sales_companies(workspace_id, id) on delete cascade +); + +create table if not exists public.sales_progress_snapshots ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + company_id text not null, + label text, + summary text, + evidence text, + created_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id), + foreign key (workspace_id, company_id) references public.sales_companies(workspace_id, id) on delete cascade +); + +create table if not exists public.sales_dossier_records ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + company_id text not null, + title text, + summary text, + memory_summary text, + status text not null default 'completed', + provider_run_id text, + created_by uuid references auth.users(id) on delete set null, + updated_by uuid references auth.users(id) on delete set null, + deleted_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id), + foreign key (workspace_id, company_id) references public.sales_companies(workspace_id, id) on delete cascade +); + +create table if not exists public.sales_dossier_citations ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + dossier_id text not null, + citation_no text not null, + label text, + source_kind text, + url text, + created_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id), + unique (workspace_id, dossier_id, citation_no), + foreign key (workspace_id, dossier_id) references public.sales_dossier_records(workspace_id, id) on delete cascade +); + +create table if not exists public.sales_materials ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + company_id text not null, + title text not null, + source_type text, + source_url text, + content_hash text, + summary text, + occurred_at timestamptz, + openviking_uri text, + openviking_status text, + created_by uuid references auth.users(id) on delete set null, + updated_by uuid references auth.users(id) on delete set null, + deleted_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id), + foreign key (workspace_id, company_id) references public.sales_companies(workspace_id, id) on delete cascade +); + +create unique index if not exists sales_materials_content_unique + on public.sales_materials(workspace_id, company_id, content_hash) + where content_hash is not null and deleted_at is null; + +create table if not exists public.sales_qa_messages ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + company_id text not null, + session_id text not null, + role text not null check (role in ('user', 'assistant', 'system', 'tool')), + text text not null, + provider_run_id text, + created_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id), + foreign key (workspace_id, company_id) references public.sales_companies(workspace_id, id) on delete cascade +); + +create table if not exists public.sales_openviking_refs ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + company_id text, + related_type text not null, + related_id text, + ref_kind text not null, + uri text not null, + summary text, + created_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id), + unique (workspace_id, related_type, related_id, ref_kind), + foreign key (workspace_id, company_id) references public.sales_companies(workspace_id, id) on delete cascade +); + +create table if not exists public.jobs ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + job_type text not null, + status text not null default 'queued' check (status in ('queued', 'running', 'succeeded', 'failed', 'cancelled')), + entity_type text, + entity_id text, + idempotency_key text, + attempt_count integer not null default 0 check (attempt_count >= 0), + max_attempts integer not null default 3 check (max_attempts > 0), + scheduled_at timestamptz, + started_at timestamptz, + finished_at timestamptz, + error_json jsonb, + payload_json jsonb not null default '{}'::jsonb, + created_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (workspace_id, id) +); + +create unique index if not exists jobs_workspace_idempotency_unique + on public.jobs(workspace_id, idempotency_key) + where idempotency_key is not null; + +create table if not exists public.provider_runs ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + job_id text, + operation text not null, + status text not null check (status in ('running', 'succeeded', 'succeeded_with_issues', 'failed', 'cancelled')), + app_mode text not null, + entity_type text, + entity_id text, + started_at timestamptz not null, + finished_at timestamptz, + duration_ms integer check (duration_ms is null or duration_ms >= 0), + result_ref text, + error_json jsonb, + payload_json jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (workspace_id, id), + foreign key (workspace_id, job_id) references public.jobs(workspace_id, id) on delete cascade +); + +create table if not exists public.provider_run_steps ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + provider_run_id text not null, + sequence integer not null check (sequence > 0), + provider text not null, + operation text not null, + status text not null check (status in ('running', 'succeeded', 'failed', 'skipped', 'cancelled')), + input_summary text, + output_summary text, + request_id text, + raw_ref text, + usage_json jsonb, + attempts integer not null default 1 check (attempts > 0), + started_at timestamptz not null, + finished_at timestamptz, + latency_ms integer check (latency_ms is null or latency_ms >= 0), + error_json jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (workspace_id, id), + unique (workspace_id, provider_run_id, sequence), + foreign key (workspace_id, provider_run_id) references public.provider_runs(workspace_id, id) on delete cascade +); + +create table if not exists public.sync_sources ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + source_type text not null, + external_id text not null, + display_name text, + status text not null default 'active' check (status in ('active', 'paused', 'error', 'deleted')), + config_json jsonb not null default '{}'::jsonb, + last_synced_at timestamptz, + created_by uuid references auth.users(id) on delete set null, + updated_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (workspace_id, id), + unique (workspace_id, source_type, external_id) +); + +create table if not exists public.sync_checkpoints ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + source_id text not null, + checkpoint_key text not null, + checkpoint_value text, + content_hash text, + last_success_at timestamptz, + error_json jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (workspace_id, id), + unique (workspace_id, source_id, checkpoint_key), + foreign key (workspace_id, source_id) references public.sync_sources(workspace_id, id) on delete cascade +); + +create table if not exists public.audit_events ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + actor_user_id uuid references auth.users(id) on delete set null, + action text not null, + entity_type text, + entity_id text, + request_id text, + ip_hash text, + before_json jsonb, + after_json jsonb, + created_at timestamptz not null default now(), + unique (workspace_id, id) +); + +create index if not exists app_workspace_members_user_idx on public.app_workspace_members(user_id, workspace_id); +create index if not exists provider_connections_workspace_idx on public.provider_connections(workspace_id, provider); +create index if not exists sales_goals_workspace_idx on public.sales_goals(workspace_id, updated_at desc) where deleted_at is null; +create index if not exists sales_companies_workspace_idx on public.sales_companies(workspace_id, updated_at desc) where deleted_at is null; +create index if not exists sales_targets_goal_idx on public.sales_target_enterprises(workspace_id, goal_id, updated_at desc) where deleted_at is null; +create index if not exists sales_search_goal_idx on public.sales_company_search_results(workspace_id, goal_id, created_at desc); +create index if not exists sales_progress_company_idx on public.sales_progress_snapshots(workspace_id, company_id, created_at desc); +create index if not exists sales_dossiers_company_idx on public.sales_dossier_records(workspace_id, company_id, created_at desc) where deleted_at is null; +create index if not exists sales_materials_company_idx on public.sales_materials(workspace_id, company_id, updated_at desc) where deleted_at is null; +create index if not exists sales_qa_company_idx on public.sales_qa_messages(workspace_id, company_id, created_at); +create index if not exists sales_openviking_company_idx on public.sales_openviking_refs(workspace_id, company_id, created_at desc); +create index if not exists jobs_queue_idx on public.jobs(workspace_id, status, scheduled_at, created_at); +create index if not exists provider_runs_entity_idx on public.provider_runs(workspace_id, entity_type, entity_id, started_at desc); +create index if not exists provider_run_steps_run_idx on public.provider_run_steps(workspace_id, provider_run_id, sequence); +create index if not exists sync_sources_workspace_idx on public.sync_sources(workspace_id, source_type, status); +create index if not exists audit_events_entity_idx on public.audit_events(workspace_id, entity_type, entity_id, created_at desc); + +do $$ +declare + table_name text; +begin + foreach table_name in array array[ + 'app_workspaces', 'app_users', 'app_workspace_members', 'provider_connections', + 'sales_goals', 'sales_companies', 'sales_target_enterprises', 'sales_dossier_records', + 'sales_materials', 'jobs', 'provider_runs', 'provider_run_steps', 'sync_sources', 'sync_checkpoints' + ] + loop + execute format('drop trigger if exists set_%I_updated_at on public.%I', table_name, table_name); + execute format( + 'create trigger set_%I_updated_at before update on public.%I for each row execute function public.set_updated_at()', + table_name, + table_name + ); + end loop; +end; +$$; + +insert into public.schema_migrations(version, description) +values ('202607210001', 'Stage 2 multi-tenant sales workbench core schema') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210002_stage2_rls.sql b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210002_stage2_rls.sql new file mode 100644 index 00000000..d5a47a75 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210002_stage2_rls.sql @@ -0,0 +1,221 @@ +begin; + +create or replace function public.is_workspace_member(target_workspace_id uuid) +returns boolean +language sql +stable +security definer +set search_path = pg_catalog, public +as $$ + select exists ( + select 1 + from public.app_workspace_members member + where member.workspace_id = target_workspace_id + and member.user_id = (select auth.uid()) + ); +$$; + +create or replace function public.can_write_workspace(target_workspace_id uuid) +returns boolean +language sql +stable +security definer +set search_path = pg_catalog, public +as $$ + select exists ( + select 1 + from public.app_workspace_members member + where member.workspace_id = target_workspace_id + and member.user_id = (select auth.uid()) + and member.role in ('owner', 'admin', 'member') + ); +$$; + +create or replace function public.can_admin_workspace(target_workspace_id uuid) +returns boolean +language sql +stable +security definer +set search_path = pg_catalog, public +as $$ + select exists ( + select 1 + from public.app_workspace_members member + where member.workspace_id = target_workspace_id + and member.user_id = (select auth.uid()) + and member.role in ('owner', 'admin') + ); +$$; + +revoke all on function public.is_workspace_member(uuid) from public; +revoke all on function public.can_write_workspace(uuid) from public; +revoke all on function public.can_admin_workspace(uuid) from public; +grant execute on function public.is_workspace_member(uuid) to authenticated, service_role; +grant execute on function public.can_write_workspace(uuid) to authenticated, service_role; +grant execute on function public.can_admin_workspace(uuid) to authenticated, service_role; + +alter table public.app_workspaces enable row level security; +alter table public.app_workspaces force row level security; +alter table public.app_users enable row level security; +alter table public.app_users force row level security; +alter table public.app_workspace_members enable row level security; +alter table public.app_workspace_members force row level security; +alter table public.provider_connections enable row level security; +alter table public.provider_connections force row level security; + +drop policy if exists app_workspaces_select on public.app_workspaces; +create policy app_workspaces_select on public.app_workspaces + for select to authenticated + using (public.is_workspace_member(id)); + +drop policy if exists app_workspaces_insert on public.app_workspaces; +create policy app_workspaces_insert on public.app_workspaces + for insert to authenticated + with check (created_by = (select auth.uid())); + +drop policy if exists app_workspaces_update on public.app_workspaces; +create policy app_workspaces_update on public.app_workspaces + for update to authenticated + using (public.can_admin_workspace(id)) + with check (public.can_admin_workspace(id)); + +drop policy if exists app_users_select on public.app_users; +create policy app_users_select on public.app_users + for select to authenticated + using (id = (select auth.uid())); + +drop policy if exists app_users_insert on public.app_users; +create policy app_users_insert on public.app_users + for insert to authenticated + with check (id = (select auth.uid())); + +drop policy if exists app_users_update on public.app_users; +create policy app_users_update on public.app_users + for update to authenticated + using (id = (select auth.uid())) + with check (id = (select auth.uid())); + +drop policy if exists workspace_members_select on public.app_workspace_members; +create policy workspace_members_select on public.app_workspace_members + for select to authenticated + using (public.is_workspace_member(workspace_id)); + +drop policy if exists workspace_members_insert on public.app_workspace_members; +create policy workspace_members_insert on public.app_workspace_members + for insert to authenticated + with check (public.can_admin_workspace(workspace_id)); + +drop policy if exists workspace_members_update on public.app_workspace_members; +create policy workspace_members_update on public.app_workspace_members + for update to authenticated + using (public.can_admin_workspace(workspace_id)) + with check (public.can_admin_workspace(workspace_id)); + +drop policy if exists workspace_members_delete on public.app_workspace_members; +create policy workspace_members_delete on public.app_workspace_members + for delete to authenticated + using (public.can_admin_workspace(workspace_id)); + +drop policy if exists provider_connections_select on public.provider_connections; +create policy provider_connections_select on public.provider_connections + for select to authenticated + using (public.can_admin_workspace(workspace_id)); + +drop policy if exists provider_connections_insert on public.provider_connections; +create policy provider_connections_insert on public.provider_connections + for insert to authenticated + with check (public.can_admin_workspace(workspace_id)); + +drop policy if exists provider_connections_update on public.provider_connections; +create policy provider_connections_update on public.provider_connections + for update to authenticated + using (public.can_admin_workspace(workspace_id)) + with check (public.can_admin_workspace(workspace_id)); + +drop policy if exists provider_connections_delete on public.provider_connections; +create policy provider_connections_delete on public.provider_connections + for delete to authenticated + using (public.can_admin_workspace(workspace_id)); + +do $$ +declare + table_name text; +begin + foreach table_name in array array[ + 'sales_goals', 'sales_companies', 'sales_target_enterprises', 'sales_company_search_results', + 'sales_progress_snapshots', 'sales_dossier_records', 'sales_dossier_citations', 'sales_materials', + 'sales_qa_messages', 'sales_openviking_refs', 'jobs', 'provider_runs', 'provider_run_steps', + 'sync_sources', 'sync_checkpoints' + ] + loop + execute format('alter table public.%I enable row level security', table_name); + execute format('alter table public.%I force row level security', table_name); + execute format('drop policy if exists workspace_select on public.%I', table_name); + execute format( + 'create policy workspace_select on public.%I for select to authenticated using (public.is_workspace_member(workspace_id))', + table_name + ); + execute format('drop policy if exists workspace_insert on public.%I', table_name); + execute format( + 'create policy workspace_insert on public.%I for insert to authenticated with check (public.can_write_workspace(workspace_id))', + table_name + ); + execute format('drop policy if exists workspace_update on public.%I', table_name); + execute format( + 'create policy workspace_update on public.%I for update to authenticated using (public.can_write_workspace(workspace_id)) with check (public.can_write_workspace(workspace_id))', + table_name + ); + execute format('drop policy if exists workspace_delete on public.%I', table_name); + execute format( + 'create policy workspace_delete on public.%I for delete to authenticated using (public.can_write_workspace(workspace_id))', + table_name + ); + end loop; +end; +$$; + +alter table public.audit_events enable row level security; +alter table public.audit_events force row level security; + +drop policy if exists audit_events_select on public.audit_events; +create policy audit_events_select on public.audit_events + for select to authenticated + using (public.can_admin_workspace(workspace_id)); + +drop policy if exists audit_events_insert on public.audit_events; +create policy audit_events_insert on public.audit_events + for insert to authenticated + with check (public.can_write_workspace(workspace_id)); + +revoke all on all tables in schema public from anon; +revoke all on public.schema_migrations from authenticated; +grant usage on schema public to authenticated; +grant select, insert, update, delete on public.app_workspaces to authenticated; +grant select, insert, update on public.app_users to authenticated; +grant select, insert, update, delete on public.app_workspace_members to authenticated; +grant select, insert, update, delete on public.provider_connections to authenticated; +grant select, insert, update, delete on + public.sales_goals, + public.sales_companies, + public.sales_target_enterprises, + public.sales_company_search_results, + public.sales_progress_snapshots, + public.sales_dossier_records, + public.sales_dossier_citations, + public.sales_materials, + public.sales_qa_messages, + public.sales_openviking_refs, + public.jobs, + public.provider_runs, + public.provider_run_steps, + public.sync_sources, + public.sync_checkpoints +to authenticated; +grant select, insert on public.audit_events to authenticated; +grant all on all tables in schema public to service_role; + +insert into public.schema_migrations(version, description) +values ('202607210002', 'Stage 2 row-level security and Data API grants') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210003_stage2_fk_corrections.sql b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210003_stage2_fk_corrections.sql new file mode 100644 index 00000000..b2f64fbf --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210003_stage2_fk_corrections.sql @@ -0,0 +1,39 @@ +begin; + +alter table public.sales_company_search_results + drop constraint if exists sales_company_search_results_workspace_id_company_id_fkey; +alter table public.sales_company_search_results + add constraint sales_company_search_results_workspace_id_company_id_fkey + foreign key (workspace_id, company_id) + references public.sales_companies(workspace_id, id) + on delete cascade; + +alter table public.provider_runs + drop constraint if exists provider_runs_workspace_id_job_id_fkey; +alter table public.provider_runs + add constraint provider_runs_workspace_id_job_id_fkey + foreign key (workspace_id, job_id) + references public.jobs(workspace_id, id) + on delete cascade; + +alter table public.sales_dossier_records + drop constraint if exists sales_dossier_records_provider_run_id_fkey; +alter table public.sales_dossier_records + add constraint sales_dossier_records_provider_run_id_fkey + foreign key (provider_run_id) + references public.provider_runs(id) + on delete set null; + +alter table public.sales_qa_messages + drop constraint if exists sales_qa_messages_provider_run_id_fkey; +alter table public.sales_qa_messages + add constraint sales_qa_messages_provider_run_id_fkey + foreign key (provider_run_id) + references public.provider_runs(id) + on delete set null; + +insert into public.schema_migrations(version, description) +values ('202607210003', 'Correct composite delete actions and provider run references') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210004_stage2_data_api_rpc.sql b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210004_stage2_data_api_rpc.sql new file mode 100644 index 00000000..ba66fff9 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210004_stage2_data_api_rpc.sql @@ -0,0 +1,278 @@ +begin; + +create or replace function public.persist_sales_dossier( + p_workspace_id uuid, + p_dossier jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_dossier_id text := nullif(p_dossier ->> 'id', ''); + v_company_id text := nullif(p_dossier ->> 'company_id', ''); + citation jsonb; + citation_id text; +begin + if v_dossier_id is null or v_company_id is null then + raise exception using message = 'dossier id and company_id are required', errcode = '22023'; + end if; + + if not exists ( + select 1 from public.app_workspaces where id = p_workspace_id + ) then + raise exception using message = 'application workspace was not found', errcode = 'P0002'; + end if; + + if not exists ( + select 1 + from public.sales_companies + where workspace_id = p_workspace_id and id = v_company_id and deleted_at is null + ) then + raise exception using message = 'company was not found in application workspace', errcode = 'P0002'; + end if; + + if exists ( + select 1 from public.sales_dossier_records + where id = v_dossier_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace dossier identifier conflict', errcode = '23505'; + end if; + + insert into public.sales_dossier_records ( + id, + workspace_id, + company_id, + title, + summary, + memory_summary, + provider_run_id, + created_at, + payload_json + ) + values ( + v_dossier_id, + p_workspace_id, + v_company_id, + p_dossier ->> 'title', + p_dossier ->> 'summary', + p_dossier ->> 'memory_summary', + nullif(p_dossier ->> 'provider_run_id', ''), + coalesce(nullif(p_dossier ->> 'created_at', '')::timestamptz, now()), + p_dossier + ) + on conflict (id) do update set + title = excluded.title, + summary = excluded.summary, + memory_summary = excluded.memory_summary, + provider_run_id = excluded.provider_run_id, + deleted_at = null, + payload_json = excluded.payload_json + where public.sales_dossier_records.workspace_id = excluded.workspace_id; + + delete from public.sales_dossier_citations + where workspace_id = p_workspace_id and dossier_id = v_dossier_id; + + for citation in + select value from jsonb_array_elements(coalesce(p_dossier -> 'citations', '[]'::jsonb)) + loop + citation_id := v_dossier_id || ':' || coalesce(nullif(citation ->> 'id', ''), 'citation'); + if exists ( + select 1 from public.sales_dossier_citations + where id = citation_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace citation identifier conflict', errcode = '23505'; + end if; + + insert into public.sales_dossier_citations ( + id, + workspace_id, + v_dossier_id, + citation_no, + label, + source_kind, + url, + created_at, + payload_json + ) + values ( + citation_id, + p_workspace_id, + dossier_id, + coalesce(nullif(citation ->> 'id', ''), 'citation'), + citation ->> 'label', + citation ->> 'source_kind', + coalesce(citation ->> 'url', ''), + now(), + citation + ); + end loop; + + return jsonb_build_object( + 'id', v_dossier_id, + 'workspace_id', p_workspace_id, + 'citation_count', jsonb_array_length(coalesce(p_dossier -> 'citations', '[]'::jsonb)) + ); +end; +$$; + +create or replace function public.persist_provider_run( + p_workspace_id uuid, + p_run jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_run_id text := nullif(p_run ->> 'id', ''); + step jsonb; + step_id text; +begin + if v_run_id is null then + raise exception using message = 'provider run id is required', errcode = '22023'; + end if; + + if not exists ( + select 1 from public.app_workspaces where id = p_workspace_id + ) then + raise exception using message = 'application workspace was not found', errcode = 'P0002'; + end if; + + if exists ( + select 1 from public.provider_runs + where id = v_run_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace provider run identifier conflict', errcode = '23505'; + end if; + + insert into public.provider_runs ( + id, + workspace_id, + operation, + status, + app_mode, + entity_type, + entity_id, + started_at, + finished_at, + duration_ms, + result_ref, + error_json, + payload_json + ) + values ( + v_run_id, + p_workspace_id, + coalesce(nullif(p_run ->> 'operation', ''), 'provider_workflow'), + coalesce(nullif(p_run ->> 'status', ''), 'running'), + coalesce(nullif(p_run ->> 'app_mode', ''), 'development'), + nullif(p_run ->> 'entity_type', ''), + nullif(p_run ->> 'entity_id', ''), + coalesce(nullif(p_run ->> 'started_at', '')::timestamptz, now()), + nullif(p_run ->> 'finished_at', '')::timestamptz, + nullif(p_run ->> 'duration_ms', '')::integer, + nullif(p_run ->> 'result_ref', ''), + case + when p_run -> 'error' is null or jsonb_typeof(p_run -> 'error') = 'null' then null + else p_run -> 'error' + end, + p_run + ) + on conflict (id) do update set + operation = excluded.operation, + status = excluded.status, + app_mode = excluded.app_mode, + entity_type = excluded.entity_type, + entity_id = excluded.entity_id, + started_at = excluded.started_at, + finished_at = excluded.finished_at, + duration_ms = excluded.duration_ms, + result_ref = excluded.result_ref, + error_json = excluded.error_json, + payload_json = excluded.payload_json + where public.provider_runs.workspace_id = excluded.workspace_id; + + delete from public.provider_run_steps + where workspace_id = p_workspace_id and provider_run_id = v_run_id; + + for step in + select value from jsonb_array_elements(coalesce(p_run -> 'steps', '[]'::jsonb)) + loop + step_id := nullif(step ->> 'id', ''); + if step_id is null then + raise exception using message = 'provider step id is required', errcode = '22023'; + end if; + if exists ( + select 1 from public.provider_run_steps + where id = step_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace provider step identifier conflict', errcode = '23505'; + end if; + + insert into public.provider_run_steps ( + id, + workspace_id, + provider_run_id, + sequence, + provider, + operation, + status, + input_summary, + output_summary, + request_id, + raw_ref, + usage_json, + attempts, + started_at, + finished_at, + latency_ms, + error_json + ) + values ( + step_id, + p_workspace_id, + v_run_id, + coalesce(nullif(step ->> 'sequence', '')::integer, 1), + coalesce(nullif(step ->> 'provider', ''), 'unknown'), + coalesce(nullif(step ->> 'operation', ''), 'provider_call'), + coalesce(nullif(step ->> 'status', ''), 'running'), + nullif(step ->> 'input_summary', ''), + nullif(step ->> 'output_summary', ''), + nullif(step ->> 'request_id', ''), + nullif(step ->> 'raw_ref', ''), + case + when step -> 'usage' is null or jsonb_typeof(step -> 'usage') = 'null' then null + else step -> 'usage' + end, + coalesce(nullif(step ->> 'attempts', '')::integer, 1), + coalesce(nullif(step ->> 'started_at', '')::timestamptz, now()), + nullif(step ->> 'finished_at', '')::timestamptz, + nullif(step ->> 'latency_ms', '')::integer, + case + when step -> 'error' is null or jsonb_typeof(step -> 'error') = 'null' then null + else step -> 'error' + end + ); + end loop; + + return jsonb_build_object( + 'id', v_run_id, + 'workspace_id', p_workspace_id, + 'step_count', jsonb_array_length(coalesce(p_run -> 'steps', '[]'::jsonb)) + ); +end; +$$; + +revoke all on function public.persist_sales_dossier(uuid, jsonb) from public, anon, authenticated; +revoke all on function public.persist_provider_run(uuid, jsonb) from public, anon, authenticated; +grant execute on function public.persist_sales_dossier(uuid, jsonb) to service_role; +grant execute on function public.persist_provider_run(uuid, jsonb) to service_role; + +insert into public.schema_migrations(version, description) +values ('202607210004', 'Stage 2 transactional Data API persistence functions') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210005_fix_dossier_citation_rpc.sql b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210005_fix_dossier_citation_rpc.sql new file mode 100644 index 00000000..d8d95f63 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210005_fix_dossier_citation_rpc.sql @@ -0,0 +1,127 @@ +begin; + +create or replace function public.persist_sales_dossier( + p_workspace_id uuid, + p_dossier jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_dossier_id text := nullif(p_dossier ->> 'id', ''); + v_company_id text := nullif(p_dossier ->> 'company_id', ''); + citation jsonb; + citation_id text; +begin + if v_dossier_id is null or v_company_id is null then + raise exception using message = 'dossier id and company_id are required', errcode = '22023'; + end if; + + if not exists ( + select 1 from public.app_workspaces where id = p_workspace_id + ) then + raise exception using message = 'application workspace was not found', errcode = 'P0002'; + end if; + + if not exists ( + select 1 + from public.sales_companies + where workspace_id = p_workspace_id and id = v_company_id and deleted_at is null + ) then + raise exception using message = 'company was not found in application workspace', errcode = 'P0002'; + end if; + + if exists ( + select 1 from public.sales_dossier_records + where id = v_dossier_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace dossier identifier conflict', errcode = '23505'; + end if; + + insert into public.sales_dossier_records ( + id, + workspace_id, + company_id, + title, + summary, + memory_summary, + provider_run_id, + created_at, + payload_json + ) + values ( + v_dossier_id, + p_workspace_id, + v_company_id, + p_dossier ->> 'title', + p_dossier ->> 'summary', + p_dossier ->> 'memory_summary', + nullif(p_dossier ->> 'provider_run_id', ''), + coalesce(nullif(p_dossier ->> 'created_at', '')::timestamptz, now()), + p_dossier + ) + on conflict (id) do update set + title = excluded.title, + summary = excluded.summary, + memory_summary = excluded.memory_summary, + provider_run_id = excluded.provider_run_id, + deleted_at = null, + payload_json = excluded.payload_json + where public.sales_dossier_records.workspace_id = excluded.workspace_id; + + delete from public.sales_dossier_citations + where workspace_id = p_workspace_id and dossier_id = v_dossier_id; + + for citation in + select value from jsonb_array_elements(coalesce(p_dossier -> 'citations', '[]'::jsonb)) + loop + citation_id := v_dossier_id || ':' || coalesce(nullif(citation ->> 'id', ''), 'citation'); + if exists ( + select 1 from public.sales_dossier_citations + where id = citation_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace citation identifier conflict', errcode = '23505'; + end if; + + insert into public.sales_dossier_citations ( + id, + workspace_id, + dossier_id, + citation_no, + label, + source_kind, + url, + created_at, + payload_json + ) + values ( + citation_id, + p_workspace_id, + v_dossier_id, + coalesce(nullif(citation ->> 'id', ''), 'citation'), + citation ->> 'label', + citation ->> 'source_kind', + coalesce(citation ->> 'url', ''), + now(), + citation + ); + end loop; + + return jsonb_build_object( + 'id', v_dossier_id, + 'workspace_id', p_workspace_id, + 'citation_count', jsonb_array_length(coalesce(p_dossier -> 'citations', '[]'::jsonb)) + ); +end; +$$; + +revoke all on function public.persist_sales_dossier(uuid, jsonb) from public, anon, authenticated; +grant execute on function public.persist_sales_dossier(uuid, jsonb) to service_role; + +insert into public.schema_migrations(version, description) +values ('202607210005', 'Fix dossier citation columns in transactional Data API persistence') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210006_cover_foreign_key_indexes.sql b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210006_cover_foreign_key_indexes.sql new file mode 100644 index 00000000..43d4905c --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210006_cover_foreign_key_indexes.sql @@ -0,0 +1,61 @@ +begin; + +create index if not exists app_workspaces_created_by_idx + on public.app_workspaces(created_by); +create index if not exists audit_events_actor_user_id_idx + on public.audit_events(actor_user_id); +create index if not exists jobs_created_by_idx + on public.jobs(created_by); +create index if not exists provider_connections_created_by_idx + on public.provider_connections(created_by); +create index if not exists provider_connections_updated_by_idx + on public.provider_connections(updated_by); +create index if not exists provider_runs_workspace_job_idx + on public.provider_runs(workspace_id, job_id); + +create index if not exists sales_companies_created_by_idx + on public.sales_companies(created_by); +create index if not exists sales_companies_updated_by_idx + on public.sales_companies(updated_by); +create index if not exists sales_company_search_results_created_by_idx + on public.sales_company_search_results(created_by); +create index if not exists sales_company_search_results_workspace_company_idx + on public.sales_company_search_results(workspace_id, company_id); +create index if not exists sales_dossier_records_created_by_idx + on public.sales_dossier_records(created_by); +create index if not exists sales_dossier_records_provider_run_id_idx + on public.sales_dossier_records(provider_run_id); +create index if not exists sales_dossier_records_updated_by_idx + on public.sales_dossier_records(updated_by); +create index if not exists sales_goals_created_by_idx + on public.sales_goals(created_by); +create index if not exists sales_goals_updated_by_idx + on public.sales_goals(updated_by); +create index if not exists sales_materials_created_by_idx + on public.sales_materials(created_by); +create index if not exists sales_materials_updated_by_idx + on public.sales_materials(updated_by); +create index if not exists sales_openviking_refs_created_by_idx + on public.sales_openviking_refs(created_by); +create index if not exists sales_progress_snapshots_created_by_idx + on public.sales_progress_snapshots(created_by); +create index if not exists sales_qa_messages_created_by_idx + on public.sales_qa_messages(created_by); +create index if not exists sales_qa_messages_provider_run_id_idx + on public.sales_qa_messages(provider_run_id); +create index if not exists sales_target_enterprises_created_by_idx + on public.sales_target_enterprises(created_by); +create index if not exists sales_target_enterprises_updated_by_idx + on public.sales_target_enterprises(updated_by); +create index if not exists sales_target_enterprises_workspace_company_idx + on public.sales_target_enterprises(workspace_id, company_id); +create index if not exists sync_sources_created_by_idx + on public.sync_sources(created_by); +create index if not exists sync_sources_updated_by_idx + on public.sync_sources(updated_by); + +insert into public.schema_migrations(version, description) +values ('202607210006', 'Add covering indexes for public schema foreign keys') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210007_stage3_material_sync.sql b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210007_stage3_material_sync.sql new file mode 100644 index 00000000..62256b44 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210007_stage3_material_sync.sql @@ -0,0 +1,52 @@ +begin; + +alter table public.sales_materials + add column if not exists source_id text, + add column if not exists source_version text, + add column if not exists last_synced_at timestamptz; + +update public.sales_materials +set + source_id = coalesce(source_id, nullif(payload_json ->> 'source_id', '')), + source_version = coalesce(source_version, nullif(payload_json ->> 'source_version', '')), + last_synced_at = coalesce( + last_synced_at, + case + when coalesce(source_id, nullif(payload_json ->> 'source_id', '')) is not null then updated_at + else null + end + ) +where source_id is null + or source_version is null + or last_synced_at is null; + +create unique index if not exists sales_materials_source_unique + on public.sales_materials(workspace_id, company_id, source_id) + where source_id is not null and deleted_at is null; + +create index if not exists sales_materials_source_id_idx + on public.sales_materials(workspace_id, source_id) + where source_id is not null and deleted_at is null; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'sales_materials_workspace_source_fkey' + and conrelid = 'public.sales_materials'::regclass + ) then + alter table public.sales_materials + add constraint sales_materials_workspace_source_fkey + foreign key (workspace_id, source_id) + references public.sync_sources(workspace_id, id) + on delete restrict; + end if; +end; +$$; + +insert into public.schema_migrations(version, description) +values ('202607210007', 'Add stable sync-source identity and version metadata to sales materials') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210008_stage4_evidence_versions.sql b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210008_stage4_evidence_versions.sql new file mode 100644 index 00000000..57cd8d99 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607210008_stage4_evidence_versions.sql @@ -0,0 +1,366 @@ +begin; + +alter table public.sales_dossier_records + add column if not exists version_no integer, + add column if not exists previous_dossier_id text, + add column if not exists evidence_hash text, + add column if not exists dossier_fingerprint text, + add column if not exists change_status text, + add column if not exists data_as_of timestamptz, + add column if not exists generated_at timestamptz, + add column if not exists evidence_pack_json jsonb not null default '[]'::jsonb; + +with ranked as ( + select + id, + row_number() over ( + partition by workspace_id, company_id + order by created_at asc, id asc + )::integer as version_no + from public.sales_dossier_records +) +update public.sales_dossier_records as dossier +set version_no = ranked.version_no +from ranked +where dossier.id = ranked.id + and dossier.version_no is null; + +update public.sales_dossier_records +set + evidence_hash = coalesce(evidence_hash, nullif(payload_json ->> 'evidence_hash', '')), + dossier_fingerprint = coalesce(dossier_fingerprint, nullif(payload_json ->> 'dossier_fingerprint', '')), + change_status = coalesce(change_status, nullif(payload_json ->> 'change_status', ''), 'initial'), + data_as_of = coalesce( + data_as_of, + case + when coalesce(payload_json ->> 'data_as_of', '') ~ '^\d{4}-\d{2}-\d{2}' + then (payload_json ->> 'data_as_of')::timestamptz + else created_at + end + ), + generated_at = coalesce( + generated_at, + case + when coalesce(payload_json ->> 'generated_at', '') ~ '^\d{4}-\d{2}-\d{2}' + then (payload_json ->> 'generated_at')::timestamptz + else created_at + end + ), + evidence_pack_json = case + when evidence_pack_json = '[]'::jsonb and jsonb_typeof(payload_json -> 'evidence_pack') = 'array' + then payload_json -> 'evidence_pack' + else evidence_pack_json + end; + +alter table public.sales_dossier_records + alter column version_no set default 1, + alter column version_no set not null, + alter column change_status set default 'initial', + alter column change_status set not null, + alter column generated_at set default now(), + alter column generated_at set not null; + +do $$ +begin + if not exists ( + select 1 from pg_constraint + where conname = 'sales_dossier_records_change_status_check' + and conrelid = 'public.sales_dossier_records'::regclass + ) then + alter table public.sales_dossier_records + add constraint sales_dossier_records_change_status_check + check (change_status in ('initial', 'changed')); + end if; + + if not exists ( + select 1 from pg_constraint + where conname = 'sales_dossier_records_previous_fkey' + and conrelid = 'public.sales_dossier_records'::regclass + ) then + alter table public.sales_dossier_records + add constraint sales_dossier_records_previous_fkey + foreign key (workspace_id, previous_dossier_id) + references public.sales_dossier_records(workspace_id, id) + on delete restrict; + end if; +end; +$$; + +create unique index if not exists sales_dossier_records_company_version_unique + on public.sales_dossier_records(workspace_id, company_id, version_no) + where deleted_at is null; + +create index if not exists sales_dossier_records_evidence_hash_idx + on public.sales_dossier_records(workspace_id, company_id, evidence_hash) + where evidence_hash is not null and deleted_at is null; + +create index if not exists sales_dossier_records_previous_idx + on public.sales_dossier_records(workspace_id, previous_dossier_id) + where previous_dossier_id is not null; + +create or replace function public.persist_sales_dossier( + p_workspace_id uuid, + p_dossier jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_dossier_id text := nullif(p_dossier ->> 'id', ''); + v_company_id text := nullif(p_dossier ->> 'company_id', ''); + citation jsonb; + citation_id text; +begin + if v_dossier_id is null or v_company_id is null then + raise exception using message = 'dossier id and company_id are required', errcode = '22023'; + end if; + + if not exists (select 1 from public.app_workspaces where id = p_workspace_id) then + raise exception using message = 'application workspace was not found', errcode = 'P0002'; + end if; + + if not exists ( + select 1 from public.sales_companies + where workspace_id = p_workspace_id and id = v_company_id and deleted_at is null + ) then + raise exception using message = 'company was not found in application workspace', errcode = 'P0002'; + end if; + + if exists ( + select 1 from public.sales_dossier_records + where id = v_dossier_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace dossier identifier conflict', errcode = '23505'; + end if; + + insert into public.sales_dossier_records ( + id, workspace_id, company_id, title, summary, memory_summary, status, + provider_run_id, version_no, previous_dossier_id, evidence_hash, + dossier_fingerprint, change_status, data_as_of, generated_at, + evidence_pack_json, created_at, updated_at, payload_json + ) + values ( + v_dossier_id, + p_workspace_id, + v_company_id, + p_dossier ->> 'title', + p_dossier ->> 'summary', + p_dossier ->> 'memory_summary', + coalesce(nullif(p_dossier ->> 'status', ''), 'completed'), + nullif(p_dossier ->> 'provider_run_id', ''), + coalesce(nullif(p_dossier ->> 'version_no', '')::integer, 1), + nullif(p_dossier ->> 'previous_dossier_id', ''), + nullif(p_dossier ->> 'evidence_hash', ''), + nullif(p_dossier ->> 'dossier_fingerprint', ''), + coalesce(nullif(p_dossier ->> 'change_status', ''), 'initial'), + nullif(p_dossier ->> 'data_as_of', '')::timestamptz, + coalesce(nullif(p_dossier ->> 'generated_at', '')::timestamptz, now()), + coalesce(p_dossier -> 'evidence_pack', '[]'::jsonb), + coalesce(nullif(p_dossier ->> 'created_at', '')::timestamptz, now()), + coalesce(nullif(p_dossier ->> 'updated_at', '')::timestamptz, now()), + p_dossier + ) + on conflict (id) do update set + title = excluded.title, + summary = excluded.summary, + memory_summary = excluded.memory_summary, + status = excluded.status, + provider_run_id = excluded.provider_run_id, + version_no = excluded.version_no, + previous_dossier_id = excluded.previous_dossier_id, + evidence_hash = excluded.evidence_hash, + dossier_fingerprint = excluded.dossier_fingerprint, + change_status = excluded.change_status, + data_as_of = excluded.data_as_of, + generated_at = excluded.generated_at, + evidence_pack_json = excluded.evidence_pack_json, + updated_at = excluded.updated_at, + deleted_at = null, + payload_json = excluded.payload_json + where public.sales_dossier_records.workspace_id = excluded.workspace_id; + + delete from public.sales_dossier_citations + where workspace_id = p_workspace_id and dossier_id = v_dossier_id; + + for citation in + select value from jsonb_array_elements(coalesce(p_dossier -> 'citations', '[]'::jsonb)) + loop + citation_id := v_dossier_id || ':' || coalesce(nullif(citation ->> 'id', ''), 'citation'); + if exists ( + select 1 from public.sales_dossier_citations + where id = citation_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace citation identifier conflict', errcode = '23505'; + end if; + + insert into public.sales_dossier_citations ( + id, workspace_id, dossier_id, citation_no, label, source_kind, url, created_at, payload_json + ) + values ( + citation_id, + p_workspace_id, + v_dossier_id, + coalesce(nullif(citation ->> 'id', ''), 'citation'), + citation ->> 'label', + citation ->> 'source_kind', + coalesce(citation ->> 'url', ''), + now(), + citation + ); + end loop; + + return jsonb_build_object( + 'id', v_dossier_id, + 'workspace_id', p_workspace_id, + 'version_no', coalesce(nullif(p_dossier ->> 'version_no', '')::integer, 1), + 'citation_count', jsonb_array_length(coalesce(p_dossier -> 'citations', '[]'::jsonb)) + ); +end; +$$; + +revoke all on function public.persist_sales_dossier(uuid, jsonb) from public, anon, authenticated; +grant execute on function public.persist_sales_dossier(uuid, jsonb) to service_role; + +create or replace function public.persist_provider_run( + p_workspace_id uuid, + p_run jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_run_id text := nullif(p_run ->> 'id', ''); + v_job_id text := nullif(p_run ->> 'job_id', ''); + step jsonb; + step_id text; +begin + if v_run_id is null then + raise exception using message = 'provider run id is required', errcode = '22023'; + end if; + + if not exists (select 1 from public.app_workspaces where id = p_workspace_id) then + raise exception using message = 'application workspace was not found', errcode = 'P0002'; + end if; + + if v_job_id is not null and not exists ( + select 1 from public.jobs where workspace_id = p_workspace_id and id = v_job_id + ) then + raise exception using message = 'provider run job was not found in application workspace', errcode = 'P0002'; + end if; + + if exists ( + select 1 from public.provider_runs + where id = v_run_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace provider run identifier conflict', errcode = '23505'; + end if; + + insert into public.provider_runs ( + id, workspace_id, job_id, operation, status, app_mode, entity_type, entity_id, + started_at, finished_at, duration_ms, result_ref, error_json, payload_json + ) + values ( + v_run_id, + p_workspace_id, + v_job_id, + coalesce(nullif(p_run ->> 'operation', ''), 'provider_workflow'), + coalesce(nullif(p_run ->> 'status', ''), 'running'), + coalesce(nullif(p_run ->> 'app_mode', ''), 'development'), + nullif(p_run ->> 'entity_type', ''), + nullif(p_run ->> 'entity_id', ''), + coalesce(nullif(p_run ->> 'started_at', '')::timestamptz, now()), + nullif(p_run ->> 'finished_at', '')::timestamptz, + nullif(p_run ->> 'duration_ms', '')::integer, + nullif(p_run ->> 'result_ref', ''), + case + when p_run -> 'error' is null or jsonb_typeof(p_run -> 'error') = 'null' then null + else p_run -> 'error' + end, + p_run + ) + on conflict (id) do update set + job_id = excluded.job_id, + operation = excluded.operation, + status = excluded.status, + app_mode = excluded.app_mode, + entity_type = excluded.entity_type, + entity_id = excluded.entity_id, + started_at = excluded.started_at, + finished_at = excluded.finished_at, + duration_ms = excluded.duration_ms, + result_ref = excluded.result_ref, + error_json = excluded.error_json, + payload_json = excluded.payload_json, + updated_at = now() + where public.provider_runs.workspace_id = excluded.workspace_id; + + delete from public.provider_run_steps + where workspace_id = p_workspace_id and provider_run_id = v_run_id; + + for step in + select value from jsonb_array_elements(coalesce(p_run -> 'steps', '[]'::jsonb)) + loop + step_id := nullif(step ->> 'id', ''); + if step_id is null then + raise exception using message = 'provider step id is required', errcode = '22023'; + end if; + if exists ( + select 1 from public.provider_run_steps + where id = step_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace provider step identifier conflict', errcode = '23505'; + end if; + + insert into public.provider_run_steps ( + id, workspace_id, provider_run_id, sequence, provider, operation, status, + input_summary, output_summary, request_id, raw_ref, usage_json, attempts, + started_at, finished_at, latency_ms, error_json + ) + values ( + step_id, + p_workspace_id, + v_run_id, + coalesce(nullif(step ->> 'sequence', '')::integer, 1), + coalesce(nullif(step ->> 'provider', ''), 'unknown'), + coalesce(nullif(step ->> 'operation', ''), 'provider_call'), + coalesce(nullif(step ->> 'status', ''), 'running'), + nullif(step ->> 'input_summary', ''), + nullif(step ->> 'output_summary', ''), + nullif(step ->> 'request_id', ''), + nullif(step ->> 'raw_ref', ''), + case + when step -> 'usage' is null or jsonb_typeof(step -> 'usage') = 'null' then null + else step -> 'usage' + end, + coalesce(nullif(step ->> 'attempts', '')::integer, 1), + coalesce(nullif(step ->> 'started_at', '')::timestamptz, now()), + nullif(step ->> 'finished_at', '')::timestamptz, + nullif(step ->> 'latency_ms', '')::integer, + case + when step -> 'error' is null or jsonb_typeof(step -> 'error') = 'null' then null + else step -> 'error' + end + ); + end loop; + + return jsonb_build_object( + 'id', v_run_id, + 'workspace_id', p_workspace_id, + 'job_id', v_job_id, + 'step_count', jsonb_array_length(coalesce(p_run -> 'steps', '[]'::jsonb)) + ); +end; +$$; + +revoke all on function public.persist_provider_run(uuid, jsonb) from public, anon, authenticated; +grant execute on function public.persist_provider_run(uuid, jsonb) to service_role; + +insert into public.schema_migrations(version, description) +values ('202607210008', 'Add dossier evidence versions and atomic job-linked provider runs') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607230001_paid_workflow_guard.sql b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607230001_paid_workflow_guard.sql new file mode 100644 index 00000000..f63fb444 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607230001_paid_workflow_guard.sql @@ -0,0 +1,318 @@ +begin; + +alter table public.jobs + add column if not exists is_paid boolean not null default false; + +create table if not exists public.paid_workflow_reservations ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + job_id text not null, + job_type text not null, + status text not null default 'running' + check (status in ('running', 'succeeded', 'failed', 'cancelled', 'expired')), + reserved_at timestamptz not null default now(), + released_at timestamptz, + expires_at timestamptz not null, + payload_json jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (workspace_id, id), + foreign key (workspace_id, job_id) references public.jobs(workspace_id, id) on delete cascade +); + +create index if not exists paid_workflow_reservations_active_idx + on public.paid_workflow_reservations(workspace_id, status, expires_at); + +create index if not exists paid_workflow_reservations_daily_idx + on public.paid_workflow_reservations(workspace_id, reserved_at desc); + +alter table public.paid_workflow_reservations enable row level security; +revoke all on table public.paid_workflow_reservations from public, anon, authenticated; + +drop trigger if exists set_paid_workflow_reservations_updated_at on public.paid_workflow_reservations; +create trigger set_paid_workflow_reservations_updated_at +before update on public.paid_workflow_reservations +for each row execute function public.set_updated_at(); + +create or replace function public.reserve_paid_workflow( + p_workspace_id uuid, + p_job jsonb, + p_reservation_id text, + p_max_concurrent integer, + p_daily_limit integer, + p_budget_timezone text, + p_stale_after_seconds integer +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job_id text := nullif(p_job ->> 'id', ''); + v_job_type text := nullif(p_job ->> 'job_type', ''); + v_now timestamptz := now(); + v_running integer := 0; + v_daily integer := 0; + v_timezone text := coalesce(nullif(p_budget_timezone, ''), 'UTC'); + v_stale_seconds integer := greatest(coalesce(p_stale_after_seconds, 1800), 60); +begin + if v_job_id is null or v_job_type is null or nullif(p_reservation_id, '') is null then + raise exception using message = 'paid_workflow_reservation_invalid', errcode = '22023'; + end if; + if coalesce(p_max_concurrent, 0) < 0 or coalesce(p_daily_limit, 0) < 0 then + raise exception using message = 'paid_workflow_limit_invalid', errcode = '22023'; + end if; + if not exists (select 1 from public.app_workspaces where id = p_workspace_id) then + raise exception using message = 'application workspace was not found', errcode = 'P0002'; + end if; + if not exists (select 1 from pg_catalog.pg_timezone_names where name = v_timezone) then + raise exception using message = 'paid_workflow_timezone_invalid', errcode = '22023'; + end if; + if exists ( + select 1 from public.jobs where id = v_job_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace job identifier conflict', errcode = '23505'; + end if; + + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_workspace_id::text)); + + update public.paid_workflow_reservations + set + status = 'expired', + released_at = v_now, + payload_json = payload_json || jsonb_build_object('release_reason', 'reservation_expired') + where workspace_id = p_workspace_id + and status = 'running' + and expires_at <= v_now; + + update public.jobs as job + set + status = 'failed', + finished_at = v_now, + error_json = jsonb_build_object( + 'code', 'paid_workflow_reservation_expired', + 'message', '任务执行超过预约时限,已自动释放并发名额。', + 'retryable', true + ), + payload_json = jsonb_set( + jsonb_set(job.payload_json, '{status}', '"failed"'::jsonb, true), + '{error}', + jsonb_build_object( + 'code', 'paid_workflow_reservation_expired', + 'message', '任务执行超过预约时限,已自动释放并发名额。', + 'retryable', true + ), + true + ) + where job.workspace_id = p_workspace_id + and job.status = 'running' + and exists ( + select 1 + from public.paid_workflow_reservations as reservation + where reservation.workspace_id = job.workspace_id + and reservation.job_id = job.id + and reservation.status = 'expired' + and reservation.released_at = v_now + ); + + select count(*)::integer + into v_running + from public.paid_workflow_reservations + where workspace_id = p_workspace_id and status = 'running'; + + if coalesce(p_max_concurrent, 0) > 0 and v_running >= p_max_concurrent then + raise exception using + message = 'paid_workflow_concurrency_exceeded', + detail = jsonb_build_object( + 'running', v_running, + 'limit', p_max_concurrent, + 'retry_after_seconds', least(v_stale_seconds, 60) + )::text, + errcode = 'P0001'; + end if; + + select count(*)::integer + into v_daily + from public.paid_workflow_reservations + where workspace_id = p_workspace_id + and pg_catalog.timezone(v_timezone, reserved_at)::date = pg_catalog.timezone(v_timezone, v_now)::date; + + if coalesce(p_daily_limit, 0) > 0 and v_daily >= p_daily_limit then + raise exception using + message = 'paid_workflow_daily_limit_exceeded', + detail = jsonb_build_object('used', v_daily, 'limit', p_daily_limit, 'timezone', v_timezone)::text, + errcode = 'P0001'; + end if; + + insert into public.jobs ( + id, workspace_id, job_type, status, entity_type, entity_id, idempotency_key, + attempt_count, max_attempts, scheduled_at, started_at, finished_at, + error_json, payload_json, is_paid, created_at, updated_at + ) + values ( + v_job_id, + p_workspace_id, + v_job_type, + 'running', + nullif(p_job ->> 'entity_type', ''), + nullif(p_job ->> 'entity_id', ''), + nullif(p_job ->> 'idempotency_key', ''), + greatest(coalesce(nullif(p_job ->> 'attempt_count', '')::integer, 1), 1), + greatest(coalesce(nullif(p_job ->> 'max_attempts', '')::integer, 1), 1), + coalesce(nullif(p_job ->> 'scheduled_at', '')::timestamptz, v_now), + coalesce(nullif(p_job ->> 'started_at', '')::timestamptz, v_now), + null, + null, + p_job || jsonb_build_object('status', 'running', 'is_paid', true, 'reservation_id', p_reservation_id), + true, + coalesce(nullif(p_job ->> 'created_at', '')::timestamptz, v_now), + v_now + ) + on conflict (id) do update set + job_type = excluded.job_type, + status = excluded.status, + entity_type = excluded.entity_type, + entity_id = excluded.entity_id, + idempotency_key = excluded.idempotency_key, + attempt_count = excluded.attempt_count, + max_attempts = excluded.max_attempts, + scheduled_at = excluded.scheduled_at, + started_at = excluded.started_at, + finished_at = null, + error_json = null, + payload_json = excluded.payload_json, + is_paid = true, + updated_at = excluded.updated_at + where public.jobs.workspace_id = excluded.workspace_id; + + insert into public.paid_workflow_reservations ( + id, workspace_id, job_id, job_type, status, reserved_at, expires_at, payload_json + ) + values ( + p_reservation_id, + p_workspace_id, + v_job_id, + v_job_type, + 'running', + v_now, + v_now + make_interval(secs => v_stale_seconds), + jsonb_build_object('attempt_count', coalesce(nullif(p_job ->> 'attempt_count', '')::integer, 1)) + ); + + return jsonb_build_object( + 'job', p_job || jsonb_build_object('status', 'running', 'is_paid', true, 'reservation_id', p_reservation_id), + 'budget', jsonb_build_object( + 'running', v_running + 1, + 'max_concurrent', p_max_concurrent, + 'used_today', v_daily + 1, + 'daily_limit', p_daily_limit, + 'timezone', v_timezone + ) + ); +end; +$$; + +create or replace function public.finish_paid_workflow( + p_workspace_id uuid, + p_job jsonb, + p_reservation_id text +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job_id text := nullif(p_job ->> 'id', ''); + v_status text := coalesce(nullif(p_job ->> 'status', ''), 'failed'); + v_now timestamptz := now(); +begin + if v_job_id is null then + raise exception using message = 'paid_workflow_job_invalid', errcode = '22023'; + end if; + if v_status not in ('succeeded', 'failed', 'cancelled') then + raise exception using message = 'paid_workflow_terminal_status_invalid', errcode = '22023'; + end if; + + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_workspace_id::text)); + + update public.jobs + set + status = v_status, + finished_at = coalesce(nullif(p_job ->> 'finished_at', '')::timestamptz, v_now), + error_json = case + when p_job -> 'error' is null or jsonb_typeof(p_job -> 'error') = 'null' then null + else p_job -> 'error' + end, + payload_json = p_job, + updated_at = v_now + where workspace_id = p_workspace_id and id = v_job_id; + + if not found then + raise exception using message = 'paid workflow job was not found', errcode = 'P0002'; + end if; + + if nullif(p_reservation_id, '') is not null then + update public.paid_workflow_reservations + set status = v_status, released_at = v_now + where workspace_id = p_workspace_id + and id = p_reservation_id + and job_id = v_job_id + and status = 'running'; + end if; + + return p_job; +end; +$$; + +create or replace function public.get_paid_workflow_usage( + p_workspace_id uuid, + p_budget_timezone text +) +returns jsonb +language sql +security definer +set search_path = pg_catalog, public +stable +as $$ + select jsonb_build_object( + 'running', count(*) filter (where status = 'running' and expires_at > now()), + 'used_today', count(*) filter ( + where pg_catalog.timezone(coalesce(nullif(p_budget_timezone, ''), 'UTC'), reserved_at)::date + = pg_catalog.timezone(coalesce(nullif(p_budget_timezone, ''), 'UTC'), now())::date + ), + 'by_job_type', coalesce(( + select jsonb_object_agg(grouped.job_type, grouped.usage_count) + from ( + select job_type, count(*)::integer as usage_count + from public.paid_workflow_reservations + where workspace_id = p_workspace_id + and pg_catalog.timezone(coalesce(nullif(p_budget_timezone, ''), 'UTC'), reserved_at)::date + = pg_catalog.timezone(coalesce(nullif(p_budget_timezone, ''), 'UTC'), now())::date + group by job_type + ) as grouped + ), '{}'::jsonb) + ) + from public.paid_workflow_reservations + where workspace_id = p_workspace_id; +$$; + +revoke all on function public.reserve_paid_workflow(uuid, jsonb, text, integer, integer, text, integer) + from public, anon, authenticated; +revoke all on function public.finish_paid_workflow(uuid, jsonb, text) + from public, anon, authenticated; +revoke all on function public.get_paid_workflow_usage(uuid, text) + from public, anon, authenticated; +grant execute on function public.reserve_paid_workflow(uuid, jsonb, text, integer, integer, text, integer) + to service_role; +grant execute on function public.finish_paid_workflow(uuid, jsonb, text) + to service_role; +grant execute on function public.get_paid_workflow_usage(uuid, text) + to service_role; + +insert into public.schema_migrations(version, description) +values ('202607230001', 'Add atomic paid workflow concurrency and daily usage guard') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607230002_async_job_queue.sql b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607230002_async_job_queue.sql new file mode 100644 index 00000000..c52b027a --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607230002_async_job_queue.sql @@ -0,0 +1,710 @@ +begin; + +alter table public.jobs + add column if not exists stage text not null default 'queued', + add column if not exists progress smallint not null default 0, + add column if not exists worker_id text, + add column if not exists lease_expires_at timestamptz, + add column if not exists heartbeat_at timestamptz, + add column if not exists cancel_requested_at timestamptz; + +alter table public.jobs + drop constraint if exists jobs_progress_check; +alter table public.jobs + add constraint jobs_progress_check check (progress between 0 and 100); + +update public.jobs +set + stage = case status + when 'queued' then 'queued' + when 'running' then 'running' + when 'succeeded' then 'succeeded' + when 'failed' then 'failed' + when 'cancelled' then 'cancelled' + else stage + end, + progress = case when status = 'succeeded' then 100 else progress end +where stage = 'queued' or (status = 'succeeded' and progress <> 100); + +create index if not exists jobs_claim_queue_idx + on public.jobs(workspace_id, scheduled_at, created_at) + where status = 'queued'; + +create index if not exists jobs_running_lease_idx + on public.jobs(workspace_id, lease_expires_at) + where status = 'running'; + +create or replace function public.enqueue_sales_job( + p_workspace_id uuid, + p_job jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_job_id text := nullif(p_job ->> 'id', ''); + v_job_type text := nullif(p_job ->> 'job_type', ''); + v_idempotency_key text := nullif(p_job ->> 'idempotency_key', ''); + v_now timestamptz := now(); +begin + if v_job_id is null or v_job_type is null then + raise exception using message = 'sales_job_invalid', errcode = '22023'; + end if; + + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_workspace_id::text)); + + if v_idempotency_key is not null then + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and idempotency_key = v_idempotency_key + limit 1; + if found then + return to_jsonb(v_job); + end if; + end if; + + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = v_job_id + limit 1; + if found then + return to_jsonb(v_job); + end if; + + insert into public.jobs ( + id, workspace_id, job_type, status, entity_type, entity_id, idempotency_key, + attempt_count, max_attempts, scheduled_at, started_at, finished_at, + error_json, payload_json, is_paid, stage, progress, worker_id, + lease_expires_at, heartbeat_at, created_by, created_at, updated_at + ) + values ( + v_job_id, + p_workspace_id, + v_job_type, + 'queued', + nullif(p_job ->> 'entity_type', ''), + nullif(p_job ->> 'entity_id', ''), + v_idempotency_key, + 0, + greatest(coalesce(nullif(p_job ->> 'max_attempts', '')::integer, 3), 1), + coalesce(nullif(p_job ->> 'scheduled_at', '')::timestamptz, v_now), + null, + null, + null, + p_job || jsonb_build_object( + 'status', 'queued', + 'stage', 'queued', + 'progress', 0, + 'attempt_count', 0, + 'started_at', null, + 'finished_at', null, + 'error', null + ), + coalesce(nullif(p_job ->> 'is_paid', '')::boolean, true), + 'queued', + 0, + null, + null, + null, + nullif(p_job ->> 'created_by', '')::uuid, + coalesce(nullif(p_job ->> 'created_at', '')::timestamptz, v_now), + v_now + ) + returning * into v_job; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.claim_sales_job( + p_workspace_id uuid, + p_worker_id text, + p_job_types text[], + p_lease_seconds integer +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); + v_lease_seconds integer := greatest(coalesce(p_lease_seconds, 600), 60); +begin + if nullif(btrim(p_worker_id), '') is null then + raise exception using message = 'sales_job_worker_invalid', errcode = '22023'; + end if; + + -- A paid task may already have reached an external provider. Do not silently + -- replay it after a worker crash; fail it and require an explicit user retry. + update public.jobs as j + set + status = 'failed', + stage = 'failed', + finished_at = v_now, + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + error_json = jsonb_build_object( + 'code', 'worker_lease_expired', + 'message', '后台任务执行中断,请确认后重试。', + 'retryable', true + ), + payload_json = j.payload_json || jsonb_build_object( + 'status', 'failed', + 'stage', 'failed', + 'finished_at', v_now, + 'worker_id', null, + 'lease_expires_at', null, + 'error', jsonb_build_object( + 'code', 'worker_lease_expired', + 'message', '后台任务执行中断,请确认后重试。', + 'retryable', true + ) + ), + updated_at = v_now + where j.workspace_id = p_workspace_id + and j.status = 'running' + and j.lease_expires_at is not null + and j.lease_expires_at <= v_now + and exists ( + select 1 + from public.paid_workflow_reservations as r + where r.workspace_id = j.workspace_id + and r.job_id = j.id + and r.status = 'running' + ); + + update public.paid_workflow_reservations as r + set status = 'expired', released_at = v_now + where r.workspace_id = p_workspace_id + and r.status = 'running' + and exists ( + select 1 + from public.jobs as j + where j.workspace_id = r.workspace_id + and j.id = r.job_id + and j.status = 'failed' + and j.error_json ->> 'code' = 'worker_lease_expired' + ); + + -- A worker that died before reserving paid capacity is safe to retry. + update public.jobs as j + set + status = case when j.attempt_count < j.max_attempts then 'queued' else 'failed' end, + stage = case when j.attempt_count < j.max_attempts then 'queued' else 'failed' end, + progress = case when j.attempt_count < j.max_attempts then 0 else j.progress end, + scheduled_at = case when j.attempt_count < j.max_attempts then v_now else j.scheduled_at end, + finished_at = case when j.attempt_count < j.max_attempts then null else v_now end, + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + error_json = jsonb_build_object( + 'code', 'worker_lease_expired', + 'message', '后台任务执行中断。', + 'retryable', j.attempt_count < j.max_attempts + ), + payload_json = j.payload_json || jsonb_build_object( + 'status', case when j.attempt_count < j.max_attempts then 'queued' else 'failed' end, + 'stage', case when j.attempt_count < j.max_attempts then 'queued' else 'failed' end, + 'progress', case when j.attempt_count < j.max_attempts then 0 else j.progress end, + 'scheduled_at', case when j.attempt_count < j.max_attempts then v_now else j.scheduled_at end, + 'finished_at', case when j.attempt_count < j.max_attempts then null else v_now end, + 'worker_id', null, + 'lease_expires_at', null, + 'error', jsonb_build_object( + 'code', 'worker_lease_expired', + 'message', '后台任务执行中断。', + 'retryable', j.attempt_count < j.max_attempts + ) + ), + updated_at = v_now + where j.workspace_id = p_workspace_id + and j.status = 'running' + and j.lease_expires_at is not null + and j.lease_expires_at <= v_now; + + select * into v_job + from public.jobs + where workspace_id = p_workspace_id + and status = 'queued' + and coalesce(scheduled_at, created_at) <= v_now + and attempt_count < max_attempts + and (coalesce(array_length(p_job_types, 1), 0) = 0 or job_type = any(p_job_types)) + order by coalesce(scheduled_at, created_at), created_at, id + for update skip locked + limit 1; + + if not found then + return null; + end if; + + update public.jobs as j + set + status = 'running', + stage = 'starting', + progress = 1, + attempt_count = j.attempt_count + 1, + started_at = v_now, + finished_at = null, + error_json = null, + worker_id = left(p_worker_id, 160), + lease_expires_at = v_now + make_interval(secs => v_lease_seconds), + heartbeat_at = v_now, + payload_json = j.payload_json || jsonb_build_object( + 'status', 'running', + 'stage', 'starting', + 'progress', 1, + 'attempt_count', j.attempt_count + 1, + 'started_at', v_now, + 'finished_at', null, + 'error', null, + 'worker_id', left(p_worker_id, 160), + 'lease_expires_at', v_now + make_interval(secs => v_lease_seconds) + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = v_job.id + returning * into v_job; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.heartbeat_sales_job( + p_workspace_id uuid, + p_job_id text, + p_worker_id text, + p_stage text, + p_progress integer, + p_lease_seconds integer +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); + v_stage text := left(coalesce(nullif(btrim(p_stage), ''), 'running'), 80); + v_progress integer := greatest(1, least(coalesce(p_progress, 1), 99)); + v_lease_seconds integer := greatest(coalesce(p_lease_seconds, 600), 60); +begin + update public.jobs as j + set + stage = case when j.cancel_requested_at is not null then 'cancelling' else v_stage end, + progress = case when j.cancel_requested_at is not null then j.progress else v_progress end, + heartbeat_at = v_now, + lease_expires_at = v_now + make_interval(secs => v_lease_seconds), + payload_json = j.payload_json || jsonb_build_object( + 'stage', case when j.cancel_requested_at is not null then 'cancelling' else v_stage end, + 'progress', case when j.cancel_requested_at is not null then j.progress else v_progress end, + 'heartbeat_at', v_now, + 'lease_expires_at', v_now + make_interval(secs => v_lease_seconds) + ), + updated_at = v_now + where j.workspace_id = p_workspace_id + and j.id = p_job_id + and j.status = 'running' + and j.worker_id = p_worker_id + returning * into v_job; + + if not found then + raise exception using message = 'sales_job_claim_lost', errcode = 'P0001'; + end if; + + update public.paid_workflow_reservations + set expires_at = greatest(expires_at, v_now + make_interval(secs => v_lease_seconds)) + where workspace_id = p_workspace_id + and job_id = p_job_id + and status = 'running'; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.request_cancel_sales_job( + p_workspace_id uuid, + p_job_id text +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); +begin + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_workspace_id::text)); + + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = p_job_id + for update; + + if not found then + raise exception using message = 'sales_job_not_found', errcode = 'P0002'; + end if; + if v_job.status in ('succeeded', 'failed', 'cancelled') then + return to_jsonb(v_job); + end if; + + if v_job.status = 'queued' then + update public.jobs as j + set + status = 'cancelled', + stage = 'cancelled', + cancel_requested_at = v_now, + finished_at = v_now, + error_json = null, + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + payload_json = j.payload_json || jsonb_build_object( + 'status', 'cancelled', + 'stage', 'cancelled', + 'cancel_requested_at', v_now, + 'finished_at', v_now, + 'error', null, + 'worker_id', null, + 'lease_expires_at', null, + 'heartbeat_at', v_now + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + else + -- Running provider calls cannot be force-aborted safely. Keep the worker + -- lease and paid reservation until it reaches the next safe checkpoint. + update public.jobs as j + set + stage = 'cancelling', + cancel_requested_at = coalesce(j.cancel_requested_at, v_now), + payload_json = j.payload_json || jsonb_build_object( + 'stage', 'cancelling', + 'cancel_requested_at', coalesce(j.cancel_requested_at, v_now) + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + end if; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.acknowledge_cancel_sales_job( + p_workspace_id uuid, + p_job_id text, + p_worker_id text +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); +begin + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_workspace_id::text)); + + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = p_job_id + for update; + + if not found then + raise exception using message = 'sales_job_not_found', errcode = 'P0002'; + end if; + if v_job.status = 'cancelled' then + return to_jsonb(v_job); + end if; + if v_job.status <> 'running' or v_job.cancel_requested_at is null then + raise exception using message = 'sales_job_cancel_not_requested', errcode = 'P0001'; + end if; + if nullif(btrim(p_worker_id), '') is null or v_job.worker_id <> p_worker_id then + raise exception using message = 'sales_job_claim_lost', errcode = 'P0001'; + end if; + + update public.jobs as j + set + status = 'cancelled', + stage = 'cancelled', + finished_at = v_now, + error_json = null, + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + payload_json = j.payload_json || jsonb_build_object( + 'status', 'cancelled', + 'stage', 'cancelled', + 'finished_at', v_now, + 'error', null, + 'worker_id', null, + 'lease_expires_at', null, + 'heartbeat_at', v_now + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + + update public.paid_workflow_reservations + set status = 'cancelled', released_at = v_now + where workspace_id = p_workspace_id + and job_id = p_job_id + and status = 'running'; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.release_sales_job_claim( + p_workspace_id uuid, + p_job_id text, + p_worker_id text, + p_error jsonb, + p_retry boolean, + p_delay_seconds integer +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); + v_has_reservation boolean := false; + v_should_retry boolean := false; +begin + select exists ( + select 1 from public.paid_workflow_reservations + where workspace_id = p_workspace_id and job_id = p_job_id and status = 'running' + ) into v_has_reservation; + + select * into v_job + from public.jobs + where workspace_id = p_workspace_id + and id = p_job_id + and status = 'running' + and worker_id = p_worker_id + for update; + + if not found then + select * into v_job from public.jobs where workspace_id = p_workspace_id and id = p_job_id; + return case when found then to_jsonb(v_job) else null end; + end if; + + v_should_retry := coalesce(p_retry, false) + and not v_has_reservation + and v_job.attempt_count < v_job.max_attempts; + + update public.jobs as j + set + status = case when v_should_retry then 'queued' else 'failed' end, + stage = case when v_should_retry then 'queued' else 'failed' end, + progress = case when v_should_retry then 0 else j.progress end, + scheduled_at = case + when v_should_retry then v_now + make_interval(secs => greatest(coalesce(p_delay_seconds, 0), 0)) + else j.scheduled_at + end, + started_at = case when v_should_retry then null else j.started_at end, + finished_at = case when v_should_retry then null else v_now end, + error_json = coalesce(p_error, jsonb_build_object('code', 'worker_failed', 'message', '后台任务执行失败。')), + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + payload_json = j.payload_json || jsonb_build_object( + 'status', case when v_should_retry then 'queued' else 'failed' end, + 'stage', case when v_should_retry then 'queued' else 'failed' end, + 'progress', case when v_should_retry then 0 else j.progress end, + 'scheduled_at', case + when v_should_retry then v_now + make_interval(secs => greatest(coalesce(p_delay_seconds, 0), 0)) + else j.scheduled_at + end, + 'started_at', case when v_should_retry then null else j.started_at end, + 'finished_at', case when v_should_retry then null else v_now end, + 'error', coalesce(p_error, jsonb_build_object('code', 'worker_failed', 'message', '后台任务执行失败。')), + 'worker_id', null, + 'lease_expires_at', null + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + + if v_has_reservation then + update public.paid_workflow_reservations + set status = 'failed', released_at = v_now + where workspace_id = p_workspace_id and job_id = p_job_id and status = 'running'; + end if; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.retry_sales_job( + p_workspace_id uuid, + p_job_id text +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); +begin + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = p_job_id + for update; + + if not found then + raise exception using message = 'sales_job_not_found', errcode = 'P0002'; + end if; + if v_job.status not in ('failed', 'cancelled') then + raise exception using message = 'sales_job_not_retryable', errcode = 'P0001'; + end if; + if v_job.attempt_count >= v_job.max_attempts then + raise exception using message = 'sales_job_attempts_exhausted', errcode = 'P0001'; + end if; + + update public.jobs as j + set + status = 'queued', + stage = 'queued', + progress = 0, + scheduled_at = v_now, + started_at = null, + finished_at = null, + error_json = null, + worker_id = null, + lease_expires_at = null, + heartbeat_at = null, + cancel_requested_at = null, + payload_json = j.payload_json || jsonb_build_object( + 'status', 'queued', + 'stage', 'queued', + 'progress', 0, + 'scheduled_at', v_now, + 'started_at', null, + 'finished_at', null, + 'error', null, + 'worker_id', null, + 'lease_expires_at', null, + 'heartbeat_at', null, + 'cancel_requested_at', null, + 'reservation_id', null + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.finish_paid_workflow( + p_workspace_id uuid, + p_job jsonb, + p_reservation_id text +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job_id text := nullif(p_job ->> 'id', ''); + v_status text := coalesce(nullif(p_job ->> 'status', ''), 'failed'); + v_now timestamptz := now(); + v_job public.jobs%rowtype; +begin + if v_job_id is null then + raise exception using message = 'paid_workflow_job_invalid', errcode = '22023'; + end if; + if v_status not in ('succeeded', 'failed', 'cancelled') then + raise exception using message = 'paid_workflow_terminal_status_invalid', errcode = '22023'; + end if; + + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_workspace_id::text)); + + update public.jobs as j + set + status = v_status, + stage = v_status, + progress = case when v_status = 'succeeded' then 100 else j.progress end, + finished_at = coalesce(nullif(p_job ->> 'finished_at', '')::timestamptz, v_now), + error_json = case + when p_job -> 'error' is null or jsonb_typeof(p_job -> 'error') = 'null' then null + else p_job -> 'error' + end, + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + cancel_requested_at = case when v_status = 'succeeded' then null else j.cancel_requested_at end, + payload_json = p_job || jsonb_build_object( + 'status', v_status, + 'stage', v_status, + 'progress', case when v_status = 'succeeded' then 100 else j.progress end, + 'worker_id', null, + 'lease_expires_at', null, + 'heartbeat_at', v_now, + 'cancel_requested_at', case when v_status = 'succeeded' then null else j.cancel_requested_at end + ), + updated_at = v_now + where j.workspace_id = p_workspace_id + and j.id = v_job_id + and j.status = 'running' + returning * into v_job; + + if not found then + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = v_job_id; + if found and v_job.status in ('succeeded', 'failed', 'cancelled') then + return to_jsonb(v_job); + end if; + raise exception using message = 'paid workflow job was not found', errcode = 'P0002'; + end if; + + if nullif(p_reservation_id, '') is not null then + update public.paid_workflow_reservations + set status = v_status, released_at = v_now + where workspace_id = p_workspace_id + and id = p_reservation_id + and job_id = v_job_id + and status = 'running'; + end if; + + return to_jsonb(v_job); +end; +$$; + +revoke all on function public.enqueue_sales_job(uuid, jsonb) from public, anon, authenticated; +revoke all on function public.claim_sales_job(uuid, text, text[], integer) from public, anon, authenticated; +revoke all on function public.heartbeat_sales_job(uuid, text, text, text, integer, integer) from public, anon, authenticated; +revoke all on function public.release_sales_job_claim(uuid, text, text, jsonb, boolean, integer) from public, anon, authenticated; +revoke all on function public.request_cancel_sales_job(uuid, text) from public, anon, authenticated; +revoke all on function public.acknowledge_cancel_sales_job(uuid, text, text) from public, anon, authenticated; +revoke all on function public.retry_sales_job(uuid, text) from public, anon, authenticated; +grant execute on function public.enqueue_sales_job(uuid, jsonb) to service_role; +grant execute on function public.claim_sales_job(uuid, text, text[], integer) to service_role; +grant execute on function public.heartbeat_sales_job(uuid, text, text, text, integer, integer) to service_role; +grant execute on function public.release_sales_job_claim(uuid, text, text, jsonb, boolean, integer) to service_role; +grant execute on function public.request_cancel_sales_job(uuid, text) to service_role; +grant execute on function public.acknowledge_cancel_sales_job(uuid, text, text) to service_role; +grant execute on function public.retry_sales_job(uuid, text) to service_role; + +insert into public.schema_migrations(version, description) +values ('202607230002', 'Add persistent asynchronous sales job queue and worker leases') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607230003_safe_job_cancellation.sql b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607230003_safe_job_cancellation.sql new file mode 100644 index 00000000..dbc4da31 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607230003_safe_job_cancellation.sql @@ -0,0 +1,343 @@ +begin; + +alter table public.jobs + add column if not exists cancel_requested_at timestamptz; + +create or replace function public.heartbeat_sales_job( + p_workspace_id uuid, + p_job_id text, + p_worker_id text, + p_stage text, + p_progress integer, + p_lease_seconds integer +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); + v_stage text := left(coalesce(nullif(btrim(p_stage), ''), 'running'), 80); + v_progress integer := greatest(1, least(coalesce(p_progress, 1), 99)); + v_lease_seconds integer := greatest(coalesce(p_lease_seconds, 600), 60); +begin + update public.jobs as j + set + stage = case when j.cancel_requested_at is not null then 'cancelling' else v_stage end, + progress = case when j.cancel_requested_at is not null then j.progress else v_progress end, + heartbeat_at = v_now, + lease_expires_at = v_now + make_interval(secs => v_lease_seconds), + payload_json = j.payload_json || jsonb_build_object( + 'stage', case when j.cancel_requested_at is not null then 'cancelling' else v_stage end, + 'progress', case when j.cancel_requested_at is not null then j.progress else v_progress end, + 'heartbeat_at', v_now, + 'lease_expires_at', v_now + make_interval(secs => v_lease_seconds) + ), + updated_at = v_now + where j.workspace_id = p_workspace_id + and j.id = p_job_id + and j.status = 'running' + and j.worker_id = p_worker_id + returning * into v_job; + + if not found then + raise exception using message = 'sales_job_claim_lost', errcode = 'P0001'; + end if; + + update public.paid_workflow_reservations + set expires_at = greatest(expires_at, v_now + make_interval(secs => v_lease_seconds)) + where workspace_id = p_workspace_id + and job_id = p_job_id + and status = 'running'; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.request_cancel_sales_job( + p_workspace_id uuid, + p_job_id text +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); +begin + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_workspace_id::text)); + + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = p_job_id + for update; + + if not found then + raise exception using message = 'sales_job_not_found', errcode = 'P0002'; + end if; + if v_job.status in ('succeeded', 'failed', 'cancelled') then + return to_jsonb(v_job); + end if; + + if v_job.status = 'queued' then + update public.jobs as j + set + status = 'cancelled', + stage = 'cancelled', + cancel_requested_at = v_now, + finished_at = v_now, + error_json = null, + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + payload_json = j.payload_json || jsonb_build_object( + 'status', 'cancelled', + 'stage', 'cancelled', + 'cancel_requested_at', v_now, + 'finished_at', v_now, + 'error', null, + 'worker_id', null, + 'lease_expires_at', null, + 'heartbeat_at', v_now + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + else + update public.jobs as j + set + stage = 'cancelling', + cancel_requested_at = coalesce(j.cancel_requested_at, v_now), + payload_json = j.payload_json || jsonb_build_object( + 'stage', 'cancelling', + 'cancel_requested_at', coalesce(j.cancel_requested_at, v_now) + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + end if; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.acknowledge_cancel_sales_job( + p_workspace_id uuid, + p_job_id text, + p_worker_id text +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); +begin + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_workspace_id::text)); + + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = p_job_id + for update; + + if not found then + raise exception using message = 'sales_job_not_found', errcode = 'P0002'; + end if; + if v_job.status = 'cancelled' then + return to_jsonb(v_job); + end if; + if v_job.status <> 'running' or v_job.cancel_requested_at is null then + raise exception using message = 'sales_job_cancel_not_requested', errcode = 'P0001'; + end if; + if nullif(btrim(p_worker_id), '') is null or v_job.worker_id <> p_worker_id then + raise exception using message = 'sales_job_claim_lost', errcode = 'P0001'; + end if; + + update public.jobs as j + set + status = 'cancelled', + stage = 'cancelled', + finished_at = v_now, + error_json = null, + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + payload_json = j.payload_json || jsonb_build_object( + 'status', 'cancelled', + 'stage', 'cancelled', + 'finished_at', v_now, + 'error', null, + 'worker_id', null, + 'lease_expires_at', null, + 'heartbeat_at', v_now + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + + update public.paid_workflow_reservations + set status = 'cancelled', released_at = v_now + where workspace_id = p_workspace_id + and job_id = p_job_id + and status = 'running'; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.retry_sales_job( + p_workspace_id uuid, + p_job_id text +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); +begin + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = p_job_id + for update; + + if not found then + raise exception using message = 'sales_job_not_found', errcode = 'P0002'; + end if; + if v_job.status not in ('failed', 'cancelled') then + raise exception using message = 'sales_job_not_retryable', errcode = 'P0001'; + end if; + if v_job.attempt_count >= v_job.max_attempts then + raise exception using message = 'sales_job_attempts_exhausted', errcode = 'P0001'; + end if; + + update public.jobs as j + set + status = 'queued', + stage = 'queued', + progress = 0, + scheduled_at = v_now, + started_at = null, + finished_at = null, + error_json = null, + worker_id = null, + lease_expires_at = null, + heartbeat_at = null, + cancel_requested_at = null, + payload_json = j.payload_json || jsonb_build_object( + 'status', 'queued', + 'stage', 'queued', + 'progress', 0, + 'scheduled_at', v_now, + 'started_at', null, + 'finished_at', null, + 'error', null, + 'worker_id', null, + 'lease_expires_at', null, + 'heartbeat_at', null, + 'cancel_requested_at', null, + 'reservation_id', null + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.finish_paid_workflow( + p_workspace_id uuid, + p_job jsonb, + p_reservation_id text +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job_id text := nullif(p_job ->> 'id', ''); + v_status text := coalesce(nullif(p_job ->> 'status', ''), 'failed'); + v_now timestamptz := now(); + v_job public.jobs%rowtype; +begin + if v_job_id is null then + raise exception using message = 'paid_workflow_job_invalid', errcode = '22023'; + end if; + if v_status not in ('succeeded', 'failed', 'cancelled') then + raise exception using message = 'paid_workflow_terminal_status_invalid', errcode = '22023'; + end if; + + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_workspace_id::text)); + + update public.jobs as j + set + status = v_status, + stage = v_status, + progress = case when v_status = 'succeeded' then 100 else j.progress end, + finished_at = coalesce(nullif(p_job ->> 'finished_at', '')::timestamptz, v_now), + error_json = case + when p_job -> 'error' is null or jsonb_typeof(p_job -> 'error') = 'null' then null + else p_job -> 'error' + end, + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + cancel_requested_at = case when v_status = 'succeeded' then null else j.cancel_requested_at end, + payload_json = p_job || jsonb_build_object( + 'status', v_status, + 'stage', v_status, + 'progress', case when v_status = 'succeeded' then 100 else j.progress end, + 'worker_id', null, + 'lease_expires_at', null, + 'heartbeat_at', v_now, + 'cancel_requested_at', case when v_status = 'succeeded' then null else j.cancel_requested_at end + ), + updated_at = v_now + where j.workspace_id = p_workspace_id + and j.id = v_job_id + and j.status = 'running' + returning * into v_job; + + if not found then + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = v_job_id; + if found and v_job.status in ('succeeded', 'failed', 'cancelled') then + return to_jsonb(v_job); + end if; + raise exception using message = 'paid workflow job was not found', errcode = 'P0002'; + end if; + + if nullif(p_reservation_id, '') is not null then + update public.paid_workflow_reservations + set status = v_status, released_at = v_now + where workspace_id = p_workspace_id + and id = p_reservation_id + and job_id = v_job_id + and status = 'running'; + end if; + + return to_jsonb(v_job); +end; +$$; + +revoke all on function public.request_cancel_sales_job(uuid, text) from public, anon, authenticated; +revoke all on function public.acknowledge_cancel_sales_job(uuid, text, text) from public, anon, authenticated; +grant execute on function public.request_cancel_sales_job(uuid, text) to service_role; +grant execute on function public.acknowledge_cancel_sales_job(uuid, text, text) to service_role; + +insert into public.schema_migrations(version, description) +values ('202607230003', 'Add safe cancellation checkpoints for asynchronous paid jobs') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607280001_openviking_qa_boundary.sql b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607280001_openviking_qa_boundary.sql new file mode 100644 index 00000000..1dd594b0 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607280001_openviking_qa_boundary.sql @@ -0,0 +1,27 @@ +begin; + +do $$ +begin + if to_regclass('public.sales_qa_messages') is not null + and to_regclass('public.sales_qa_messages_legacy') is null then + alter table public.sales_qa_messages rename to sales_qa_messages_legacy; + end if; +end +$$; + +do $$ +begin + if to_regclass('public.sales_qa_messages_legacy') is not null then + revoke all on table public.sales_qa_messages_legacy from public, anon, authenticated; + grant all on table public.sales_qa_messages_legacy to service_role; + comment on table public.sales_qa_messages_legacy is + 'Read-only migration archive. Current QA content is stored and restored by OpenViking.'; + end if; +end +$$; + +insert into public.schema_migrations(version, description) +values ('202607280001', 'Quarantine legacy QA message rows and make OpenViking the sole QA content store') +on conflict(version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607280002_secure_internal_tables.sql b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607280002_secure_internal_tables.sql new file mode 100644 index 00000000..4f9053b7 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607280002_secure_internal_tables.sql @@ -0,0 +1,11 @@ +begin; + +alter table public.schema_migrations enable row level security; +revoke all on table public.schema_migrations from public, anon, authenticated; +grant all on table public.schema_migrations to service_role; + +insert into public.schema_migrations(version, description) +values ('202607280002', 'Enable RLS and restrict the project migration table to the service role') +on conflict(version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607290001_reconcile_terminal_job_provider_runs.sql b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607290001_reconcile_terminal_job_provider_runs.sql new file mode 100644 index 00000000..3b282aac --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607290001_reconcile_terminal_job_provider_runs.sql @@ -0,0 +1,106 @@ +begin; + +create or replace function public.reconcile_terminal_job_provider_runs() +returns trigger +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_finished_at timestamptz := coalesce(new.finished_at, now()); + v_status text; + v_error jsonb; +begin + if new.status not in ('failed', 'cancelled') then + return new; + end if; + + v_status := case when new.status = 'cancelled' then 'cancelled' else 'failed' end; + v_error := case + when v_status = 'cancelled' then null + else jsonb_build_object( + 'code', coalesce(nullif(new.error_json ->> 'code', ''), 'job_terminated'), + 'message', '任务执行已终止,未继续等待上游返回。', + 'category', 'workflow', + 'retryable', lower(coalesce(new.error_json ->> 'retryable', 'false')) in ('1', 'true', 'yes', 'on') + ) + end; + + update public.provider_run_steps as s + set + status = v_status, + output_summary = case + when v_status = 'cancelled' then '任务已取消,未继续等待上游返回。' + else '任务执行已终止,未继续等待上游返回。' + end, + finished_at = v_finished_at, + latency_ms = least( + 2147483647, + greatest(0, floor(extract(epoch from (v_finished_at - s.started_at)) * 1000)) + )::integer, + error_json = v_error, + updated_at = v_finished_at + where s.workspace_id = new.workspace_id + and s.status = 'running' + and exists ( + select 1 + from public.provider_runs as r + where r.workspace_id = s.workspace_id + and r.id = s.provider_run_id + and r.job_id = new.id + and r.status = 'running' + ); + + update public.provider_runs as r + set + status = v_status, + finished_at = v_finished_at, + duration_ms = least( + 2147483647, + greatest(0, floor(extract(epoch from (v_finished_at - r.started_at)) * 1000)) + )::integer, + error_json = v_error, + payload_json = r.payload_json || jsonb_build_object( + 'status', v_status, + 'finished_at', v_finished_at, + 'duration_ms', least( + 2147483647, + greatest(0, floor(extract(epoch from (v_finished_at - r.started_at)) * 1000)) + )::integer, + 'error', v_error + ), + updated_at = v_finished_at + where r.workspace_id = new.workspace_id + and r.job_id = new.id + and r.status = 'running'; + + return new; +end; +$$; + +revoke all on function public.reconcile_terminal_job_provider_runs() from public, anon, authenticated; +grant execute on function public.reconcile_terminal_job_provider_runs() to service_role; + +drop trigger if exists reconcile_terminal_job_provider_runs_after_update on public.jobs; +create trigger reconcile_terminal_job_provider_runs_after_update +after update of status, error_json on public.jobs +for each row +execute function public.reconcile_terminal_job_provider_runs(); + +-- Reconcile runs that were orphaned before this trigger was installed. +update public.jobs as j +set status = j.status +where j.status in ('failed', 'cancelled') + and exists ( + select 1 + from public.provider_runs as r + where r.workspace_id = j.workspace_id + and r.job_id = j.id + and r.status = 'running' + ); + +insert into public.schema_migrations(version, description) +values ('202607290001', 'Reconcile running provider traces when their worker job terminates') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607300001_durable_job_checkpoints.sql b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607300001_durable_job_checkpoints.sql new file mode 100644 index 00000000..64763e21 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/migrations/202607300001_durable_job_checkpoints.sql @@ -0,0 +1,222 @@ +begin; + +alter table public.jobs + add column if not exists checkpoint_json jsonb not null default '{}'::jsonb, + add column if not exists progress_detail_json jsonb not null default '{}'::jsonb; + +alter table public.jobs + drop constraint if exists jobs_checkpoint_json_object_check; +alter table public.jobs + add constraint jobs_checkpoint_json_object_check + check (jsonb_typeof(checkpoint_json) = 'object'); + +alter table public.jobs + drop constraint if exists jobs_progress_detail_json_object_check; +alter table public.jobs + add constraint jobs_progress_detail_json_object_check + check (jsonb_typeof(progress_detail_json) = 'object'); + +update public.jobs +set + checkpoint_json = case + when jsonb_typeof(payload_json -> 'checkpoint') = 'object' + then payload_json -> 'checkpoint' + else checkpoint_json + end, + progress_detail_json = case + when jsonb_typeof(payload_json -> 'progress_detail') = 'object' + then payload_json -> 'progress_detail' + else progress_detail_json + end +where payload_json ? 'checkpoint' or payload_json ? 'progress_detail'; + +create or replace function public.checkpoint_sales_job( + p_workspace_id uuid, + p_job_id text, + p_worker_id text, + p_stage text, + p_progress integer, + p_progress_detail jsonb, + p_checkpoint_patch jsonb, + p_lease_seconds integer +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); + v_stage text := left(coalesce(nullif(btrim(p_stage), ''), 'running'), 80); + v_progress integer := greatest(1, least(coalesce(p_progress, 1), 99)); + v_detail jsonb := coalesce(p_progress_detail, '{}'::jsonb); + v_patch jsonb := coalesce(p_checkpoint_patch, '{}'::jsonb); + v_lease_seconds integer := greatest(coalesce(p_lease_seconds, 600), 60); +begin + if jsonb_typeof(v_detail) <> 'object' or jsonb_typeof(v_patch) <> 'object' then + raise exception using message = 'sales_job_checkpoint_invalid', errcode = '22023'; + end if; + if pg_catalog.octet_length(v_patch::text) > 524288 then + raise exception using message = 'sales_job_checkpoint_too_large', errcode = '22023'; + end if; + + update public.jobs as j + set + stage = case when j.cancel_requested_at is not null then 'cancelling' else v_stage end, + progress = case when j.cancel_requested_at is not null then j.progress else v_progress end, + progress_detail_json = case + when j.cancel_requested_at is not null + then jsonb_build_object('message', '正在安全取消任务') + else v_detail + end, + checkpoint_json = j.checkpoint_json || v_patch, + heartbeat_at = v_now, + lease_expires_at = v_now + make_interval(secs => v_lease_seconds), + payload_json = j.payload_json || jsonb_build_object( + 'stage', case when j.cancel_requested_at is not null then 'cancelling' else v_stage end, + 'progress', case when j.cancel_requested_at is not null then j.progress else v_progress end, + 'progress_detail', case + when j.cancel_requested_at is not null + then jsonb_build_object('message', '正在安全取消任务') + else v_detail + end, + 'checkpoint', j.checkpoint_json || v_patch, + 'heartbeat_at', v_now, + 'lease_expires_at', v_now + make_interval(secs => v_lease_seconds) + ), + updated_at = v_now + where j.workspace_id = p_workspace_id + and j.id = p_job_id + and j.status = 'running' + and j.worker_id = p_worker_id + returning * into v_job; + + if not found then + raise exception using message = 'sales_job_claim_lost', errcode = 'P0001'; + end if; + + update public.paid_workflow_reservations + set expires_at = greatest(expires_at, v_now + make_interval(secs => v_lease_seconds)) + where workspace_id = p_workspace_id + and job_id = p_job_id + and status = 'running'; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.release_sales_job_claim( + p_workspace_id uuid, + p_job_id text, + p_worker_id text, + p_error jsonb, + p_retry boolean, + p_delay_seconds integer +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); + v_next_retry_at timestamptz; + v_should_retry boolean := false; +begin + select * into v_job + from public.jobs + where workspace_id = p_workspace_id + and id = p_job_id + and status = 'running' + and worker_id = p_worker_id + for update; + + if not found then + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = p_job_id; + return case when found then to_jsonb(v_job) else null end; + end if; + + v_should_retry := coalesce(p_retry, false) + and v_job.attempt_count < v_job.max_attempts; + v_next_retry_at := v_now + + make_interval(secs => greatest(coalesce(p_delay_seconds, 0), 0)); + + update public.jobs as j + set + status = case when v_should_retry then 'queued' else 'failed' end, + stage = case when v_should_retry then 'retry_wait' else 'failed' end, + progress = j.progress, + progress_detail_json = case + when v_should_retry then jsonb_build_object( + 'message', '上游服务暂时不可用,正在自动重试', + 'next_retry_at', v_next_retry_at + ) + else '{}'::jsonb + end, + scheduled_at = case when v_should_retry then v_next_retry_at else j.scheduled_at end, + started_at = case when v_should_retry then null else j.started_at end, + finished_at = case when v_should_retry then null else v_now end, + error_json = coalesce( + p_error, + jsonb_build_object('code', 'worker_failed', 'message', '后台任务执行失败。') + ), + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + payload_json = j.payload_json || jsonb_build_object( + 'status', case when v_should_retry then 'queued' else 'failed' end, + 'stage', case when v_should_retry then 'retry_wait' else 'failed' end, + 'progress', j.progress, + 'progress_detail', case + when v_should_retry then jsonb_build_object( + 'message', '上游服务暂时不可用,正在自动重试', + 'next_retry_at', v_next_retry_at + ) + else '{}'::jsonb + end, + 'scheduled_at', case when v_should_retry then v_next_retry_at else j.scheduled_at end, + 'started_at', case when v_should_retry then null else j.started_at end, + 'finished_at', case when v_should_retry then null else v_now end, + 'error', coalesce( + p_error, + jsonb_build_object('code', 'worker_failed', 'message', '后台任务执行失败。') + ), + 'worker_id', null, + 'lease_expires_at', null + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + + update public.paid_workflow_reservations + set + status = 'failed', + released_at = v_now, + payload_json = payload_json || jsonb_build_object( + 'release_reason', + case when v_should_retry then 'retryable_worker_failure' else 'worker_failure' end + ) + where workspace_id = p_workspace_id + and job_id = p_job_id + and status = 'running'; + + return to_jsonb(v_job); +end; +$$; + +revoke all on function public.checkpoint_sales_job( + uuid, text, text, text, integer, jsonb, jsonb, integer +) from public, anon, authenticated; +grant execute on function public.checkpoint_sales_job( + uuid, text, text, text, integer, jsonb, jsonb, integer +) to service_role; + +insert into public.schema_migrations(version, description) +values ('202607300001', 'Add durable job checkpoints and retryable paid-stage recovery') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/tests/202607210008_stage4_evidence_smoke.sql b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/tests/202607210008_stage4_evidence_smoke.sql new file mode 100644 index 00000000..c22b050b --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/tests/202607210008_stage4_evidence_smoke.sql @@ -0,0 +1,214 @@ +begin; + +do $$ +declare + v_workspace_id uuid; + v_company_id text := '__stage4_smoke_company__'; + v_job_id text := '__stage4_smoke_job__'; + v_run_id text := '__stage4_smoke_run__'; + v_step_id text := '__stage4_smoke_step__'; + v_dossier_v1_id text := '__stage4_smoke_dossier_v1__'; + v_dossier_v2_id text := '__stage4_smoke_dossier_v2__'; + v_text text; + v_integer integer; +begin + select id + into v_workspace_id + from public.app_workspaces + order by created_at asc + limit 1; + + if v_workspace_id is null then + raise exception 'stage4 smoke requires one application workspace'; + end if; + + insert into public.sales_companies ( + id, workspace_id, name, initial, industry, location, tags, payload_json + ) + values ( + v_company_id, + v_workspace_id, + '__Stage4 Smoke Company__', + 'S', + 'smoke-test', + 'test-only', + '["stage4-smoke"]'::jsonb, + '{"test_only":true}'::jsonb + ); + + insert into public.jobs ( + id, workspace_id, job_type, status, entity_type, entity_id, + idempotency_key, attempt_count, max_attempts, scheduled_at, + started_at, finished_at, payload_json + ) + values ( + v_job_id, + v_workspace_id, + 'stage4_smoke', + 'succeeded', + 'company', + v_company_id, + v_job_id, + 1, + 1, + now(), + now() - interval '1 second', + now(), + '{"test_only":true}'::jsonb + ); + + perform public.persist_provider_run( + v_workspace_id, + jsonb_build_object( + 'id', v_run_id, + 'job_id', v_job_id, + 'operation', 'stage4_smoke', + 'status', 'succeeded', + 'app_mode', 'development', + 'entity_type', 'company', + 'entity_id', v_company_id, + 'started_at', now() - interval '1 second', + 'finished_at', now(), + 'duration_ms', 1000, + 'steps', jsonb_build_array( + jsonb_build_object( + 'id', v_step_id, + 'sequence', 1, + 'provider', 'ark', + 'operation', 'structured_generation', + 'status', 'succeeded', + 'input_summary', 'stage4 smoke input', + 'output_summary', 'stage4 smoke output', + 'request_id', 'stage4-smoke-request', + 'usage', jsonb_build_object( + 'prompt_tokens', 10, + 'completion_tokens', 5, + 'total_tokens', 15 + ), + 'attempts', 1, + 'started_at', now() - interval '500 milliseconds', + 'finished_at', now(), + 'latency_ms', 500 + ) + ) + ) + ); + + select job_id + into v_text + from public.provider_runs + where workspace_id = v_workspace_id and id = v_run_id; + + if v_text is distinct from v_job_id then + raise exception 'provider run job binding mismatch: %', v_text; + end if; + + select (usage_json ->> 'total_tokens')::integer + into v_integer + from public.provider_run_steps + where workspace_id = v_workspace_id and id = v_step_id; + + if v_integer is distinct from 15 then + raise exception 'provider run token usage mismatch: %', v_integer; + end if; + + perform public.persist_sales_dossier( + v_workspace_id, + jsonb_build_object( + 'id', v_dossier_v1_id, + 'company_id', v_company_id, + 'title', 'Stage 4 smoke dossier v1', + 'summary', 'Initial evidence-backed dossier.', + 'memory_summary', 'Initial memory summary.', + 'status', 'completed', + 'provider_run_id', v_run_id, + 'version_no', 1, + 'evidence_hash', 'stage4-smoke-evidence-v1', + 'dossier_fingerprint', 'stage4-smoke-fingerprint-v1', + 'change_status', 'initial', + 'data_as_of', now() - interval '1 day', + 'generated_at', now(), + 'evidence_pack', jsonb_build_array( + jsonb_build_object( + 'id', 'evidence-professional-1', + 'source_kind', 'professional_dataset', + 'label', 'Stage 4 smoke professional evidence', + 'summary', 'Version one evidence.' + ) + ), + 'citations', jsonb_build_array( + jsonb_build_object( + 'id', 'evidence-professional-1', + 'source_kind', 'professional_dataset', + 'label', 'Stage 4 smoke professional evidence', + 'url', 'https://example.invalid/stage4-smoke/v1' + ) + ) + ) + ); + + perform public.persist_sales_dossier( + v_workspace_id, + jsonb_build_object( + 'id', v_dossier_v2_id, + 'company_id', v_company_id, + 'title', 'Stage 4 smoke dossier v2', + 'summary', 'Changed evidence-backed dossier.', + 'memory_summary', 'Changed memory summary.', + 'status', 'completed', + 'provider_run_id', v_run_id, + 'version_no', 2, + 'previous_dossier_id', v_dossier_v1_id, + 'evidence_hash', 'stage4-smoke-evidence-v2', + 'dossier_fingerprint', 'stage4-smoke-fingerprint-v2', + 'change_status', 'changed', + 'data_as_of', now(), + 'generated_at', now(), + 'evidence_pack', jsonb_build_array( + jsonb_build_object( + 'id', 'evidence-professional-2', + 'source_kind', 'professional_dataset', + 'label', 'Stage 4 smoke changed evidence', + 'summary', 'Version two evidence.' + ) + ), + 'citations', jsonb_build_array( + jsonb_build_object( + 'id', 'evidence-professional-2', + 'source_kind', 'professional_dataset', + 'label', 'Stage 4 smoke changed evidence', + 'url', 'https://example.invalid/stage4-smoke/v2' + ) + ) + ) + ); + + select previous_dossier_id + into v_text + from public.sales_dossier_records + where workspace_id = v_workspace_id + and id = v_dossier_v2_id + and version_no = 2 + and change_status = 'changed' + and evidence_hash = 'stage4-smoke-evidence-v2' + and jsonb_array_length(evidence_pack_json) = 1; + + if v_text is distinct from v_dossier_v1_id then + raise exception 'dossier version chain mismatch: %', v_text; + end if; + + select count(*)::integer + into v_integer + from public.sales_dossier_citations + where workspace_id = v_workspace_id + and dossier_id in (v_dossier_v1_id, v_dossier_v2_id); + + if v_integer is distinct from 2 then + raise exception 'dossier citation count mismatch: %', v_integer; + end if; +end; +$$; + +select 'stage4_evidence_smoke_passed' as result; + +rollback; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/tests/202607230002_paid_workflow_guard_smoke.sql b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/tests/202607230002_paid_workflow_guard_smoke.sql new file mode 100644 index 00000000..733aec86 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/tests/202607230002_paid_workflow_guard_smoke.sql @@ -0,0 +1,88 @@ +begin; + +do $paid_workflow_guard_smoke$ +declare + v_workspace_id uuid; + v_suffix text := pg_catalog.txid_current()::text; + v_job_id text := 'smoke_paid_job_' || v_suffix; + v_reservation_id text := 'smoke_paid_reservation_' || v_suffix; + v_job jsonb; + v_reserved jsonb; + v_finished jsonb; + v_status text; +begin + select id into v_workspace_id + from public.app_workspaces + order by created_at + limit 1; + + if v_workspace_id is null then + raise exception 'paid workflow smoke requires one application workspace'; + end if; + + v_job := jsonb_build_object( + 'id', v_job_id, + 'job_type', 'paid_workflow_guard_smoke', + 'status', 'running', + 'entity_type', 'smoke', + 'entity_id', v_suffix, + 'attempt_count', 1, + 'max_attempts', 1, + 'created_at', now(), + 'started_at', now(), + 'updated_at', now(), + 'is_paid', true + ); + + v_reserved := public.reserve_paid_workflow( + v_workspace_id, + v_job, + v_reservation_id, + 2147483647, + 2147483647, + 'Asia/Shanghai', + 300 + ); + + if v_reserved #>> '{job,status}' <> 'running' + or v_reserved #>> '{job,reservation_id}' <> v_reservation_id + then + raise exception 'paid workflow reservation returned an invalid payload'; + end if; + + select status into v_status + from public.paid_workflow_reservations + where workspace_id = v_workspace_id and id = v_reservation_id; + if v_status <> 'running' then + raise exception 'paid workflow reservation was not persisted as running'; + end if; + + v_job := v_job || jsonb_build_object( + 'status', 'succeeded', + 'reservation_id', v_reservation_id, + 'finished_at', now(), + 'updated_at', now() + ); + v_finished := public.finish_paid_workflow(v_workspace_id, v_job, v_reservation_id); + + if v_finished ->> 'status' <> 'succeeded' then + raise exception 'paid workflow finish returned an invalid payload'; + end if; + + select status into v_status + from public.paid_workflow_reservations + where workspace_id = v_workspace_id and id = v_reservation_id; + if v_status <> 'succeeded' then + raise exception 'paid workflow reservation was not released'; + end if; + + select status into v_status + from public.jobs + where workspace_id = v_workspace_id and id = v_job_id; + if v_status <> 'succeeded' then + raise exception 'paid workflow job was not completed'; + end if; +end; +$paid_workflow_guard_smoke$; + +rollback; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/tests/202607230003_async_job_queue_smoke.sql b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/tests/202607230003_async_job_queue_smoke.sql new file mode 100644 index 00000000..9e4eb5c1 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/supabase/tests/202607230003_async_job_queue_smoke.sql @@ -0,0 +1,198 @@ +begin; + +do $async_job_queue_smoke$ +declare + v_workspace_id uuid; + v_suffix text := pg_catalog.txid_current()::text; + v_job_id text := 'smoke_async_job_' || v_suffix; + v_reservation_id text := 'smoke_async_reservation_' || v_suffix; + v_worker_id text := 'smoke-worker-' || v_suffix; + v_cancel_job_id text := 'smoke_async_cancel_job_' || v_suffix; + v_cancel_reservation_id text := 'smoke_async_cancel_reservation_' || v_suffix; + v_job jsonb; + v_result jsonb; + v_status text; +begin + select id into v_workspace_id + from public.app_workspaces + order by created_at + limit 1; + + if v_workspace_id is null then + raise exception 'async job queue smoke requires one application workspace'; + end if; + + v_job := jsonb_build_object( + 'id', v_job_id, + 'job_type', 'async_job_queue_smoke', + 'status', 'queued', + 'stage', 'queued', + 'progress', 0, + 'entity_type', 'smoke', + 'entity_id', v_suffix, + 'idempotency_key', 'async-job-smoke-' || v_suffix, + 'attempt_count', 0, + 'max_attempts', 3, + 'is_paid', true, + 'created_at', now(), + 'updated_at', now() + ); + + v_result := public.enqueue_sales_job(v_workspace_id, v_job); + if v_result ->> 'status' <> 'queued' or (v_result ->> 'attempt_count')::integer <> 0 then + raise exception 'async job was not queued correctly'; + end if; + + v_result := public.claim_sales_job( + v_workspace_id, + v_worker_id, + array['async_job_queue_smoke']::text[], + 120 + ); + if v_result ->> 'status' <> 'running' + or v_result ->> 'worker_id' <> v_worker_id + or (v_result ->> 'attempt_count')::integer <> 1 + then + raise exception 'async job was not claimed correctly'; + end if; + + v_result := public.heartbeat_sales_job( + v_workspace_id, + v_job_id, + v_worker_id, + 'validating_evidence', + 50, + 120 + ); + if v_result ->> 'stage' <> 'validating_evidence' or (v_result ->> 'progress')::integer <> 50 then + raise exception 'async job heartbeat was not persisted'; + end if; + + v_result := public.release_sales_job_claim( + v_workspace_id, + v_job_id, + v_worker_id, + jsonb_build_object('code', 'smoke_retry', 'message', 'retry safely before reservation'), + true, + 0 + ); + if v_result ->> 'status' <> 'queued' or v_result ->> 'worker_id' is not null then + raise exception 'unreserved async job was not safely requeued'; + end if; + + v_result := public.claim_sales_job( + v_workspace_id, + v_worker_id, + array['async_job_queue_smoke']::text[], + 120 + ); + if (v_result ->> 'attempt_count')::integer <> 2 then + raise exception 'async job retry attempt was not incremented'; + end if; + + v_result := public.reserve_paid_workflow( + v_workspace_id, + v_result, + v_reservation_id, + 2147483647, + 2147483647, + 'Asia/Shanghai', + 300 + ); + if v_result #>> '{job,reservation_id}' <> v_reservation_id then + raise exception 'async job paid reservation was not created'; + end if; + + v_job := public.heartbeat_sales_job( + v_workspace_id, + v_job_id, + v_worker_id, + 'persisting_result', + 95, + 120 + ); + v_job := v_job || jsonb_build_object( + 'status', 'succeeded', + 'finished_at', now(), + 'result', jsonb_build_object('status', 'ok') + ); + v_result := public.finish_paid_workflow(v_workspace_id, v_job, v_reservation_id); + if v_result ->> 'status' <> 'succeeded' + or (v_result ->> 'progress')::integer <> 100 + or v_result ->> 'worker_id' is not null + then + raise exception 'async job did not finish cleanly'; + end if; + + select status into v_status + from public.paid_workflow_reservations + where workspace_id = v_workspace_id and id = v_reservation_id; + if v_status <> 'succeeded' then + raise exception 'async job paid reservation was not released'; + end if; + + v_job := jsonb_build_object( + 'id', v_cancel_job_id, + 'job_type', 'async_job_queue_smoke', + 'status', 'queued', + 'stage', 'queued', + 'progress', 0, + 'entity_type', 'smoke', + 'entity_id', v_suffix, + 'idempotency_key', 'async-job-cancel-smoke-' || v_suffix, + 'attempt_count', 0, + 'max_attempts', 3, + 'is_paid', true, + 'created_at', now(), + 'updated_at', now() + ); + perform public.enqueue_sales_job(v_workspace_id, v_job); + v_job := public.claim_sales_job( + v_workspace_id, + v_worker_id, + array['async_job_queue_smoke']::text[], + 120 + ); + v_result := public.reserve_paid_workflow( + v_workspace_id, + v_job, + v_cancel_reservation_id, + 2147483647, + 2147483647, + 'Asia/Shanghai', + 300 + ); + + v_result := public.request_cancel_sales_job(v_workspace_id, v_cancel_job_id); + if v_result ->> 'status' <> 'running' + or v_result ->> 'stage' <> 'cancelling' + or v_result ->> 'worker_id' <> v_worker_id + then + raise exception 'running cancellation released the worker before a safe checkpoint'; + end if; + + select status into v_status + from public.paid_workflow_reservations + where workspace_id = v_workspace_id and id = v_cancel_reservation_id; + if v_status <> 'running' then + raise exception 'running cancellation released paid capacity too early'; + end if; + + v_result := public.acknowledge_cancel_sales_job(v_workspace_id, v_cancel_job_id, v_worker_id); + if v_result ->> 'status' <> 'cancelled' + or v_result ->> 'stage' <> 'cancelled' + or v_result ->> 'worker_id' is not null + then + raise exception 'worker did not acknowledge cancellation cleanly'; + end if; + + select status into v_status + from public.paid_workflow_reservations + where workspace_id = v_workspace_id and id = v_cancel_reservation_id; + if v_status <> 'cancelled' then + raise exception 'acknowledged cancellation did not release paid capacity'; + end if; +end; +$async_job_queue_smoke$; + +rollback; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/architecture.md b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/architecture.md new file mode 100644 index 00000000..4240913b --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/architecture.md @@ -0,0 +1,48 @@ +# 运行架构 + +```text +浏览器 + -> 同源 Node HTTP 服务 + -> frontend 静态文件 + -> /api 路由 + -> SalesService + -> AI Native 应用开发底座(Supabase)持久化任务队列 +独立 Worker + -> 原子领取 / 租约 / 心跳 + -> 专业数据集(DataPro)/ 豆包搜索(联网搜索) + -> 覆盖缺口评估 / 有界补充检索 / 独立来源去重 + -> 档案 Agent(六章节事实规划 / 强制函数提交 / 确定性组装 / 服务端质量门禁) + -> AI Native 应用开发底座(Supabase)Data API Repository + +Codex CLI / 前端导入入口 + -> 飞书 CLI + -> Agent 记忆(OpenViking)资料正文 + -> AI Native 应用开发底座(Supabase)同步元数据 + +资料问答 + -> AI Native 应用开发底座(Supabase)当前档案 + -> Agent 记忆(OpenViking)资料召回 / Session / 长期记忆 +``` + +## 核心边界 + +- AI Native 应用开发底座(Supabase)是业务事实库:企业、目标、档案版本、公开引用、同步源、资料/会话索引、Job、Provider Run、权限和审计持久化在这里。 +- Agent 记忆(OpenViking)是飞书资料正文、资料问答 Session 和长期记忆的唯一内容存储;它不代替 Supabase 的关系型业务状态。 +- 专业数据集(DataPro)提供企业主体和专业数据候选;豆包搜索(联网搜索)提供公开来源候选。 +- 最新档案只允许模型使用 DataPro 与豆包搜索的已校验证据;问答只允许使用当前档案和 OpenViking 召回的企业内部资料。 +- 档案生成对已完成的专业数据集和联网搜索查询逐项保存检查点,发生可重试故障时只继续未完成查询;新刷新任务或过期检查点会重新采集,避免复用旧资料。系统按主体、经营、近期事件、风险、招采项目和来源独立性评估初始结果,只对缺失主题执行最多 4 次补充检索。档案 Agent 通过 `plan_sales_dossier` 一次规划六章完整内容与逐项证据:前五章使用可直接进入正文的 `claim` 表达事实或判断,“建议行动”使用独立 `action` 表达具体动作;每章固定一个完整段落,每段使用一个或两个直接连续原文片段。整份规划不设置固定来源数量门槛,每个事实只绑定最少且直接的来源。服务端按已批准计划条目确定性组装六章正文并派生最终引用,模型不能二次自由改写、选择引用或删除章节。规划或组装校验失败时,修订请求携带被拒绝的完整规划和有限错误,只允许定点修复错误条目。整个运行最多 3 次模型调用,首次合格立即停止。 +- 公开视图复验继续使用 Agent 输入中的已清洗、有界来源摘要,不在校验前把来源二次压缩成单一要点,避免正文使用的证据后半段在最终门禁中丢失。 +- 章节不设最低字数,也不按企业规模决定信息量;前五章至少保留一个由证据支持的完整事实或判断,“建议行动”至少保留一项有证据依据的具体动作。服务端质量门禁不通过时不保存新档案,不使用文本修补、规则拼接或旧档案模板重建报告。 +- DataPro、联网搜索和严格函数模型调用仅对可重试的临时故障执行有界重试;任务级重试采用退避并复用未过期检查点,不重复已完成查询。证据哈希不变时跳过模型,证据变化但最终正文及实际引用的报告指纹不变时保留原版本。 +- 飞书 CLI 使用当前用户授权读取云文档和消息;后端负责幂等导入、增量游标、企业归属和 OpenViking 写入。 +- 前端只显示后端状态,不保存最终业务事实,也不持有任何 Provider 密钥。 + +## 运行边界 + +项目只有一种运行方式:真实 Provider 和 Supabase 必须完整,缺少配置或依赖失败时关闭对应业务操作。自动化测试可以显式注入测试数据和替身 Provider,但这些能力不进入应用配置、用户界面或发行资产。 + +## API 与 Worker + +Skill 启动两个 Node 进程:API 进程同时提供前端和同源 `/api`,Worker 进程执行已持久化的长任务。页面不需要第二个前端服务或跨域配置。Worker 不直接对外监听端口,进度和结果只通过 Supabase 任务记录与 API 返回。 + +页面取消运行任务时,API 只写入取消请求;Worker 在当前 Provider 调用返回后的检查点确认取消。确认前租约和付费预约保持有效,任务不可重试,避免同一业务操作并行调用两次上游能力。 diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/cookbook-workflow.md b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/cookbook-workflow.md new file mode 100644 index 00000000..856d72d6 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/cookbook-workflow.md @@ -0,0 +1,63 @@ +# Cookbook 搭建流程 + +## 目标 + +帮助用户把自己的销售目标、企业数据和授权资料接入一个真实可用的销售团队工作台。最终交付物必须是可登录、可持续写入、可追溯引用、重启后数据仍存在的完整应用,不是方案文档、静态页面或演示数据。 + +## Cookbook 与 Builder 的对应关系 + +在 Agent Plan 控制台的能力列表中找到`专业数据集`、`豆包搜索`、`Agent 记忆`和 +`AI Native 应用开发底座`,确认“开启抵扣”,首次使用时按“配置使用”完成授权。本文统一写成 +`专业数据集(DataPro)`、`豆包搜索(联网搜索)`、`Agent 记忆(OpenViking)` 和 +`AI Native 应用开发底座(Supabase)`,避免只看内部技术名却找不到对应能力卡片。 + +| Cookbook 阶段 | Builder 动作 | 完成标准 | +| --- | --- | --- | +| 描述销售痛点 | 询问销售目标、客户范围、资料来源和部署方式 | `setup.mjs` 已记录业务范围 | +| 配置 Agent Plan | 用户只输入统一 Key,配置 Agent Plan 模型,并开启专业数据集(DataPro)、豆包搜索(联网搜索)、Agent 记忆(OpenViking)和 AI Native 应用开发底座(Supabase) | 套餐与能力卡片配置完整 | +| 连接 Agent 记忆(OpenViking) | 自动复用或经确认创建记忆库,等待 READY 并私密保存内部连接信息 | 用户未输入第二个 Key,Agent 记忆(OpenViking)live doctor 通过 | +| 准备 AI Native 应用开发底座(Supabase) | 用户完成火山账号 OAuth,脚本自动发现 Agent Plan Workspace、获取内部连接并应用版本化迁移 | 用户未输入 Supabase Key/AK-SK,AI Native 应用开发底座(Supabase)Workspace 属性、表结构和回读验证通过 | +| 获取历史资料 | 由 Codex CLI 调度用户态飞书 CLI 读取获授权资料 | 至少一次真实资料写入 AI Native 应用开发底座(Supabase)与 Agent 记忆(OpenViking) | +| 搭建工作台 | 安装随 Skill 发布、经过测试的完整前后端模板 | API 与独立 Worker 同时健康 | +| 生成最新档案 | 专业数据集(DataPro)与豆包搜索(联网搜索)有界并发采集、逐查询检查点 → 档案 Agent 六章节事实规划、服务端确定性组装与质量门禁 → AI Native 应用开发底座(Supabase) | 六章完整档案、逐段引用、Agent 三次以内的规划轨迹和版本记录可回读;可重试故障只继续未完成查询;飞书资料和 Agent 记忆(OpenViking)不作为外部事实来源;失败不保存或模板重建替代报告 | +| 资料问答 | 只基于企业档案、历史资料和实际引用检索后回答 | 回答有逐段引用,且问答与用量记录持久化 | +| 持续迭代 | 增量导入、重新生成、版本比较、备份恢复 | 重启后可读,增量和恢复验收通过 | + +## 为什么安装标准模板 + +Skill 的职责是让不同用户基于自己的真实数据快速得到同一套可靠产品能力,而不是每次临时生成一套无法维护的前端。默认安装仓库内经过测试的前后端模板;企业字段、销售阶段、资料来源和部署方式通过业务配置完成。确需改变产品交互时,应修改源码、补测试并重新安装,不能直接改运行时目录。 + +## 执行顺序 + +1. 复述用户需求,确认工作台范围。 +2. 运行 `setup.mjs --init` 保存不含密钥的业务范围。 +3. 安装应用并检查离线测试。 +4. 交互式配置 Agent Plan 模型,并在控制台能力列表为专业数据集、豆包搜索、Agent 记忆和 AI Native 应用开发底座确认“开启抵扣”及“配置使用”状态。 +5. 在用户知情后创建或连接 AI Native 应用开发底座(Supabase)。 +6. 由 `setup-openviking.mjs` 自动连接 Agent 记忆(OpenViking);需要飞书资料时准备 `lark-cli`。 +7. 执行全量 `doctor.mjs --live`,逐项修复数据面。 +8. 启动 API 与 Worker,在浏览器创建首位管理员。 +9. 导入首批获授权历史资料。 +10. 使用获授权测试企业执行搜索、入池、档案和问答验收。 +11. 补做重启读取、增量导入、版本比较与备份恢复。 + +任何创建计费资源、真实 Provider 调用、业务写入、迁移或恢复,都要先说明影响并获得用户确认。 + +## 进度命令 + +```bash +node {baseDir}/scripts/setup.mjs +node {baseDir}/scripts/setup.mjs --json +``` + +进度状态只保存业务范围和脱敏回执,不保存 API Key、飞书正文、企业档案正文或问答内容。缺少阶段时,按 `next_action` 执行,不要跳过失败项。 + +## 不得宣称完成的情况 + +- 只安装了页面,没有真实 API 或 Worker。 +- 使用了普通按量 Workspace,而不是 AI Native 应用开发底座(Supabase)的 Agent Plan Workspace。 +- 配置存在,但没有做全量真实诊断。 +- Agent 记忆(OpenViking)只写未搜,或只搜未写。 +- 飞书资料没有经过用户授权。 +- 档案或问答使用固定数据、静态引用或不可核验来源。 +- 没有执行真实企业搜索、档案、问答和持久化回读。 diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/evidence-policy.md b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/evidence-policy.md new file mode 100644 index 00000000..a0f00f1e --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/evidence-policy.md @@ -0,0 +1,46 @@ +# 事实与引用规则 + +## 来源职责 + +- DataPro:企业工商、经营、风险、知识产权等结构化专业数据。 +- 联网搜索:官网、公告、新闻和其他公开网页。 +- 飞书资料:用户有权访问的内部文档和会话。 +- OpenViking:已导入资料和经确认内容的检索,不是新增事实来源。 + +## 生成门槛 + +1. 专业数据和公开结果先成为候选证据。 +2. 校验企业主体匹配、来源类型、URL、时间和摘要。 +3. 验证码、人机校验、访问拦截和无实质正文的页面直接拒绝,不进入证据包。 +4. 只把通过校验的 evidence ID 交给模型。 +5. 档案 Agent 必须通过严格 Function Calling 调用 `plan_sales_dossier`,提交固定六章节的 `text + evidence_ids`:前五章为可直接进入正文的事实或判断,“建议行动”为具体动作;每章固定一个完整段落,六章合计六个段落。每段默认选择一个最短直接 Evidence Atom,只有高风险事实、关键数字或确需跨来源组合时才增加第二个。模型不得提交 quote、citation ID、URL 或引用位置;服务端根据 Evidence Atom 确定性派生逐字原文、来源 ID、引用位置和六章公开正文,不接受普通文本或从文本中猜测 JSON。 +6. 生成前必须由专业来源中的法定名称或统一社会信用代码锚定目标主体;品牌、集团和简称来源只能支持明确标注主体边界的相关事实。 +7. 引用条数不是生成门槛。每个事实段落只引用直接相关来源,不得用未被正文使用的证据凑数量;普通事实可由一个直接、高质量来源支撑,高风险事实和关键数字仍需独立双来源。 +8. 服务端独立校验结构、字段、短摘录在所选来源摘要中的连续性、所选有界来源摘要对日期/数值/命名实体的支持、计划条目与引用派生、实际主体锚点、品牌边界、高风险事实、搜索标题残片、名词片段、模板套话和展示质量。模型选错同章 Evidence ID 时,服务端只在本章白名单中确定性选择能够减少真实校验错误的最小证据组合,找不到直接支持则不得补引。规划或确定性组装仍未通过时,修订请求必须携带有限错误及不受支持的数字、日期、实体、机构和事件词,只允许定点修复错误条目;服务端在修订上下文中清空被点名章节的旧正文和证据 ID,避免模型复制已知错误,其他已合格章节继续保留。整个 Agent 最多 3 次模型调用(首次规划和最多两次局部修订);成功结果必须保留六个固定章节,每章至少一个完整句子和可核验引用,最终公开视图不合格时不持久化。 +9. 送入模型的有界上下文必须先为六个章节保留直接匹配来源,再填充其他高质量来源,不能因全局条数上限裁掉低排序但对某章不可替代的证据。某章没有专属事件 Atom 时,只要目标主体已经由专业来源锚定,可使用同一主体的已核验经营 Atom 作保守分析或核验建议;这种跨章节证据不得被扩写成来源没有陈述的近期事件、风险事实、采购意向、预算或客户需求。 +10. 不设置章节最低字数,也不根据企业规模推断应有信息量;证据少时允许简短但完整,禁止为满足长度或引用数量而补写无来源内容。内部 coverage 的 `partial` 或 `missing` 只用于触发补检索和约束生成,不直接变成前端的空章节、缺省占位句或整份报告失败;仍有合法主体锚点和可用事实时,应完成六章的有界生成。 +11. 关键 Provider 失败、缺少合法主体锚点,或有界修订后仍无法形成六章可核验完整句子时返回错误,不保存档案,不生成本地拼接报告,也不把旧档案模板重建成正式报告。 +12. 已核验法定名称中的“投资”“建设”等构词不单独视为融资或交付事件;每章固定段落不合格时仍须修订或失败,不允许删除章节或用模板补齐。来源只给出月日而模型擅自补全年份时,服务端只删除不受支持的年份精度;来源未给出月日时继续失败。纯展示层收敛只允许截短连续长摘录、在保留至少一个有效证据时删除无效附加摘录,以及把建议行动中无证据英文缩写降为“相关业务”;外部事实不适用缩写降级。风险、机会和建议行动三个分析章节如果仅因“合作、交付、签约、合同、部署、上线、落地”等动作措辞被识别为无证据事件,可确定性降为“对接、项目推进、事项确认、商务事项、应用、实施”等非事实动作词;只有真实校验错误减少时才接受,事实章节不执行这种改写。 +13. 同名或近似名称的工商记录只有在法定主体完全一致,或有直接关联证据时才能使用;只因名称相似命中的其他企业记录必须在全章节排除。 +14. 带明确日期的处罚、诉讼、失信、限高、经营异常和监管处罚按高风险事实处理:必须有两个独立外部来源,且至少一个为专业或官方来源;否则该 Atom 在进入 Agent 前排除,不会流入风险章或建议行动。 +15. 登记经营范围是静态口径,只能写为“经营范围包括”或“登记业务覆盖”,不得写成“延伸至”“扩展至”等时序变化。 + +## 档案与问答 + +- 每个档案保存 evidence hash、版本号、上一版本、资料时间和段落引用。 +- 相同证据不得伪造新版本;证据变化才生成版本差异。 +- 新企业没有历史资料,直到真实导入或业务写入完成。 +- 问答只能引用当前企业的档案和资料段落,不得接受模型编造的 citation ID。 +- 问答的每个结论、风险或行动段必须引用实际支撑该段的档案章节;不同档案章节在公开视图中分别展示,不能合并成一张误导性来源卡。当回答已完整且用户没有询问资料缺口时,不额外输出“缺口”或“证据不足”段落;真实不可回答时仍必须明确失败边界,不得编造。 +- 页面展示的标题、发布方、URL、数值和日期必须与保存证据一致。 +- 最终公开视图复验必须保留 Agent 实际使用的已清洗、有界来源摘要,不得先二次压缩为单一要点再做事实接地校验。 +- 报告摘要只能从最终可见章节按完整句子收敛,不能在企业名称、金额、项目或动作中间硬截断。风险章节不得从单个项目、单笔金额或少量公告外推企业整体的订单结构、客户结构、收入结构、业务能力、回款状况或长期趋势。 +- 工商来源中的总公司、分公司和子公司按完整登记名称分别绑定成立日期、注册地址、注册号、统一社会信用代码等身份字段。正文没有逐字点名分支机构完整名称时,不得把分支机构字段写成目标法定主体字段。 +- 正文点名分公司或子公司时必须实际引用该分支机构自己的工商记录,不能用总公司记录补写分支布局或区域覆盖。少量中标或公告只能支撑具体项目事实,不得外推企业整体业务转型;近期公开动态不得把中标密度写成来源未披露的采购需求或采购意向,保守销售推断只能放在机会章节并明确其判断边界。 +- “资料截至”按最终实际引用计算;联网摘要内可核验且不晚于生成时间的完整事件日期可修正过早的来源元数据日期,不能使用生成时间冒充资料时间。 + +## 对外展示 + +不要把自动化测试数据、录屏占位、推测或无法核实的数据标为真实。覆盖不足不直接生成缺证章或占位段;只有缺少法定主体锚点、关键 Provider 失败或有界修订后仍无法形成六章时,才返回简洁业务错误,不用静态内容覆盖失败。 + +档案正文保留逐段引用编号,来源区按“专业数据集(DataPro)”和“联网搜索”分组。专业数据集默认只显示数据集名称和记录编号,用户主动展开后按字段展示实际返回的结构化数据,以便核验;不得展示 Provider 查询词、内部引用、质量评分或主体校验标签。联网搜索只显示网页标题、发布方或来源站点、可核验的发布时间和原始跳转链接,没有发布时间时明确显示“未标注发布时间”。联网结果的长摘要仍保留在后端用于事实接地和审计,不直接堆叠到普通用户界面。 diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/feishu-import.md b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/feishu-import.md new file mode 100644 index 00000000..7bfe6f94 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/feishu-import.md @@ -0,0 +1,57 @@ +# Codex CLI + 飞书 CLI 资料导入 + +## 选择这条路线的原因 + +本项目需要读取用户有权访问的云文档、双人会话和群聊历史。官方 Feishu MCP 的消息读取通常依赖应用机器人可见范围,双人会话无法加入机器人;因此这里明确采用用户态 `lark-cli`。Codex CLI 负责理解任务、选择参数和调度命令,飞书 CLI 负责授权与读取。 + +```text +用户请求 + -> Codex CLI + -> import-feishu.mjs + -> lark-cli(用户身份、只读获取) + -> 后端受控导入服务 + -> OpenViking 企业子树保存正文 + -> Supabase 保存同步状态与业务索引 +``` + +## 前置条件 + +1. 安装 `lark-cli`。 +2. 在用户终端完成飞书 OAuth 登录。 +3. 用户本人对目标文档或会话有读取权限。 +4. 工作台正在运行,目标企业已经存在。 +5. 已在工作台页面设置本机管理员,并运行 `node {baseDir}/scripts/login.mjs` 建立 CLI 会话。 +6. `FEISHU_CLI_IMPORT_ENABLED=true`(兼容旧配置 `FEISHU_SYNC_ENABLED=true`)时 doctor 能检测到 CLI。 + +工作台运行后,本机管理员也可在“历史资料”模块点击“导入飞书资料”,选择“飞书会话”或 +“云文档”。会话只填写联系人姓名或 `oc_` 开头的会话 ID,云文档只粘贴完整链接。网页只提交来源参数并轮询本机任务状态,不显示 CLI 命令、授权令牌、 +OpenViking URI 或 Supabase 内部字段。会议纪要按云文档展示。 + +## 支持来源 + +```bash +# 云文档 +node {baseDir}/scripts/import-feishu.mjs --company-id --doc + +# 双人会话 +node {baseDir}/scripts/import-feishu.mjs --company-id --p2p-user <联系人姓名> --start 2026-07-01 + +# 群聊 +node {baseDir}/scripts/import-feishu.mjs --company-id --chat-id +``` + +先用 `--dry-run` 验证飞书读取;该模式不写后端。正式导入使用稳定来源 ID、内容哈希和检查点,重复内容会跳过,消息按 ID 合并,暂停来源需先 `--resume-source`。 + +前端导入任务当前保存在 API 进程内存中,同一企业同一时间只允许一个任务。进程重启后 +旧任务进度不可查询,但成功写入的正文仍由 OpenViking 保存,Supabase 中的来源、游标、 +内容指纹和引用仍可恢复。正式多实例部署前应把该任务迁入持久化队列。 + +## 权限边界 + +- CLI 不能绕过用户权限;不可见内容必须报告无权限。 +- 导入 API 使用当前本机管理员的 Bearer 会话并执行身份校验,不使用 Supabase Service Role 冒充用户。 +- 访问令牌不放入命令行参数;仅保存到本机状态目录的 `cli-session.json`,权限为 `0600`,过期后自动刷新。 +- 不自动扩大时间范围或导入整个组织消息。 +- 不把会话原文写入日志或聊天回复。 +- 每个来源必须绑定当前企业;跨企业 `source_id` 操作由后端拒绝。 +- 删除同步源会删除应用内关联资料,并尝试清理对应 OpenViking 内容;执行前必须确认。 diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/provider-configuration.md b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/provider-configuration.md new file mode 100644 index 00000000..59b6cc87 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/provider-configuration.md @@ -0,0 +1,75 @@ +# Provider 配置 + +## Agent Plan 控制台名称 + +在 Agent Plan 控制台的能力列表按控制台名称确认以下卡片已“开启抵扣”;首次使用时按 +卡片中的“配置使用”完成授权: + +| 控制台名称 | 本文作用说明 | +| --- | --- | +| 专业数据集 | DataPro | +| 豆包搜索 | 联网搜索 | +| Agent 记忆 | OpenViking | +| AI Native 应用开发底座 | Supabase | + +面向用户时统一写成“控制台名称(内部技术名或作用说明)”,不要只写内部技术名。 + +## 凭证对应关系 + +| 能力 | 私密配置 | 说明 | +| --- | --- | --- | +| 模型、专业数据集(DataPro)、豆包搜索(联网搜索)、Agent 记忆(OpenViking)控制面 | `AGENT_PLAN_API_KEY` | 用户只输入这一枚 Agent Plan 专属 API Key;对应能力仍需在套餐和控制台能力卡片中开通 | +| Agent 记忆(OpenViking) | 初始化脚本自动管理 | 脚本自动选择或创建记忆库,并获取内部访问凭证;不得要求用户输入、查看或管理第二个 Key | +| 能力专用覆盖(可选) | `MODEL_API_KEY`、`DATAPRO_API_KEY`、`WEB_SEARCH_API_KEY` | 仅用于独立轮换或排障;未设置时回退到 `AGENT_PLAN_API_KEY` | +| OpenViking 连接信息 | `OPENVIKING_BASE_URL` | 也可复用 `~/.openviking/ovcli.conf`;`OPENVIKING_AGENT_ID` 默认 `default` | +| AI Native 应用开发底座(Supabase)Data API | 初始化脚本自动管理 | 脚本从已登录的官方 CLI 获取内部 `service_role`,只供后端使用;不得要求用户粘贴 | +| AI Native 应用开发底座(Supabase)控制面 | `SUPABASE_CLI_PROFILE` | 用户完成一次火山账号 OAuth 登录;这是账号授权,不是另一枚业务 Key | + +不要把这些值放入前端、README、日志、截图、Provider Run 或 Git。 + +模型、专业数据集(DataPro)、豆包搜索(联网搜索)和 Agent 记忆(OpenViking)控制面统一使用 `AGENT_PLAN_API_KEY`。专业数据集、豆包搜索、Agent 记忆和 AI Native 应用开发底座必须先在 Agent Plan 控制台确认“开启抵扣”,并按卡片提示完成“配置使用”。`setup-openviking.mjs` 会优先复用已有配置或记忆库;需要新建时取得用户对名称和计费影响的确认,等待资源就绪,再把内部连接信息保存到本机 `0600` 私密配置。用户全程只输入一枚 Agent Plan Key。 + +Supabase 初始化需要用户在官方 CLI 完成一次火山账号 OAuth 登录。Skill 自动发现 Agent Plan Workspace,并从控制面取得 Data API 地址和内部 `service_role` 后写入本机 `0600` 配置;这些是后端运行细节,不向用户索取或展示。用户不需要手工输入 Supabase Key、火山 AK/SK 或 Data API 地址。 + +## 运行必需配置 + +- `REPOSITORY_MODE=supabase` +- `SUPABASE_READ_ONLY=false` +- `SUPABASE_API_URL`、`SUPABASE_SERVICE_ROLE_KEY`、`APP_WORKSPACE_ID` +- `HTTP_AUTH_ENABLED=true`;本地回环 HTTP 使用 `AUTH_COOKIE_SECURE=false`,非回环部署必须使用 HTTPS 并设为 `true` +- `ALLOWED_ORIGINS` 只列出实际部署来源,不使用 `*` +- `PAID_WORKFLOW_MAX_CONCURRENCY` 和 `PAID_WORKFLOW_DAILY_LIMIT` 必须为正整数;默认分别为 `2` 和 `100` +- `PAID_WORKFLOW_BUDGET_TIMEZONE` 默认 `Asia/Shanghai`;`PAID_WORKFLOW_STALE_AFTER_SECONDS` 默认 `1800` +- `ASYNC_JOBS_ENABLED=true`;`JOB_WORKER_LEASE_SECONDS` 不低于 `60`,默认 `600` +- `SUPABASE_CLI_PROFILE` 指向已用 `--is-agent-plan` 登录的 profile,目标 Workspace 具备 Agent Plan 属性 +- 模型、专业数据集(DataPro)、豆包搜索(联网搜索)和 Agent 记忆(OpenViking)已配置且各自 `*_RUN_ENABLED=true` +- `MODEL_MAX_RETRIES` 默认 `1`、上限 `2`,只重试超时、限流、网络和上游临时故障;设为 `0` 可关闭模型传输层重试 +- `DOSSIER_AGENT_MAX_CALLS` 默认 `3`、范围 `1-3`,限制单次档案任务的六章节规划与定点修订总预算;首次完整规划合格后立即停止,否则最多再局部修订两次。六章正文由服务端确定性组装,不另行调用模型自由成稿 +- `DOSSIER_CHECKPOINT_TTL_MS` 默认 `1800000`(30 分钟),控制失败重试可复用的内部证据检查点时效;新刷新任务不继承旧任务检查点 +- `DOSSIER_DATAPRO_CONCURRENCY` 默认 `2`、`DOSSIER_WEB_CONCURRENCY` 默认 `3`,限制同一档案任务的只读采集并发;增大并发会提高上游限流和瞬时失败风险 +飞书 CLI 导入由 `FEISHU_CLI_IMPORT_ENABLED` 控制;旧配置 +`FEISHU_SYNC_ENABLED` 仍兼容。启用时 doctor 必须检测到 `lark-cli`。任务数量上限可用 +`FEISHU_CLI_IMPORT_TASK_LIMIT` 调整;该任务状态当前只在 API 进程内保存。 + +OpenViking 保存飞书正文,并按官方“确认或创建会话 → 逐条添加消息 → 提交会话”流程保存资料问答记忆。提交频率和保留的近期消息数可分别用 `OPENVIKING_QA_AUTO_COMMIT_EVERY`、`OPENVIKING_QA_KEEP_RECENT_MESSAGES` 调整。自动取得的内部凭证只供后端读取,不会复制到前端、日志或用户引导。 + +首次使用只设置唯一的本机管理员用户名和密码,不需要邮箱或邮件服务。设置完成后浏览器保存长期会话,短期访问令牌过期时由服务端自动续期;只有主动退出或长期会话失效时才再次使用原账号登录。`SUPABASE_SERVICE_ROLE_KEY` 始终留在后端;任何用户密码都不得写入命令行参数、日志、截图或前端持久化存储。 + +## doctor 语义 + +- 默认 doctor:检查本机目录、权限、配置结构和运行门槛,不调用外部服务。 +- `--live`:发起最小只读 Agent Plan 模型、专业数据集(DataPro)、豆包搜索(联网搜索)、Agent 记忆(OpenViking)和 AI Native 应用开发底座(Supabase)检查,可能产生少量 AFP/Token。 +- 启动要求配置 doctor 通过。live doctor 结果会按 `LIVE_DOCTOR_TTL_MS`(默认 15 分钟)标记新鲜度并展示在运维状态中,但上游临时故障不会阻止其他独立能力启动。 + +`--live` 默认以 `北京火山引擎科技有限公司` 做只读 DataPro/Web Search 探针;如组织策略要求使用其他公开主体,可设置 `LIVE_PROBE_COMPANY`。该值只用于诊断,不会写入目标企业池。 + +配置存在不代表权限、余额、网络和上游服务正常;只有 live doctor 能证明当时的可达性。 +业务操作仍按 Provider 严格失败:例如联网搜索故障时不能生成声称包含最新公开动态的档案,也不会退回静态替代来源。 + +## 付费工作流保护 + +企业搜索、档案生成、资料问答、资料导入、OpenViking 批量同步、问答记忆提交和同步源删除在调用对应 Provider 前,先在 Supabase 中原子预约名额。任务成功或失败会释放并发名额;等待任务可立即取消,运行任务则在 Worker 到达安全检查点后确认取消并释放,避免尚未结束的 Provider 调用与重试并行。超过时限的遗留预约会自动标记过期,暂停/恢复等纯数据库状态修改不占用付费名额。 + +`PAID_WORKFLOW_DAILY_LIMIT` 统计的是付费工作流尝试次数。一次档案生成可能包含 DataPro、豆包搜索和模型多个步骤;一次资料问答还会包含 OpenViking 召回与 Session 写入。因此该值用于防止失控调用,不能作为 AFP 或金额报表。精确用量应结合 Provider Run 的 Token/调用记录与官方账单。 + +数据库必须依次应用到 `202607300001_durable_job_checkpoints.sql`。迁移缺失时应用会返回 `503`,不会退化为单进程内存队列或请求内假成功。内部元数据表只允许后端 `service_role` 访问;Job 失败或取消时,数据库会同步结束关联的 Provider Run 和运行中步骤,避免留下“任务已失败、调用仍运行”的悬挂状态。档案和 OpenViking 批量同步先持久化入队,Worker 原子领取后才建立付费预约。仅超时、限流、网络和上游临时故障进入有界退避重试;档案任务逐项保存已完成的只读查询和证据包,重试只继续未完成查询,不重复已经成功的 Provider 调用。鉴权、配置、请求校验和内容门禁错误不自动重试。问答正文由 OpenViking 保存和检索,Supabase 仅保存业务结构与 OpenViking 会话引用。 diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/security.md b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/security.md new file mode 100644 index 00000000..ace90c88 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/security.md @@ -0,0 +1,31 @@ +# 安全与开源边界 + +## 密钥 + +- 凭证存入 `~/.config/sales-intelligence-workbench/credentials.env`,权限 `0600`。 +- 前端、仓库、日志、截图、测试夹具和 Provider Run 不得出现完整密钥。 +- Skill 应用包同步时排除 `.env.local`、依赖、临时目录、日志、PID 和备份。 +- 用户曾在聊天或公开文档粘贴的密钥应视为暴露并轮换。 + +## 数据 + +- Supabase service role 只存在于后端进程。 +- 业务 API 必须启用 Supabase Auth;网页使用 HttpOnly、SameSite=Strict Cookie,写操作额外校验 CSRF。 +- CLI 使用用户级短期 Bearer 会话,本机文件权限 `0600`;不得把令牌放入参数、日志或仓库。 +- 所有业务、Provider 管理、运行追踪、任务管理和数据导出仅对唯一的本机管理员开放。 +- 所有业务读取和写入按 `APP_WORKSPACE_ID` 隔离;底层账号归属记录只用于鉴权和数据隔离,不代表产品提供成员系统。 +- OpenViking URI 按 Workspace、企业和来源分层。 +- OpenViking URI、Provider raw reference、Service Role 和证据内部包不得通过业务 DTO 返回前端。 +- 飞书导入只读取用户授权范围,避免把原始会话写入日志。 +- 备份包含私有业务数据,目录权限 `0700`、文件 `0600`,不得提交。 + +## 运行与删除 + +- 运行时仅使用真实 Provider 和 Supabase,不提供测试数据或内存仓库配置入口。 +- `uninstall.mjs` 默认保留配置、备份和云数据。 +- `--purge --yes` 只删除本机配置、日志和备份,不删除云端数据。 +- 云端删除必须使用各服务的独立管理流程,并再次确认范围。 + +## 发布检查 + +执行凭证模式扫描、依赖审计和真实客户资料清查。移除录屏、截图、历史日志、测试备份和任何无法公开授权的内容;仅保留虚构测试夹具并明确标注。 diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/setup.md b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/setup.md new file mode 100644 index 00000000..e4f9d4ab --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/setup.md @@ -0,0 +1,51 @@ +# 安装与首次配置 + +## 本机要求 + +- Node.js 20 或更高版本。 +- 可访问的 Agent Plan 模型,以及在控制台能力列表中“开启抵扣”并按需完成“配置使用”的专业数据集(DataPro)、豆包搜索(联网搜索)、Agent 记忆(OpenViking)和 AI Native 应用开发底座(Supabase)。 +- 需要飞书资料同步时,安装并登录 `lark-cli`。 +- 数据库迁移、备份或恢复需要火山 Supabase 控制面凭证及 CLI。 + +## 私有目录 + +| 内容 | 默认路径 | +| --- | --- | +| 只读应用运行时 | `~/.local/share/sales-intelligence-workbench/app` | +| 私密配置 | `~/.config/sales-intelligence-workbench` | +| 日志、PID、doctor 证据和备份 | `~/.local/state/sales-intelligence-workbench` | + +配置文件和状态目录权限为 `0700`,凭证文件为 `0600`。源码目录、运行目录和配置目录必须分开。 + +## 首次部署顺序 + +优先反复运行 `onboard.mjs`。它会根据阶段状态自动完成本地安装、配置引导和启动,并在需要云资源写入、真实调用、登录、资料导入或业务验收时暂停。手工排障时按以下顺序执行: + +1. `install.mjs` 安装应用并执行测试。 +2. `configure.mjs` 只收集一枚 Agent Plan Key 和业务选项。 +3. `setup-supabase.mjs` 查看 AI Native 应用开发底座(Supabase)初始化计划。 +4. 用户确认目标后运行 `setup-supabase.mjs --apply --yes`,自动获取 Data API 配置、执行迁移、创建应用 Workspace 记录并回读。 +5. 启动后在页面设置唯一的本机管理员用户名和密码;不配置邮箱、邮件确认或公开注册。 +6. 运行 `doctor.mjs`。 +7. 告知用户会产生少量用量后运行 `doctor.mjs --live`。 +8. 运行 `start.mjs`,确认 API 与 Worker 均启动,再从 `status.mjs` 获取网址。 + +## 从现有工程迁移 + +使用 `configure.mjs --from-env-file ` 读取现有 `.env.local`。脚本只复制白名单配置项,并把秘密与普通配置拆分;不会修改或打印源文件。迁移后仍要运行 doctor,不能把“文件里有值”等同于服务可用。 + +## 数据库首次初始化 + +Skill 复用应用包中的版本化 `supabase/migrations`。目标必须是北京地域的 Agent Plan Workspace;初始化脚本会只读检查 `is_agent_plan` / `is_agent_plan_instance` 与 Running 状态,普通按量 Workspace 会被拒绝。 + +先登录一个明确的 Agent Plan CLI profile: + +```bash +byted-supabase-cli login --profile agent-plan --region cn-beijing --is-agent-plan +``` + +`setup-supabase.mjs` 会列出当前账号下的 Agent Plan Workspace;只有一个时自动选择,存在多个时要求通过 `--workspace-id` 明确目标。默认只显示计划,只有 `--apply --yes` 才读取端点与内部 API Key、写本机配置并执行 SQL。指定 profile 后,脚本会忽略旧的静态 AK/SK,防止连到另一个账号。用户无需输入 Supabase Key、Data API 地址或火山 AK/SK;业务运行使用脚本私下配置的 Supabase Data API,控制面 CLI 仅用于首次初始化、迁移、资源管理、备份和恢复。 + +脚本不会创建云 Workspace,因为该操作可能持续计费。需要新建时,先由用户确认目标套餐、地域和自动休眠时间,再由有 `aidap:CreateWorkspace` 权限的账号执行 `byted-supabase-cli projects create --profile agent-plan --is-agent-plan`;随后再次运行 `setup-supabase.mjs`,脚本会自动发现新 Workspace。 + +恢复不得覆盖当前生产分支。先创建独立空工作区或独立空分支,完成预检、校验哈希和行数后再按恢复脚本要求显式确认。 diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/troubleshooting.md b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/troubleshooting.md new file mode 100644 index 00000000..6edd14ba --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/troubleshooting.md @@ -0,0 +1,47 @@ +# 故障排查 + +## install 失败 + +- Node 版本不足:升级到 Node 20+。 +- 测试失败:修复源码后再安装,不使用 `--skip-tests` 作为正式发布手段。 +- 服务仍运行:先 `stop.mjs`;不要删除 PID 后强行覆盖。 +- 端口被占用:修改私有 `runtime.env` 的 `PORT`,再运行 doctor。 + +## doctor 配置失败 + +- 查看 `local.blockers` 和 `backend.blockers`,只补对应资源。 +- `credentials.env 权限过宽`:执行 `chmod 600`。 +- Supabase 缺配置:Data API 需要 URL、service role 和应用 Workspace ID;控制面优先使用 `SUPABASE_CLI_PROFILE`,目标必须是 Agent Plan Workspace。 +- OpenViking 缺配置:运行 `setup-openviking.mjs` 只读查看可复用记忆库,再用 + `--apply --resource-id` 连接;没有资源时确认计费影响后用 + `--apply --collection-name <英文名称> --yes` 创建。不要让用户输入第二个 Key。 +- 飞书导入缺 CLI:安装并登录 `lark-cli`,或明确设置 `FEISHU_CLI_IMPORT_ENABLED=false`。 + +## live doctor 失败 + +- DataPro 超时:记录 request/error 和发生时间,检查权限与平台状态;不要改用静态工商数据。 +- 豆包搜索平台错误:确认 Agent Plan 套餐为 Running,控制台“豆包搜索”能力卡片已“开启抵扣”并按需完成“配置使用”,Key 为当前 Agent Plan 专属 API Key;保留错误码和 request ID,并用 `doctor.mjs --live --only-provider web_search` 单项复测。`10500` 在重试后仍出现时按上游服务异常反馈,不得改用静态新闻兜底。 +- 模型鉴权失败:检查 Key、Base URL 和套餐;不要在日志打印 Key。 +- OpenViking 健康成功但检索失败:检查命名空间和 CLI/API 配置。 +- Supabase 控制面失败:Data API 可用不代表 CLI 控制面权限可用;`aidap:CreateWorkspace` 被拒绝时,需要账号管理员授权或代为创建 Agent Plan Workspace。 + +## 启动后部分功能不可用 + +配置 doctor 不通过时工作台拒绝启动。live doctor 失败或过期时仍可进入工作台查看已有数据、导入资料和检查状态;依赖异常 Provider 的操作会明确失败,不会生成替代数据。 + +重新告知会产生最小调用后,可执行 `doctor.mjs --live`;`--only-provider` 用于定位单项故障。不要手工伪造 `doctor-live.json`。 + +## 页面打不开 + +1. 运行 `status.mjs` 查看 PID、URL 和健康检查。 +2. 查看 `~/.local/state/sales-intelligence-workbench/logs/server.log`。 +3. 确认 URL 使用 status 给出的地址,不直接打开 Skill 内 HTML。 +4. `/api/health` 正常而页面 404 时,重新安装应用包并检查 `frontend/index.html`。 + +## 飞书导入失败 + +- 先运行同一命令加 `--dry-run`,区分飞书读取失败和后端写入失败。 +- 401/403:重新登录或确认用户权限。 +- 双人会话不需要机器人;使用 `--p2p-user` 的用户态 CLI 路线。 +- 来源暂停:添加 `--resume-source`,确认后再继续。 +- 重复导入显示 skipped:说明内容哈希未变化,不是失败。 diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/backup.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/backup.mjs new file mode 100644 index 00000000..c5eca340 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/backup.mjs @@ -0,0 +1,29 @@ +import path from "node:path"; +import { + assertInstalledApp, + ensureDirectories, + paths, + readOption, + resolveUserPath, + run, + runtimeEnvironment, +} from "./lib.mjs"; + +assertInstalledApp(); +ensureDirectories(); +const requestedOutput = readOption("--output-dir"); +const timestamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); +const outputDir = requestedOutput + ? resolveUserPath(requestedOutput) + : path.join(paths.backupDir, `supabase-${timestamp}`); + +const result = run(process.execPath, [ + path.join(paths.installedApp, "backend", "scripts", "backup-supabase.mjs"), + "--output-dir", + outputDir, +], { + cwd: path.join(paths.installedApp, "backend"), + env: runtimeEnvironment(), + allowFailure: true, +}); +if (result.status !== 0) process.exitCode = result.status; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/configure.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/configure.mjs new file mode 100644 index 00000000..1a4f4309 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/configure.mjs @@ -0,0 +1,136 @@ +import fs from "node:fs"; +import { Writable } from "node:stream"; +import readline from "node:readline/promises"; +import { + configurationSummary, + openVikingCliConfiguration, + parseEnvFile, + paths, + readConfiguration, + readOption, + resolveUserPath, + writeConfiguration, +} from "./lib.mjs"; + +class MutedOutput extends Writable { + constructor(output) { + super(); + this.output = output; + this.muted = false; + } + + _write(chunk, encoding, callback) { + if (!this.muted) this.output.write(chunk, encoding); + callback(); + } +} + +async function hiddenQuestion(rl, output, label, existingValue = "") { + process.stdout.write(`${label}${existingValue ? "(留空保留现有值)" : ""}: `); + output.muted = true; + const answer = await rl.question(""); + output.muted = false; + process.stdout.write("\n"); + return answer.trim() || existingValue; +} + +async function visibleQuestion(rl, label, existingValue = "") { + const suffix = existingValue ? ` [${existingValue}]` : ""; + const answer = (await rl.question(`${label}${suffix}: `)).trim(); + return answer || existingValue; +} + +const importPath = readOption("--from-env-file"); + +if (importPath) { + const resolved = resolveUserPath(importPath); + if (!fs.existsSync(resolved)) throw new Error(`配置源文件不存在:${resolved}`); + const imported = parseEnvFile(resolved); + writeConfiguration(imported); + process.stdout.write(`已从现有环境文件迁移配置,源文件未被修改。\n`); + process.stdout.write(`私密凭证:${paths.credentialsFile}(0600)\n`); + process.stdout.write(`运行配置:${paths.runtimeFile}(0600)\n`); + process.stdout.write(`${JSON.stringify(configurationSummary(), null, 2)}\n`); + process.exit(0); +} + +if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error("交互配置需要终端,以确保密钥输入不回显;迁移现有配置可使用 --from-env-file。"); +} + +const existing = readConfiguration(); +const output = new MutedOutput(process.stdout); +const rl = readline.createInterface({ input: process.stdin, output, terminal: true }); + +try { + process.stdout.write("凭证只写入本机用户配置目录,输入过程不会回显。\n"); + const agentPlanKey = await hiddenQuestion( + rl, + output, + "Agent Plan API Key(模型、DataPro、豆包搜索和 OpenViking 控制面共用)", + existing.AGENT_PLAN_API_KEY + || existing.MODEL_API_KEY + || existing.DATAPRO_API_KEY + || existing.WEB_SEARCH_API_KEY, + ); + if (!agentPlanKey) throw new Error("Agent Plan API Key 不能为空。"); + const detectedOpenViking = openVikingCliConfiguration(existing); + const openVikingKey = existing.OPENVIKING_API_KEY || ""; + const openVikingBaseUrl = existing.OPENVIKING_BASE_URL || ""; + let openVikingCliConfig = existing.OPENVIKING_CLI_CONFIG || ""; + if (detectedOpenViking.ready && !openVikingKey) { + openVikingCliConfig = detectedOpenViking.path; + process.stdout.write(`已自动接入本机 OpenViking CLI 配置:${detectedOpenViking.path}\n`); + } else if (!openVikingKey) { + process.stdout.write( + "Agent Plan Key 已保存;OpenViking 记忆库将在下一阶段自动选择或创建,无需输入其他 Key。\n", + ); + } + const openVikingCli = existing.OPENVIKING_CLI || ""; + const openVikingAgentId = existing.OPENVIKING_AGENT_ID || detectedOpenViking.agent_id || "default"; + + process.stdout.write( + "Supabase 将在下一阶段通过已登录的官方 CLI 自动选择 Agent Plan Workspace," + + "并获取后端内部连接信息;无需输入 Data API、Service Role 或火山 AK/SK。\n", + ); + + const feishuAnswer = await visibleQuestion( + rl, + "是否启用飞书 CLI 导入(命令行与前端入口,true/false)", + existing.FEISHU_CLI_IMPORT_ENABLED || existing.FEISHU_SYNC_ENABLED || "false", + ); + const liveProbeCompany = await visibleQuestion( + rl, + "真实只读诊断使用的企业名称", + existing.LIVE_PROBE_COMPANY || "北京火山引擎科技有限公司", + ); + writeConfiguration({ + ...existing, + AGENT_PLAN_API_KEY: agentPlanKey, + MODEL_API_KEY: "", + DATAPRO_API_KEY: "", + WEB_SEARCH_API_KEY: "", + OPENVIKING_API_KEY: openVikingKey, + OPENVIKING_BASE_URL: openVikingBaseUrl, + OPENVIKING_CLI: openVikingCli, + OPENVIKING_CLI_CONFIG: openVikingCliConfig, + OPENVIKING_AGENT_ID: openVikingAgentId, + SUPABASE_API_URL: existing.SUPABASE_API_URL || "", + SUPABASE_SERVICE_ROLE_KEY: existing.SUPABASE_SERVICE_ROLE_KEY || "", + APP_WORKSPACE_ID: existing.APP_WORKSPACE_ID || "", + SUPABASE_WORKSPACE_ID: existing.SUPABASE_WORKSPACE_ID || "", + SUPABASE_BRANCH_ID: existing.SUPABASE_BRANCH_ID || "", + SUPABASE_CLI_PROFILE: existing.SUPABASE_CLI_PROFILE || "current", + VOLCENGINE_ACCESS_KEY: existing.VOLCENGINE_ACCESS_KEY || "", + VOLCENGINE_SECRET_KEY: existing.VOLCENGINE_SECRET_KEY || "", + FEISHU_CLI_IMPORT_ENABLED: /^true|1|yes$/i.test(feishuAnswer) ? "true" : "false", + FEISHU_SYNC_ENABLED: /^true|1|yes$/i.test(feishuAnswer) ? "true" : "false", + LIVE_PROBE_COMPANY: liveProbeCompany, + }); + + process.stdout.write(`私密凭证已写入 ${paths.credentialsFile}(0600)。\n`); + process.stdout.write(`运行配置已写入 ${paths.runtimeFile}(0600)。\n`); + process.stdout.write("下一步运行 doctor.mjs;它只显示配置状态,不显示密钥。\n"); +} finally { + rl.close(); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/doctor.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/doctor.mjs new file mode 100644 index 00000000..67757787 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/doctor.mjs @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + assertInstalledApp, + commandExists, + configurationSummary, + credentialFileIsPrivate, + paths, + readConfiguration, + run, + runtimeEnvironment, + writePrivateJson, +} from "./lib.mjs"; + +assertInstalledApp(); +const live = process.argv.includes("--live"); +const onlyProviderIndex = process.argv.indexOf("--only-provider"); +const onlyProvider = onlyProviderIndex >= 0 ? String(process.argv[onlyProviderIndex + 1] || "").trim() : ""; +const configuration = readConfiguration(); +const summary = configurationSummary(); +const localBlockers = []; +const localWarnings = []; + +if (!fs.existsSync(paths.credentialsFile)) localBlockers.push("credentials.env 不存在"); +if (!fs.existsSync(paths.runtimeFile)) localBlockers.push("runtime.env 不存在"); +if (fs.existsSync(paths.credentialsFile) && !credentialFileIsPrivate()) { + localBlockers.push("credentials.env 权限过宽,必须为 0600"); +} +if (!summary.async_jobs) { + localBlockers.push("必须启用持久化异步任务队列"); +} +if (summary.worker_lease_seconds < 60) { + localBlockers.push("Worker 租约必须不少于 60 秒"); +} +if (summary.feishu_sync && !commandExists("lark-cli")) localBlockers.push("已启用飞书 CLI 导入,但找不到 lark-cli"); +if (!summary.feishu_sync && !commandExists("lark-cli")) localWarnings.push("飞书 CLI 导入未启用,且当前未检测到 lark-cli"); + +const scriptName = live ? "baseline-real-readonly.mjs" : "doctor.mjs"; +const args = [path.join(paths.installedApp, "backend", "scripts", scriptName)]; +if (live) args.push("--live"); +if (onlyProvider) args.push("--only-provider", onlyProvider); +const result = run(process.execPath, args, { + cwd: path.join(paths.installedApp, "backend"), + env: runtimeEnvironment(), + encoding: "utf8", + stdio: "pipe", + allowFailure: true, +}); + +let backendReport = null; +try { + backendReport = JSON.parse(result.stdout || "{}"); +} catch { + localBlockers.push("后端 doctor 未返回有效 JSON"); +} +if (result.status !== 0) localBlockers.push(live ? "真实只读 Provider 检查未全部通过" : "后端配置检查未通过"); + +const ok = localBlockers.length === 0; +const report = { + checked_at: new Date().toISOString(), + check_type: live ? onlyProvider ? "read_only_live_partial" : "read_only_live" : "configuration_only", + selected_provider: onlyProvider || null, + ok, + local: { + installed_app: paths.installedApp, + configuration: summary, + warnings: localWarnings, + blockers: localBlockers, + }, + backend: backendReport, +}; + +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +if (live) writePrivateJson(paths.lastDoctorFile, report); +if (live && !onlyProvider && ok && backendReport?.runtime_ready) { + writePrivateJson(paths.liveDoctorFile, backendReport); +} +if (!ok) process.exitCode = 1; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/export-workspace.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/export-workspace.mjs new file mode 100644 index 00000000..cdfc4f05 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/export-workspace.mjs @@ -0,0 +1,15 @@ +import path from "node:path"; + +import { assertInstalledApp, paths, run, serverAddress } from "./lib.mjs"; + +assertInstalledApp(); +const userArgs = process.argv.slice(2); +const args = [ + path.join(paths.installedApp, "backend", "scripts", "export-workspace.mjs"), +]; +if (!userArgs.includes("--api-url")) args.push("--api-url", serverAddress().url); +if (!userArgs.includes("--auth-session")) args.push("--auth-session", paths.cliSessionFile); +args.push(...userArgs); + +const result = run(process.execPath, args, { allowFailure: true }); +if (result.status !== 0) process.exitCode = result.status; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/import-feishu.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/import-feishu.mjs new file mode 100644 index 00000000..b6188f9e --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/import-feishu.mjs @@ -0,0 +1,62 @@ +import path from "node:path"; +import { + assertInstalledApp, + commandExists, + paths, + run, + runtimeEnvironment, + serverAddress, + writePrivateJson, +} from "./lib.mjs"; + +assertInstalledApp(); +if (!commandExists("lark-cli")) throw new Error("找不到 lark-cli,请先安装并完成用户登录。"); + +const userArgs = process.argv.slice(2); +const valueAfter = (name) => { + const index = userArgs.indexOf(name); + return index >= 0 ? String(userArgs[index + 1] || "").trim() : ""; +}; +const documentTarget = valueAfter("--doc"); +const p2pTarget = valueAfter("--p2p-user"); +const chatTarget = valueAfter("--chat-id"); +if (documentTarget && !/^https:\/\/\S+$/i.test(documentTarget)) { + throw new Error("--doc 只接受完整的 https:// 飞书云文档或知识库链接。"); +} +if (p2pTarget && /^ou_/i.test(p2pTarget)) { + throw new Error("--p2p-user 只接受联系人姓名,不接受 Open ID。"); +} +if (chatTarget && !/^oc_[A-Za-z0-9]+$/.test(chatTarget)) { + throw new Error("--chat-id 必须是 oc_ 开头的飞书会话 ID。"); +} +if (userArgs.includes("--message-query")) { + throw new Error("销售工作台只提供按联系人姓名、会话 ID 或云文档链接导入。"); +} +const args = [path.join(paths.installedApp, "backend", "scripts", "import-feishu-cli.mjs")]; +if (!userArgs.includes("--api-url")) args.push("--api-url", serverAddress().url); +if (!userArgs.includes("--auth-session")) args.push("--auth-session", paths.cliSessionFile); +args.push(...userArgs); + +const result = run(process.execPath, args, { + cwd: path.join(paths.installedApp, "backend"), + env: runtimeEnvironment(), + allowFailure: true, +}); +if (result.status !== 0) { + process.exitCode = result.status; +} else if (!userArgs.includes("--dry-run")) { + const sourceKind = userArgs.includes("--doc") + ? "feishu_doc" + : userArgs.includes("--p2p-user") + ? "feishu_p2p" + : userArgs.includes("--chat-id") + ? "feishu_chat" + : "feishu"; + writePrivateJson(paths.historyImportReceiptFile, { + schema_version: 1, + ok: true, + imported_at: new Date().toISOString(), + company_id: valueAfter("--company-id") || null, + source_kind: sourceKind, + }); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/install.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/install.mjs new file mode 100644 index 00000000..78825ef8 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/install.mjs @@ -0,0 +1,84 @@ +import fs from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { + appCopyFilter, + assertAppSource, + assertNodeVersion, + ensureDirectories, + paths, + processExists, + readConfiguration, + readOption, + readPid, + resolveUserPath, + run, + serverAddress, + waitForHealth, + writeConfiguration, +} from "./lib.mjs"; + +assertNodeVersion(); +ensureDirectories(); + +if (processExists(readPid()) || processExists(readPid(paths.workerPidFile))) { + throw new Error("工作台正在运行。更新前请先执行 stop.mjs,业务数据不会因此删除。"); +} +if (await waitForHealth(serverAddress().url, 800)) { + throw new Error("配置端口仍有服务响应。请先停止该服务,再执行安装或升级。"); +} + +const sourceValue = readOption("--source"); +const sourceRoot = assertAppSource(sourceValue ? resolveUserPath(sourceValue) : paths.sourceApp); +if (path.resolve(sourceRoot) === path.resolve(paths.installedApp)) { + throw new Error("运行时安装目录不能同时作为源码目录。"); +} + +const staging = path.join(paths.installRoot, `.app-install-${randomUUID()}`); +const previous = path.join(paths.installRoot, ".app-previous"); +const skipTests = process.argv.includes("--skip-tests"); + +try { + fs.cpSync(sourceRoot, staging, { + recursive: true, + force: true, + filter: (entry) => appCopyFilter(sourceRoot, entry), + }); + assertAppSource(staging); + + if (!skipTests) { + run(process.execPath, ["--check", "frontend/app.js"], { cwd: staging }); + run(process.execPath, ["--check", "frontend/text-format.js"], { cwd: staging }); + run(process.platform === "win32" ? "npm.cmd" : "npm", ["test"], { + cwd: path.join(staging, "backend"), + env: { ...process.env, NODE_ENV: "test" }, + }); + } + + fs.writeFileSync(path.join(staging, ".sales-workbench-runtime.json"), `${JSON.stringify({ + schema_version: 1, + source_path: sourceRoot, + installed_at: new Date().toISOString(), + }, null, 2)}\n`, { mode: 0o600 }); + + fs.rmSync(previous, { recursive: true, force: true }); + if (fs.existsSync(paths.installedApp)) fs.renameSync(paths.installedApp, previous); + fs.renameSync(staging, paths.installedApp); + fs.rmSync(previous, { recursive: true, force: true }); +} catch (error) { + fs.rmSync(staging, { recursive: true, force: true }); + if (!fs.existsSync(paths.installedApp) && fs.existsSync(previous)) { + fs.renameSync(previous, paths.installedApp); + } + throw error; +} + +process.stdout.write(`应用运行时已安装到 ${paths.installedApp}\n`); +const installedConfiguration = readConfiguration(); +if (installedConfiguration.AUTH_REFRESH_COOKIE_MAX_AGE === "2592000") { + writeConfiguration({ AUTH_REFRESH_COOKIE_MAX_AGE: "31536000" }); + process.stdout.write("浏览器本机会话保持期已从旧版默认值升级为一年。\n"); +} +if (!fs.existsSync(paths.credentialsFile) || !fs.existsSync(paths.runtimeFile)) { + process.stdout.write(`下一步:运行 node ${path.join(paths.skillRoot, "scripts", "configure.mjs")}\n`); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/lib.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/lib.mjs new file mode 100644 index 00000000..5b59a97d --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/lib.mjs @@ -0,0 +1,558 @@ +import fs from "node:fs"; +import { randomUUID } from "node:crypto"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); +const cliErrorHandler = Symbol.for("sales-intelligence-workbench.cli-error-handler"); + +if (!globalThis[cliErrorHandler]) { + globalThis[cliErrorHandler] = true; + const reportFatal = (error) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`错误:${message}\n`); + if (process.env.DEBUG && error?.stack) process.stderr.write(`${error.stack}\n`); + process.exitCode = 1; + }; + process.on("uncaughtException", reportFatal); + process.on("unhandledRejection", reportFatal); +} + +export const paths = { + skillRoot: path.resolve(scriptsDir, ".."), + sourceApp: path.resolve(scriptsDir, "..", "assets", "app"), + projectRoot: path.resolve(scriptsDir, "..", "..", ".."), + installRoot: path.resolve(process.env.SALES_WORKBENCH_HOME + || path.join(os.homedir(), ".local", "share", "sales-intelligence-workbench")), + configDir: path.resolve(process.env.SALES_WORKBENCH_CONFIG_HOME + || path.join(os.homedir(), ".config", "sales-intelligence-workbench")), + stateDir: path.resolve(process.env.SALES_WORKBENCH_STATE_HOME + || path.join(os.homedir(), ".local", "state", "sales-intelligence-workbench")), +}; + +paths.installedApp = path.join(paths.installRoot, "app"); +paths.credentialsFile = path.join(paths.configDir, "credentials.env"); +paths.runtimeFile = path.join(paths.configDir, "runtime.env"); +paths.runDir = path.join(paths.stateDir, "run"); +paths.logDir = path.join(paths.stateDir, "logs"); +paths.backupDir = path.join(paths.stateDir, "backups"); +paths.pidFile = path.join(paths.runDir, "server.pid"); +paths.logFile = path.join(paths.logDir, "server.log"); +paths.workerPidFile = path.join(paths.runDir, "worker.pid"); +paths.workerLogFile = path.join(paths.logDir, "worker.log"); +paths.liveDoctorFile = path.join(paths.stateDir, "doctor-live.json"); +paths.lastDoctorFile = path.join(paths.stateDir, "doctor-last.json"); +paths.cliSessionFile = path.join(paths.stateDir, "cli-session.json"); +paths.builderBriefFile = path.join(paths.stateDir, "builder-brief.json"); +paths.historyImportReceiptFile = path.join(paths.stateDir, "history-import-receipt.json"); +paths.businessAcceptanceFile = path.join(paths.stateDir, "business-acceptance.json"); +Object.freeze(paths); + +export const SECRET_KEYS = Object.freeze([ + "AGENT_PLAN_API_KEY", + "MODEL_API_KEY", + "DATAPRO_API_KEY", + "WEB_SEARCH_API_KEY", + "OPENVIKING_API_KEY", + "VOLCENGINE_ACCESS_KEY", + "VOLCENGINE_SECRET_KEY", + "SUPABASE_SERVICE_ROLE_KEY", +]); + +export const RUNTIME_KEYS = Object.freeze([ + "REPOSITORY_MODE", + "HOST", + "PORT", + "HTTP_AUTH_ENABLED", + "AUTH_BOOTSTRAP_ENABLED", + "AUTH_COOKIE_SECURE", + "AUTH_PROVIDER_TIMEOUT_MS", + "AUTH_SESSION_CACHE_TTL_MS", + "AUTH_REFRESH_COOKIE_MAX_AGE", + "ALLOWED_ORIGINS", + "TRUST_PROXY", + "API_MAX_BODY_BYTES", + "API_RATE_LIMIT_PER_MIN", + "API_WRITE_RATE_LIMIT_PER_MIN", + "API_PAID_RATE_LIMIT_PER_MIN", + "AUTH_RATE_LIMIT_PER_15_MIN", + "PAID_WORKFLOW_MAX_CONCURRENCY", + "PAID_WORKFLOW_DAILY_LIMIT", + "PAID_WORKFLOW_BUDGET_TIMEZONE", + "PAID_WORKFLOW_STALE_AFTER_SECONDS", + "ASYNC_JOBS_ENABLED", + "JOB_WORKER_POLL_MS", + "JOB_WORKER_LEASE_SECONDS", + "PROVIDER_CIRCUIT_BREAKER_ENABLED", + "PROVIDER_CIRCUIT_BREAKER_FAILURE_THRESHOLD", + "PROVIDER_CIRCUIT_BREAKER_COOLDOWN_SECONDS", + "LIVE_PROBE_COMPANY", + "DATAPRO_MCP_URL", + "DATAPRO_RUN_ENABLED", + "DATAPRO_MAX_SOURCES", + "DATAPRO_TIMEOUT_MS", + "DATAPRO_MAX_RETRIES", + "WEB_SEARCH_BASE_URL", + "WEB_SEARCH_TRAFFIC_TAG", + "WEB_SEARCH_RUN_ENABLED", + "WEB_SEARCH_MAX_COUNT", + "WEB_SEARCH_TIMEOUT_MS", + "WEB_SEARCH_MAX_RETRIES", + "MODEL_BASE_URL", + "MODEL_NAME", + "MODEL_RUN_ENABLED", + "MODEL_MAX_CARDS", + "MODEL_MAX_TOKENS", + "MODEL_TIMEOUT_MS", + "MODEL_MAX_RETRIES", + "DOSSIER_AGENT_MAX_CALLS", + "DOSSIER_CHECKPOINT_TTL_MS", + "DOSSIER_DATAPRO_CONCURRENCY", + "DOSSIER_WEB_CONCURRENCY", + "OPENVIKING_BASE_URL", + "OPENVIKING_CLI", + "OPENVIKING_CLI_CONFIG", + "OPENVIKING_AGENT_ID", + "OPENVIKING_RESOURCE_ID", + "OPENVIKING_COLLECTION_NAME", + "OPENVIKING_RUN_ENABLED", + "OPENVIKING_SALES_ROOT_URI", + "OPENVIKING_FIND_LIMIT", + "OPENVIKING_TIMEOUT_MS", + "OPENVIKING_QA_AUTO_COMMIT_EVERY", + "OPENVIKING_QA_KEEP_RECENT_MESSAGES", + "VOLCENGINE_REGION", + "SUPABASE_WORKSPACE_ID", + "SUPABASE_BRANCH_ID", + "SUPABASE_API_URL", + "SUPABASE_DATA_API_TIMEOUT_MS", + "SUPABASE_READ_ONLY", + "SUPABASE_RUN_ENABLED", + "SUPABASE_CLI_BIN", + "SUPABASE_CLI_PROFILE", + "SUPABASE_TIMEOUT_MS", + "APP_WORKSPACE_ID", + "APP_WORKSPACE_SLUG", + "APP_WORKSPACE_NAME", + "APP_WORKSPACE_PLAN_MODE", + "FEISHU_SYNC_ENABLED", + "FEISHU_CLI_IMPORT_ENABLED", + "FEISHU_CLI_IMPORT_TASK_LIMIT", + "LIVE_DOCTOR_TTL_MS", +]); + +const RUNTIME_DEFAULTS = Object.freeze({ + REPOSITORY_MODE: "supabase", + HOST: "127.0.0.1", + PORT: "8787", + HTTP_AUTH_ENABLED: "true", + AUTH_BOOTSTRAP_ENABLED: "true", + AUTH_COOKIE_SECURE: "false", + AUTH_PROVIDER_TIMEOUT_MS: "12000", + AUTH_SESSION_CACHE_TTL_MS: "15000", + AUTH_REFRESH_COOKIE_MAX_AGE: "31536000", + ALLOWED_ORIGINS: "", + TRUST_PROXY: "false", + API_MAX_BODY_BYTES: "1048576", + API_RATE_LIMIT_PER_MIN: "180", + API_WRITE_RATE_LIMIT_PER_MIN: "60", + API_PAID_RATE_LIMIT_PER_MIN: "12", + AUTH_RATE_LIMIT_PER_15_MIN: "20", + PAID_WORKFLOW_MAX_CONCURRENCY: "2", + PAID_WORKFLOW_DAILY_LIMIT: "100", + PAID_WORKFLOW_BUDGET_TIMEZONE: "Asia/Shanghai", + PAID_WORKFLOW_STALE_AFTER_SECONDS: "1800", + ASYNC_JOBS_ENABLED: "true", + JOB_WORKER_POLL_MS: "1000", + JOB_WORKER_LEASE_SECONDS: "600", + PROVIDER_CIRCUIT_BREAKER_ENABLED: "true", + PROVIDER_CIRCUIT_BREAKER_FAILURE_THRESHOLD: "5", + PROVIDER_CIRCUIT_BREAKER_COOLDOWN_SECONDS: "60", + LIVE_PROBE_COMPANY: "北京火山引擎科技有限公司", + DATAPRO_MCP_URL: "https://datapro.hqd.cn-beijing.volces.com/mcp", + DATAPRO_MAX_SOURCES: "4", + DATAPRO_TIMEOUT_MS: "45000", + DATAPRO_MAX_RETRIES: "1", + WEB_SEARCH_BASE_URL: "https://open.feedcoopapi.com/search_api/web_search", + WEB_SEARCH_TRAFFIC_TAG: "skill_web_search_common", + WEB_SEARCH_MAX_COUNT: "3", + WEB_SEARCH_TIMEOUT_MS: "20000", + WEB_SEARCH_MAX_RETRIES: "1", + MODEL_BASE_URL: "https://ark.cn-beijing.volces.com/api/plan/v3", + MODEL_NAME: "ark-code-latest", + MODEL_MAX_CARDS: "2", + MODEL_MAX_TOKENS: "700", + MODEL_TIMEOUT_MS: "90000", + MODEL_MAX_RETRIES: "1", + DOSSIER_AGENT_MAX_CALLS: "3", + DOSSIER_CHECKPOINT_TTL_MS: "1800000", + DOSSIER_DATAPRO_CONCURRENCY: "2", + DOSSIER_WEB_CONCURRENCY: "3", + OPENVIKING_AGENT_ID: "default", + OPENVIKING_SALES_ROOT_URI: "viking://resources/sales-workbench", + OPENVIKING_FIND_LIMIT: "3", + OPENVIKING_TIMEOUT_MS: "120000", + OPENVIKING_QA_AUTO_COMMIT_EVERY: "4", + OPENVIKING_QA_KEEP_RECENT_MESSAGES: "6", + VOLCENGINE_REGION: "cn-beijing", + SUPABASE_DATA_API_TIMEOUT_MS: "15000", + SUPABASE_READ_ONLY: "false", + SUPABASE_CLI_BIN: "byted-supabase-cli", + SUPABASE_CLI_PROFILE: "current", + SUPABASE_TIMEOUT_MS: "30000", + APP_WORKSPACE_SLUG: "default", + APP_WORKSPACE_NAME: "Sales Workbench", + APP_WORKSPACE_PLAN_MODE: "agent_plan", + FEISHU_SYNC_ENABLED: "false", + FEISHU_CLI_IMPORT_ENABLED: "false", + FEISHU_CLI_IMPORT_TASK_LIMIT: "100", + LIVE_DOCTOR_TTL_MS: "900000", +}); + +export function ensureDirectories() { + for (const directory of [ + paths.installRoot, + paths.configDir, + paths.stateDir, + paths.runDir, + paths.logDir, + paths.backupDir, + ]) { + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + fs.chmodSync(directory, 0o700); + } +} + +function parseEnvValue(value) { + const trimmed = value.trim(); + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + try { + return JSON.parse(trimmed); + } catch { + return trimmed.slice(1, -1); + } + } + if (trimmed.startsWith("'") && trimmed.endsWith("'")) return trimmed.slice(1, -1); + return trimmed; +} + +export function parseEnvFile(filePath) { + try { + const values = {}; + for (const rawLine of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#") || !line.includes("=")) continue; + const index = line.indexOf("="); + const key = line.slice(0, index).trim(); + if (!/^[A-Z][A-Z0-9_]*$/.test(key)) continue; + values[key] = parseEnvValue(line.slice(index + 1)); + } + return values; + } catch (error) { + if (error.code === "ENOENT") return {}; + throw error; + } +} + +function writeEnvFile(filePath, heading, keys, values) { + const lines = [heading]; + for (const key of keys) { + if (values[key] === undefined || values[key] === null || values[key] === "") continue; + lines.push(`${key}=${JSON.stringify(String(values[key]))}`); + } + lines.push(""); + fs.writeFileSync(filePath, lines.join("\n"), { mode: 0o600 }); + fs.chmodSync(filePath, 0o600); +} + +function firstValue(values, keys) { + for (const key of keys) { + if (values[key] !== undefined && values[key] !== null && values[key] !== "") return String(values[key]); + } + return ""; +} + +function configuredFlag(values, key, configured) { + if (values[key] !== undefined && values[key] !== "") return String(values[key]); + return configured ? "true" : "false"; +} + +export function readConfiguration() { + return { + ...parseEnvFile(paths.runtimeFile), + ...parseEnvFile(paths.credentialsFile), + }; +} + +export function writeConfiguration(inputValues) { + ensureDirectories(); + const current = readConfiguration(); + const values = { ...current, ...inputValues }; + const hasInput = (key) => Object.hasOwn(inputValues, key); + const explicitPlanKey = hasInput("AGENT_PLAN_API_KEY"); + const agentPlanKey = firstValue(values, [ + "AGENT_PLAN_API_KEY", + "MODEL_API_KEY", + "ARK_API_KEY", + "VOLCENGINE_ARK_API_KEY", + "DATAPRO_API_KEY", + "WEB_SEARCH_API_KEY", + ]); + const capabilityOverride = (key, aliases = []) => { + if (hasInput(key)) return String(inputValues[key] || ""); + if (explicitPlanKey) return ""; + return firstValue(values, [key, ...aliases]); + }; + const credentialValues = { + AGENT_PLAN_API_KEY: agentPlanKey, + MODEL_API_KEY: capabilityOverride("MODEL_API_KEY", ["ARK_API_KEY", "VOLCENGINE_ARK_API_KEY"]), + DATAPRO_API_KEY: capabilityOverride("DATAPRO_API_KEY"), + WEB_SEARCH_API_KEY: capabilityOverride("WEB_SEARCH_API_KEY", ["ASK_ECHO_SEARCH_INFINITY_API_KEY"]), + OPENVIKING_API_KEY: capabilityOverride("OPENVIKING_API_KEY", ["OPENVIKING_BEARER_TOKEN"]), + VOLCENGINE_ACCESS_KEY: firstValue(values, ["VOLCENGINE_ACCESS_KEY"]), + VOLCENGINE_SECRET_KEY: firstValue(values, ["VOLCENGINE_SECRET_KEY"]), + SUPABASE_SERVICE_ROLE_KEY: firstValue(values, ["SUPABASE_SERVICE_ROLE_KEY"]), + }; + const runtimeValues = { ...RUNTIME_DEFAULTS }; + for (const key of RUNTIME_KEYS) { + if (values[key] !== undefined && values[key] !== "") runtimeValues[key] = String(values[key]); + } + if (!runtimeValues.APP_WORKSPACE_ID) runtimeValues.APP_WORKSPACE_ID = randomUUID(); + if (!runtimeValues.ALLOWED_ORIGINS) { + const port = Number(runtimeValues.PORT) || 8787; + runtimeValues.ALLOWED_ORIGINS = `http://127.0.0.1:${port},http://localhost:${port}`; + } + runtimeValues.REPOSITORY_MODE = "supabase"; + runtimeValues.SUPABASE_READ_ONLY = "false"; + runtimeValues.MODEL_RUN_ENABLED = configuredFlag(values, "MODEL_RUN_ENABLED", Boolean(credentialValues.MODEL_API_KEY || agentPlanKey)); + runtimeValues.DATAPRO_RUN_ENABLED = configuredFlag(values, "DATAPRO_RUN_ENABLED", Boolean(credentialValues.DATAPRO_API_KEY || agentPlanKey)); + runtimeValues.WEB_SEARCH_RUN_ENABLED = configuredFlag(values, "WEB_SEARCH_RUN_ENABLED", Boolean(credentialValues.WEB_SEARCH_API_KEY || agentPlanKey)); + runtimeValues.OPENVIKING_RUN_ENABLED = configuredFlag( + values, + "OPENVIKING_RUN_ENABLED", + Boolean( + (credentialValues.OPENVIKING_API_KEY && runtimeValues.OPENVIKING_BASE_URL) + || openVikingCliConfiguration(runtimeValues).ready + ), + ); + runtimeValues.SUPABASE_RUN_ENABLED = configuredFlag( + values, + "SUPABASE_RUN_ENABLED", + Boolean( + runtimeValues.SUPABASE_API_URL + && credentialValues.SUPABASE_SERVICE_ROLE_KEY + && runtimeValues.APP_WORKSPACE_ID + ), + ); + writeEnvFile(paths.credentialsFile, "# 销售智能工作台私密凭证。不要提交此文件。", SECRET_KEYS, credentialValues); + writeEnvFile(paths.runtimeFile, "# 销售智能工作台非敏感运行配置。", RUNTIME_KEYS, runtimeValues); + return { credentials: credentialValues, runtime: runtimeValues }; +} + +export function assertNodeVersion() { + const major = Number(process.versions.node.split(".")[0]); + if (!Number.isFinite(major) || major < 20) { + throw new Error(`需要 Node.js 20 或更高版本,当前为 ${process.versions.node}`); + } +} + +export function assertAppSource(sourcePath) { + const resolved = path.resolve(sourcePath); + const required = [ + "backend/package.json", + "backend/src/server.js", + "frontend/index.html", + "frontend/app.js", + "supabase/migrations", + ]; + const missing = required.filter((relative) => !fs.existsSync(path.join(resolved, relative))); + if (missing.length) throw new Error(`不是完整的销售智能工作台应用包:缺少 ${missing.join("、")}`); + return resolved; +} + +export function assertInstalledApp() { + return assertAppSource(paths.installedApp); +} + +export function appCopyFilter(rootDir, sourcePath) { + const relative = path.relative(rootDir, sourcePath); + if (!relative) return true; + const segments = relative.split(path.sep); + const first = segments[0]; + if (!["backend", "frontend", "supabase"].includes(first)) return false; + if (segments.some((segment) => ["node_modules", ".git", ".temp", "coverage", "backups"].includes(segment))) return false; + const name = path.basename(sourcePath); + if (name === ".DS_Store" || name === ".env.local") return false; + if (name.startsWith(".env.") && name !== ".env.example") return false; + return !/\.(?:log|pid)$/i.test(name); +} + +export function readOption(name, args = process.argv.slice(2)) { + const index = args.indexOf(name); + if (index < 0) return null; + const value = args[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${name} 缺少参数值。`); + return value; +} + +export function resolveUserPath(value) { + if (!value) return null; + if (value === "~") return os.homedir(); + if (value.startsWith(`~${path.sep}`)) return path.join(os.homedir(), value.slice(2)); + return path.resolve(value); +} + +export function openVikingCliConfiguration(values = readConfiguration()) { + const configuredPath = values.OPENVIKING_CLI_CONFIG || "~/.openviking/ovcli.conf"; + const configPath = resolveUserPath(configuredPath); + try { + const parsed = JSON.parse(fs.readFileSync(configPath, "utf8")); + const url = String(parsed?.url || parsed?.base_url || "").trim(); + const apiKeyPresent = Boolean(String(parsed?.api_key || "").trim()); + return { + path: configPath, + ready: Boolean(url && apiKeyPresent), + url: url || null, + agent_id: String(parsed?.agent_id || "").trim() || null, + }; + } catch { + return { + path: configPath, + ready: false, + url: null, + agent_id: null, + }; + } +} + +export function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + env: options.env || process.env, + stdio: options.stdio || "inherit", + encoding: options.encoding, + input: options.input, + }); + if (result.error) throw result.error; + if (result.status !== 0 && !options.allowFailure) { + throw new Error(`${command} 执行失败,退出码 ${result.status}`); + } + return result; +} + +export function commandExists(command) { + const result = spawnSync(command, ["--version"], { stdio: "ignore" }); + return !result.error && result.status === 0; +} + +export function readPid(filePath = paths.pidFile) { + try { + const pid = Number(fs.readFileSync(filePath, "utf8").trim()); + return Number.isInteger(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +export function processExists(pid) { + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +export function runtimeEnvironment(overrides = {}) { + const configuration = readConfiguration(); + return { + ...configuration, + ...process.env, + FRONTEND_DIR: path.join(paths.installedApp, "frontend"), + SALES_WORKBENCH_STATE_DIR: paths.stateDir, + SALES_WORKBENCH_BACKUP_DIR: paths.backupDir, + SALES_WORKBENCH_LIVE_DOCTOR_FILE: paths.lastDoctorFile, + ...overrides, + }; +} + +export function serverAddress() { + const configuration = readConfiguration(); + const host = process.env.HOST || configuration.HOST || "127.0.0.1"; + const port = Number(process.env.PORT || configuration.PORT || 8787); + const browserHost = ["0.0.0.0", "::"].includes(host) ? "127.0.0.1" : host; + return { host, port, url: `http://${browserHost}:${port}` }; +} + +export async function waitForHealth(url, timeoutMs = 20_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const response = await fetch(`${url}/api/health`); + if (response.ok) return true; + } catch { + // The process may still be starting. + } + await new Promise((resolve) => setTimeout(resolve, 300)); + } + return false; +} + +export function credentialFileIsPrivate() { + try { + return (fs.statSync(paths.credentialsFile).mode & 0o077) === 0; + } catch { + return false; + } +} + +export function writePrivateJson(filePath, value) { + ensureDirectories(); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + fs.chmodSync(filePath, 0o600); +} + +export function liveDoctorEvidence() { + try { + const report = JSON.parse(fs.readFileSync(paths.liveDoctorFile, "utf8")); + const finishedAt = Date.parse(report.finished_at || report.checked_at || ""); + const ttlMs = Number(readConfiguration().LIVE_DOCTOR_TTL_MS || 900000); + const ageMs = Number.isFinite(finishedAt) ? Date.now() - finishedAt : Number.POSITIVE_INFINITY; + return { + exists: true, + fresh: Boolean(report.runtime_ready && ageMs >= 0 && ageMs <= ttlMs), + age_ms: Number.isFinite(ageMs) ? ageMs : null, + ttl_ms: ttlMs, + report, + }; + } catch { + return { exists: false, fresh: false, age_ms: null, ttl_ms: Number(readConfiguration().LIVE_DOCTOR_TTL_MS || 900000), report: null }; + } +} + +export function configurationSummary() { + const values = readConfiguration(); + const hasAgentPlanKey = Boolean(values.AGENT_PLAN_API_KEY); + const openVikingCli = openVikingCliConfiguration(values); + return { + repository_mode: values.REPOSITORY_MODE || "supabase", + http_auth: String(values.HTTP_AUTH_ENABLED || "false").toLowerCase() === "true", + async_jobs: String(values.ASYNC_JOBS_ENABLED || "false").toLowerCase() === "true", + worker_lease_seconds: Number(values.JOB_WORKER_LEASE_SECONDS || 0), + cli_session: fs.existsSync(paths.cliSessionFile), + model: Boolean(values.MODEL_API_KEY || hasAgentPlanKey), + datapro: Boolean(values.DATAPRO_API_KEY || hasAgentPlanKey), + web_search: Boolean(values.WEB_SEARCH_API_KEY || hasAgentPlanKey), + openviking: Boolean( + (values.OPENVIKING_BASE_URL && values.OPENVIKING_API_KEY) + || openVikingCli.ready + ), + supabase_data_api: Boolean(values.SUPABASE_API_URL && values.SUPABASE_SERVICE_ROLE_KEY && values.APP_WORKSPACE_ID), + supabase_control_plane: Boolean(values.VOLCENGINE_ACCESS_KEY && values.VOLCENGINE_SECRET_KEY), + feishu_sync: [values.FEISHU_CLI_IMPORT_ENABLED, values.FEISHU_SYNC_ENABLED] + .some((value) => String(value || "false").toLowerCase() === "true"), + }; +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/login.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/login.mjs new file mode 100644 index 00000000..c0c1a119 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/login.mjs @@ -0,0 +1,119 @@ +import fs from "node:fs"; +import { createInterface } from "node:readline/promises"; + +import { + ensureDirectories, + paths, + readOption, + serverAddress, + writePrivateJson, +} from "./lib.mjs"; + +function hasFlag(name) { + return process.argv.slice(2).includes(name); +} + +async function promptLine(label) { + const reader = createInterface({ input: process.stdin, output: process.stdout }); + try { + return String(await reader.question(label)).trim(); + } finally { + reader.close(); + } +} + +async function promptSecret(label) { + if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== "function") { + throw new Error("当前终端不支持隐藏输入;请同时使用 --username 和 --password-stdin。"); + } + return new Promise((resolve, reject) => { + let value = ""; + const cleanup = () => { + process.stdin.off("data", onData); + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdout.write("\n"); + }; + const onData = (chunk) => { + for (const character of String(chunk)) { + if (character === "\u0003") { + cleanup(); + reject(new Error("已取消登录。")); + return; + } + if (character === "\r" || character === "\n") { + cleanup(); + resolve(value); + return; + } + if (character === "\u007f" || character === "\b") { + if (value) { + value = [...value].slice(0, -1).join(""); + process.stdout.write("\b \b"); + } + continue; + } + if (character >= " ") { + value += character; + process.stdout.write("*"); + } + } + }; + process.stdout.write(label); + process.stdin.setEncoding("utf8"); + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on("data", onData); + }); +} + +function parseResponse(text) { + try { + return text ? JSON.parse(text) : {}; + } catch { + return {}; + } +} + +async function main() { + ensureDirectories(); + const apiUrl = readOption("--api-url") || serverAddress().url; + let username = readOption("--username") || readOption("--email") || ""; + if (!username) username = await promptLine("工作台用户名:"); + const password = hasFlag("--password-stdin") + ? fs.readFileSync(0, "utf8").replace(/[\r\n]+$/, "") + : await promptSecret("工作台登录密码:"); + + const response = await fetch(`${apiUrl.replace(/\/$/, "")}/api/auth/cli-login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + const payload = parseResponse(await response.text()); + if (!response.ok) { + throw new Error(payload?.error?.message || `工作台登录失败(HTTP ${response.status})。`); + } + const session = payload.data || payload; + if (!session.access_token || !session.refresh_token) { + throw new Error("服务端未返回有效的 CLI 会话。"); + } + const issuedAt = Date.now(); + writePrivateJson(paths.cliSessionFile, { + api_url: apiUrl.replace(/\/$/, ""), + token_type: "bearer", + access_token: session.access_token, + refresh_token: session.refresh_token, + expires_in: Number(session.expires_in) || 3600, + issued_at: new Date(issuedAt).toISOString(), + expires_at: new Date(issuedAt + (Number(session.expires_in) || 3600) * 1000).toISOString(), + user: { + id: session.user?.id || "", + username: session.user?.username || username, + display_name: session.user?.display_name || username, + }, + }); + console.log(`CLI 登录成功:${session.user?.display_name || username}`); + console.log(`会话已以 0600 权限保存到:${paths.cliSessionFile}`); +} + +main(); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/logout.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/logout.mjs new file mode 100644 index 00000000..63271b89 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/logout.mjs @@ -0,0 +1,13 @@ +import fs from "node:fs"; + +import { paths } from "./lib.mjs"; + +let removed = false; +try { + fs.unlinkSync(paths.cliSessionFile); + removed = true; +} catch (error) { + if (error.code !== "ENOENT") throw error; +} + +console.log(removed ? "已删除本机 CLI 登录会话。" : "本机没有已保存的 CLI 登录会话。"); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/migrate.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/migrate.mjs new file mode 100644 index 00000000..bae0dc10 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/migrate.mjs @@ -0,0 +1,25 @@ +import path from "node:path"; +import { + assertAppSource, + assertInstalledApp, + readOption, + resolveUserPath, + run, + runtimeEnvironment, +} from "./lib.mjs"; + +const sourceValue = readOption("--source"); +const appRoot = sourceValue ? assertAppSource(resolveUserPath(sourceValue)) : assertInstalledApp(); +const apply = process.argv.includes("--apply"); +const args = [path.join(appRoot, "backend", "scripts", "migrate-supabase.mjs")]; +if (apply) args.push("--apply"); + +process.stdout.write(apply + ? `正在从 ${appRoot} 应用版本化数据库迁移;不会执行未登记的临时 SQL。\n` + : `正在从 ${appRoot} 只读检查数据库迁移版本;添加 --apply 才会写入。\n`); +const result = run(process.execPath, args, { + cwd: path.join(appRoot, "backend"), + env: runtimeEnvironment(), + allowFailure: true, +}); +if (result.status !== 0) process.exitCode = result.status; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/onboard.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/onboard.mjs new file mode 100644 index 00000000..c559dd8d --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/onboard.mjs @@ -0,0 +1,269 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; + +import { + assertNodeVersion, + paths, + run, +} from "./lib.mjs"; + +function usage() { + return ` +销售智能工作台安全编排 + +首次记录业务范围并推进安全步骤: + node onboard.mjs \\ + --workspace-name <工作台名称> \\ + --sales-goal <销售目标> \\ + --target-scope <行业、区域或客户范围> \\ + --sources feishu_docs,feishu_chats \\ + --deployment local + +继续上次搭建: + node onboard.mjs + +可选参数: + --from-env-file <路径> 从现有私密环境文件迁移配置 + --apply-supabase --yes 用户确认后初始化指定的 Agent Plan Supabase + --supabase-workspace-id + --supabase-branch-id + --supabase-profile + --apply-openviking 复用已有 Agent Plan OpenViking 记忆库 + --openviking-resource-id + --openviking-collection-name <英文名称> 不存在时与 --yes 一起创建 + --confirm-live 用户知情后执行会产生少量用量的真实诊断 + +说明: + - 默认自动执行本地安装、配置引导和启动等可恢复步骤。 + - 遇到云资源写入、真实 Provider 调用、用户登录、飞书导入或业务验收时会暂停。 + - 用户只输入一枚 Agent Plan Key;OpenViking 与 Supabase 内部连接信息由脚本自动获取和保存。 + - 不会擅自创建、暂停或删除云资源,也不会自动执行付费业务验收。 +`; +} + +function parseArgs(argv) { + const options = { + help: false, + applySupabase: false, + applyOpenViking: false, + yes: false, + confirmLive: false, + workspaceName: "", + salesGoal: "", + targetScope: "", + sources: "", + deployment: "", + fromEnvFile: "", + openVikingResourceId: "", + openVikingCollectionName: "", + supabaseWorkspaceId: "", + supabaseBranchId: "", + supabaseProfile: "", + }; + const flags = new Map([ + ["--help", "help"], + ["-h", "help"], + ["--apply-supabase", "applySupabase"], + ["--apply-openviking", "applyOpenViking"], + ["--yes", "yes"], + ["--confirm-live", "confirmLive"], + ]); + const values = new Map([ + ["--workspace-name", "workspaceName"], + ["--sales-goal", "salesGoal"], + ["--target-scope", "targetScope"], + ["--sources", "sources"], + ["--deployment", "deployment"], + ["--from-env-file", "fromEnvFile"], + ["--openviking-resource-id", "openVikingResourceId"], + ["--openviking-collection-name", "openVikingCollectionName"], + ["--supabase-workspace-id", "supabaseWorkspaceId"], + ["--supabase-branch-id", "supabaseBranchId"], + ["--supabase-profile", "supabaseProfile"], + ]); + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (flags.has(argument)) { + options[flags.get(argument)] = true; + } else if (values.has(argument)) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${argument} 缺少参数值。`); + options[values.get(argument)] = value.trim(); + index += 1; + } else { + throw new Error(`未知参数:${argument}`); + } + } + return options; +} + +function readSetupReport(setupScript) { + const result = spawnSync(process.execPath, [setupScript, "--json"], { + env: process.env, + encoding: "utf8", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`无法读取搭建进度:${result.stderr.trim() || `退出码 ${result.status}`}`); + } + try { + return JSON.parse(result.stdout); + } catch { + throw new Error("setup.mjs 未返回有效的 JSON 进度。"); + } +} + +function printCheckpoint(report) { + process.stdout.write(`\n已安全暂停在“${report.next_action.stage}”阶段。\n`); + process.stdout.write(`原因:${report.next_action.reason}\n`); + process.stdout.write(`下一步:${report.next_action.command}\n`); +} + +assertNodeVersion(); +const options = parseArgs(process.argv.slice(2)); +if (options.help) { + process.stdout.write(usage().trimStart()); + process.exit(0); +} +if (options.applySupabase && !options.yes) { + throw new Error("--apply-supabase 会写入目标数据库,必须同时提供 --yes。"); +} +if (options.openVikingResourceId && options.openVikingCollectionName) { + throw new Error("--openviking-resource-id 与 --openviking-collection-name 只能选择一个。"); +} + +const scripts = path.join(paths.skillRoot, "scripts"); +const setupScript = path.join(scripts, "setup.mjs"); +const hasBriefArguments = [ + options.workspaceName, + options.salesGoal, + options.targetScope, + options.sources, + options.deployment, +].some(Boolean); + +if (hasBriefArguments) { + if (!options.workspaceName || !options.salesGoal) { + throw new Error("初始化业务范围时,--workspace-name 和 --sales-goal 必须同时提供。"); + } + run(process.execPath, [ + setupScript, + "--init", + "--workspace-name", options.workspaceName, + "--sales-goal", options.salesGoal, + "--target-scope", options.targetScope, + "--sources", options.sources || "feishu_docs,feishu_chats", + "--deployment", options.deployment || "local", + ]); +} + +const attempted = new Set(); +for (let step = 0; step < 12; step += 1) { + const report = readSetupReport(setupScript); + const phase = report.next_action.stage; + process.stdout.write( + `\n搭建进度 ${report.progress.complete}/${report.progress.total},当前阶段:${phase}。\n` + + `为什么做:${report.next_action.reason}\n`, + ); + + if (phase === "ready") { + process.stdout.write(`销售智能工作台已通过阶段验收。运行地址和进程状态请查看:\n`); + process.stdout.write(`node ${path.join(scripts, "status.mjs")}\n`); + process.exit(0); + } + if (attempted.has(phase)) { + process.stdout.write("本次操作后阶段仍未通过,请按下方提示处理具体配置或权限问题。\n"); + printCheckpoint(report); + process.exit(0); + } + + if (phase === "brief") { + printCheckpoint(report); + process.exit(0); + } + + attempted.add(phase); + if (phase === "app") { + run(process.execPath, [path.join(scripts, "install.mjs")]); + continue; + } + + if (phase === "agent_plan") { + if (options.fromEnvFile) { + run(process.execPath, [ + path.join(scripts, "configure.mjs"), + "--from-env-file", options.fromEnvFile, + ]); + continue; + } + if (process.stdin.isTTY && process.stdout.isTTY) { + run(process.execPath, [path.join(scripts, "configure.mjs")]); + continue; + } + printCheckpoint(report); + process.exit(0); + } + + if (phase === "openviking") { + const openVikingScript = path.join(scripts, "setup-openviking.mjs"); + if (options.applyOpenViking) { + const argumentsList = [openVikingScript, "--apply"]; + if (options.openVikingResourceId) { + argumentsList.push("--resource-id", options.openVikingResourceId); + } + if (options.openVikingCollectionName) { + argumentsList.push("--collection-name", options.openVikingCollectionName); + } + if (options.yes) argumentsList.push("--yes"); + run(process.execPath, argumentsList); + continue; + } + run(process.execPath, [openVikingScript]); + printCheckpoint(report); + process.stdout.write( + "复用已有记忆库时追加 --apply-openviking;需要新建时再提供英文名称并追加 --yes。\n", + ); + process.exit(0); + } + + if (phase === "supabase") { + if (options.applySupabase) { + const argumentsList = [path.join(scripts, "setup-supabase.mjs"), "--apply", "--yes"]; + if (options.supabaseWorkspaceId) { + argumentsList.push("--workspace-id", options.supabaseWorkspaceId); + } + if (options.supabaseBranchId) { + argumentsList.push("--branch-id", options.supabaseBranchId); + } + if (options.supabaseProfile) { + argumentsList.push("--profile", options.supabaseProfile); + } + run(process.execPath, argumentsList); + continue; + } + printCheckpoint(report); + process.stdout.write("确认目标 Workspace 和持续计费影响后,再追加 --apply-supabase --yes 继续。\n"); + process.exit(0); + } + + if (phase === "live_doctor") { + if (options.confirmLive) { + run(process.execPath, [path.join(scripts, "doctor.mjs"), "--live"]); + continue; + } + printCheckpoint(report); + process.stdout.write("用户知情同意少量 Agent Plan 外部能力用量后,再追加 --confirm-live 继续。\n"); + process.exit(0); + } + + if (phase === "runtime") { + run(process.execPath, [path.join(scripts, "start.mjs")]); + continue; + } + + printCheckpoint(report); + process.exit(0); +} + +throw new Error("安全编排超过最大阶段数,请运行 setup.mjs 查看具体阻塞项。"); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/restore.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/restore.mjs new file mode 100644 index 00000000..b5a0f7ca --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/restore.mjs @@ -0,0 +1,20 @@ +import path from "node:path"; +import { assertInstalledApp, paths, run, runtimeEnvironment } from "./lib.mjs"; + +assertInstalledApp(); +if (!process.argv.includes("--backup-dir")) { + throw new Error("恢复必须显式提供 --backup-dir,并按脚本提示提供独立目标与确认值。"); +} +if (!process.argv.includes("--apply")) { + process.stdout.write("当前为恢复预检,不写入目标;添加 --apply 后才会执行恢复。\n"); +} + +const result = run(process.execPath, [ + path.join(paths.installedApp, "backend", "scripts", "restore-supabase.mjs"), + ...process.argv.slice(2), +], { + cwd: path.join(paths.installedApp, "backend"), + env: runtimeEnvironment(), + allowFailure: true, +}); +if (result.status !== 0) process.exitCode = result.status; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/self-test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/self-test.mjs new file mode 100644 index 00000000..2a77694c --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/self-test.mjs @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sales-workbench-skill-")); +const fixtureEnv = path.join(tempRoot, "fixture.env"); +const fakeOpenVikingCli = path.join(tempRoot, "fake-openviking-control-plane.mjs"); +const fakeOpenVikingApiKey = "test-internal-openviking-key"; +const isolatedEnv = { + ...process.env, + HOME: path.join(tempRoot, "home"), + SALES_WORKBENCH_HOME: path.join(tempRoot, "share"), + SALES_WORKBENCH_CONFIG_HOME: path.join(tempRoot, "config"), + SALES_WORKBENCH_STATE_HOME: path.join(tempRoot, "state"), + OPENVIKING_CONTROL_PLANE_CLI: fakeOpenVikingCli, +}; + +function runScript(name, args = [], { expectSuccess = true } = {}) { + const result = spawnSync(process.execPath, [path.join(scriptsDir, name), ...args], { + env: isolatedEnv, + encoding: "utf8", + }); + if (result.error) throw result.error; + if (expectSuccess && result.status !== 0) { + throw new Error(`${name} 自测失败:${result.stderr || result.stdout}`); + } + return result; +} + +try { + fs.mkdirSync(isolatedEnv.HOME, { recursive: true }); + fs.writeFileSync(fakeOpenVikingCli, `#!/usr/bin/env node +const command = process.argv[2]; +if (process.env.AGENTPLAN_API_KEY !== "test-agent-plan-key") process.exit(3); +if (command === "list") { + process.stdout.write(JSON.stringify([ + { Name: "sales_memory", ResourceID: "ov-self-test", Status: "READY" }, + ])); +} else if (command === "get") { + process.stdout.write(JSON.stringify({ + Name: "sales_memory", + ResourceID: "ov-self-test", + Status: "READY", + })); +} else if (command === "api-key") { + process.stdout.write(JSON.stringify({ + UserID: "default", + Role: "admin", + ApiKey: "${fakeOpenVikingApiKey}", + })); +} else if (command === "create") { + process.stdout.write(JSON.stringify({ + Name: "sales_memory", + ResourceID: "ov-self-test", + Status: "READY", + })); +} else { + process.stderr.write("unsupported command"); + process.exit(2); +} +`, { mode: 0o700 }); + fs.writeFileSync(fixtureEnv, [ + "AGENT_PLAN_API_KEY=test-agent-plan-key", + "VOLCENGINE_ACCESS_KEY=test-access-key", + "VOLCENGINE_SECRET_KEY=test-secret-key", + "SUPABASE_WORKSPACE_ID=test-cloud-workspace", + "SUPABASE_BRANCH_ID=test-branch", + "SUPABASE_API_URL=https://supabase.invalid/rest/v1", + "SUPABASE_SERVICE_ROLE_KEY=test-service-role", + "FEISHU_SYNC_ENABLED=false", + "", + ].join("\n"), { mode: 0o600 }); + + const setupInitial = runScript("setup.mjs", [ + "--init", + "--workspace-name", "隔离测试销售工作台", + "--sales-goal", "验证真实销售资料闭环", + "--target-scope", "获授权测试企业", + "--sources", "none", + "--deployment", "local", + "--json", + ]); + const initialReport = JSON.parse(setupInitial.stdout); + assert.equal(initialReport.stages.find((item) => item.id === "brief")?.status, "complete"); + assert.equal(initialReport.stages.find((item) => item.id === "app")?.status, "pending"); + + runScript("install.mjs"); + runScript("configure.mjs", ["--from-env-file", fixtureEnv]); + const configureSource = fs.readFileSync(path.join(scriptsDir, "configure.mjs"), "utf8"); + assert.doesNotMatch(configureSource, /OpenViking 数据面 API Key|OpenViking 专用 API Key/); + assert.doesNotMatch(configureSource, /hiddenQuestion\(rl, output, "Supabase Service Role Key"/); + assert.doesNotMatch(configureSource, /hiddenQuestion\(rl, output, "火山 (?:Access|Secret) Key/); + assert.doesNotMatch(configureSource, /visibleQuestion\(rl, "Supabase Data API URL"/); + let runtimeConfig = fs.readFileSync(path.join(isolatedEnv.SALES_WORKBENCH_CONFIG_HOME, "runtime.env"), "utf8"); + assert.match(runtimeConfig, /APP_WORKSPACE_ID="[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"/i); + + const openVikingPlan = runScript("setup-openviking.mjs"); + assert.match(openVikingPlan.stdout, /sales_memory/); + assert.match(openVikingPlan.stdout, /ov-self-test/); + assert.match(openVikingPlan.stdout, /--apply --resource-id ov-self-test/); + assert.doesNotMatch(openVikingPlan.stdout + openVikingPlan.stderr, new RegExp(fakeOpenVikingApiKey)); + + const openVikingApply = runScript("setup-openviking.mjs", [ + "--apply", + "--resource-id", "ov-self-test", + ]); + assert.match(openVikingApply.stdout, /用户侧仍只使用 Agent Plan Key/); + assert.doesNotMatch(openVikingApply.stdout + openVikingApply.stderr, new RegExp(fakeOpenVikingApiKey)); + const credentialsPath = path.join(isolatedEnv.SALES_WORKBENCH_CONFIG_HOME, "credentials.env"); + const credentialsConfig = fs.readFileSync(credentialsPath, "utf8"); + assert.match(credentialsConfig, new RegExp(fakeOpenVikingApiKey)); + assert.equal(fs.statSync(credentialsPath).mode & 0o777, 0o600); + runtimeConfig = fs.readFileSync(path.join(isolatedEnv.SALES_WORKBENCH_CONFIG_HOME, "runtime.env"), "utf8"); + assert.match(runtimeConfig, /OPENVIKING_RESOURCE_ID="ov-self-test"/); + assert.match(runtimeConfig, /OPENVIKING_COLLECTION_NAME="sales_memory"/); + assert.match(runtimeConfig, /OPENVIKING_BASE_URL="https:\/\/api\.vikingdb\.cn-beijing\.volces\.com\/openviking"/); + + const realChainHelp = runScript("verify-real-chain.mjs", ["--help"]); + assert.match(realChainHelp.stdout, /查看本帮助不会发起任何 Provider 请求/); + assert.equal( + fs.existsSync(path.join(isolatedEnv.SALES_WORKBENCH_STATE_HOME, "doctor-live.json")), + false, + ); + + const supabasePlan = runScript("setup-supabase.mjs"); + assert.match(supabasePlan.stdout, /当前未写入/); + assert.match(supabasePlan.stdout, /不会创建、暂停或删除云 Workspace/); + runScript("doctor.mjs"); + + const setupConfigured = JSON.parse(runScript("setup.mjs", ["--json"]).stdout); + assert.equal(setupConfigured.stages.find((item) => item.id === "app")?.status, "complete"); + assert.equal(setupConfigured.stages.find((item) => item.id === "agent_plan")?.status, "complete"); + assert.equal(setupConfigured.stages.find((item) => item.id === "supabase")?.status, "complete"); + assert.equal(setupConfigured.stages.find((item) => item.id === "openviking")?.status, "complete"); + assert.equal(setupConfigured.stages.find((item) => item.id === "feishu_cli")?.status, "skipped"); + assert.equal(setupConfigured.stages.find((item) => item.id === "live_doctor")?.status, "pending"); + + const startCheck = runScript("start.mjs", ["--dry-run"]); + assert.match(startCheck.stdout, /启动预检通过/); + assert.match(startCheck.stderr, /没有全绿 live doctor 结果/); + + const status = runScript("status.mjs"); + const parsedStatus = JSON.parse(status.stdout); + assert.equal(parsedStatus.installed, true); + assert.equal(parsedStatus.running, false); + assert.equal(parsedStatus.configuration.repository_mode, "supabase"); + + runScript("uninstall.mjs", ["--purge", "--yes"]); + process.stdout.write("Skill Builder 隔离自测通过:单 Agent Plan Key、OpenViking 与 Supabase 内部凭据不要求用户输入、业务范围、阶段判断、安装、配置、doctor、正式启动预检、状态和卸载均符合预期。\n"); +} finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup-openviking.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup-openviking.mjs new file mode 100644 index 00000000..6b924926 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup-openviking.mjs @@ -0,0 +1,335 @@ +import fs from "node:fs"; +import { spawnSync } from "node:child_process"; + +import { + assertNodeVersion, + commandExists, + configurationSummary, + paths, + readConfiguration, + writeConfiguration, +} from "./lib.mjs"; + +const OFFICIAL_BASE_URL = "https://api.vikingdb.cn-beijing.volces.com/openviking"; +const DEFAULT_WAIT_SECONDS = 900; +const MAX_COLLECTIONS = 20; + +function usage() { + return ` +Agent Plan OpenViking 记忆库初始化 + +只读检查并给出下一步: + node setup-openviking.mjs + +复用指定记忆库: + node setup-openviking.mjs --apply --resource-id + +按名称复用;不存在时创建: + node setup-openviking.mjs --apply --collection-name <英文名称> --yes + +参数: + --apply 保存选中记忆库的内部连接信息 + --resource-id <资源ID> 复用已有记忆库 + --collection-name <名称> 精确匹配已有记忆库,或创建新记忆库 + --yes 确认创建付费云资源;复用已有资源不要求 + --wait-seconds <秒> 等待新资源 READY 的最长时间,默认 900 + +说明: + - 用户只需配置 Agent Plan Key;内部访问凭证由官方控制面自动获取并以 0600 保存。 + - 默认只读,不创建资源、不写配置、不产生新资源费用。 + - 新建 OpenViking 记忆库可能持续计费,且单账号最多 20 个,必须显式提供 --yes。 +`; +} + +function parseArgs(argv) { + const options = { + apply: false, + yes: false, + help: false, + resourceId: "", + collectionName: "", + waitSeconds: DEFAULT_WAIT_SECONDS, + }; + const valueOptions = new Map([ + ["--resource-id", "resourceId"], + ["--collection-name", "collectionName"], + ["--wait-seconds", "waitSeconds"], + ]); + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--apply") options.apply = true; + else if (argument === "--yes") options.yes = true; + else if (argument === "--help" || argument === "-h") options.help = true; + else if (valueOptions.has(argument)) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${argument} 缺少参数值。`); + options[valueOptions.get(argument)] = value.trim(); + index += 1; + } else { + throw new Error(`未知参数:${argument}`); + } + } + options.waitSeconds = Number(options.waitSeconds); + if (!Number.isFinite(options.waitSeconds) || options.waitSeconds < 5 || options.waitSeconds > 3600) { + throw new Error("--wait-seconds 必须是 5 到 3600 之间的秒数。"); + } + if (options.resourceId && options.collectionName) { + throw new Error("--resource-id 与 --collection-name 只能选择一个。"); + } + return options; +} + +function redact(value, secrets = []) { + let text = String(value || ""); + for (const secret of secrets.filter(Boolean)) text = text.split(secret).join("[REDACTED]"); + return text + .replace(/("?(?:ApiKey|api_key)"?\s*[:=]\s*")[^"]+(")/gi, "$1[REDACTED]$2") + .replace(/\bark-[A-Za-z0-9-]{20,}\b/g, "[REDACTED]"); +} + +function parseJsonOutput(stdout, label) { + const text = String(stdout || "").trim(); + if (!text) throw new Error(`${label} 未返回数据。`); + try { + return JSON.parse(text); + } catch { + const starts = [text.indexOf("{"), text.indexOf("[")].filter((index) => index >= 0); + const start = starts.length ? Math.min(...starts) : -1; + const end = Math.max(text.lastIndexOf("}"), text.lastIndexOf("]")); + if (start >= 0 && end > start) { + try { + return JSON.parse(text.slice(start, end + 1)); + } catch { + // Continue to the safe error below. + } + } + throw new Error(`${label} 返回格式无法识别。`); + } +} + +function controlPlaneCommand() { + const override = String(process.env.OPENVIKING_CONTROL_PLANE_CLI || "").trim(); + if (override) return { command: override, prefix: [] }; + if (commandExists("ov-cp")) return { command: "ov-cp", prefix: [] }; + if (commandExists("uvx")) { + return { + command: "uvx", + prefix: ["--from", "mcp-server-openviking-controlplane", "ov-cp"], + }; + } + throw new Error( + "未找到官方 OpenViking 控制面命令。请先安装 uv(提供 uvx),然后重新运行本脚本。", + ); +} + +function mapControlPlaneError(raw, secrets) { + const message = redact(raw, secrets); + if (/ProductUnordered/i.test(message)) { + return "当前 Agent Plan 尚未开通 Agent 记忆(OpenViking)。请在控制台对应能力卡片完成开通后重试。"; + } + if (/Unauthorized|Forbidden|Invalid.*Key|Authentication/i.test(message)) { + return "Agent Plan Key 无效、已过期或无权管理 OpenViking,请更新套餐 Key 后重试。"; + } + if (/20|limit|quota|maximum/i.test(message)) { + return "OpenViking 记忆库数量可能已达账号上限(20 个)。请复用已有记忆库,或在控制台清理闲置资源后重试。"; + } + return `OpenViking 控制面调用失败。${message ? `错误摘要:${message.slice(0, 500)}` : ""}`; +} + +function invokeControlPlane(cli, subcommand, args, agentPlanKey) { + const result = spawnSync(cli.command, [...cli.prefix, subcommand, ...args], { + env: { + ...process.env, + AGENTPLAN_API_KEY: agentPlanKey, + }, + encoding: "utf8", + timeout: 120_000, + maxBuffer: 4 * 1024 * 1024, + }); + if (result.error) { + throw new Error(mapControlPlaneError(result.error.message, [agentPlanKey])); + } + if (result.status !== 0) { + throw new Error(mapControlPlaneError(result.stderr || result.stdout, [agentPlanKey])); + } + return parseJsonOutput(result.stdout, `OpenViking ${subcommand}`); +} + +function collectionList(payload) { + if (Array.isArray(payload)) return payload; + for (const key of ["Items", "items", "Resources", "resources", "Collections", "collections", "Data", "data"]) { + if (Array.isArray(payload?.[key])) return payload[key]; + } + return []; +} + +function collectionId(item) { + return String(item?.ResourceID || item?.resource_id || item?.resourceId || item?.ID || item?.id || "").trim(); +} + +function collectionName(item) { + return String(item?.Name || item?.name || "").trim(); +} + +function collectionStatus(item) { + return String(item?.Status || item?.status || "").trim().toUpperCase(); +} + +function printCollections(collections) { + if (!collections.length) { + process.stdout.write("当前账号还没有 OpenViking 记忆库。\n"); + return; + } + process.stdout.write("当前账号可用的 OpenViking 记忆库:\n"); + for (const item of collections) { + const id = collectionId(item) || "未知资源ID"; + const name = collectionName(item) || "未命名"; + const status = collectionStatus(item) || "UNKNOWN"; + process.stdout.write(`- ${name}(${id},${status})\n`); + } +} + +function selectCollection(collections, options) { + if (options.resourceId) { + const selected = collections.find((item) => collectionId(item) === options.resourceId); + if (!selected) throw new Error(`未找到 OpenViking 记忆库:${options.resourceId}`); + return selected; + } + if (options.collectionName) { + return collections.find((item) => collectionName(item) === options.collectionName) || null; + } + if (collections.length === 1) return collections[0]; + return null; +} + +function validateCollectionName(name) { + if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(name)) { + throw new Error("记忆库名称必须以英文字母开头,只包含英文字母、数字或下划线,最长 64 个字符。"); + } +} + +function createdResource(payload) { + const candidates = [ + payload, + payload?.Data, + payload?.data, + payload?.Resource, + payload?.resource, + ]; + for (const item of candidates) { + if (item && collectionId(item)) return item; + } + throw new Error("OpenViking 创建成功,但响应中缺少 ResourceID。请在控制台确认资源后按 ResourceID 复用,避免重复创建。"); +} + +function apiCredential(payload) { + const candidates = [payload, payload?.Data, payload?.data]; + for (const item of candidates) { + const apiKey = String(item?.ApiKey || item?.api_key || item?.apiKey || "").trim(); + const userId = String(item?.UserID || item?.user_id || item?.userId || "").trim(); + if (apiKey) return { apiKey, userId: userId || "default" }; + } + throw new Error("OpenViking 记忆库已就绪,但控制面没有返回可用的内部访问凭证。"); +} + +async function waitUntilReady(cli, resourceId, agentPlanKey, waitSeconds) { + const deadline = Date.now() + waitSeconds * 1000; + while (Date.now() <= deadline) { + const resource = invokeControlPlane(cli, "get", [resourceId], agentPlanKey); + const status = collectionStatus(resource); + if (status === "READY") return resource; + if (["FAILED", "ERROR", "DELETED", "DELETE_FAILED"].includes(status)) { + throw new Error(`OpenViking 记忆库进入终止状态:${status}。请在控制台查看失败原因。`); + } + await new Promise((resolve) => setTimeout(resolve, 5_000)); + } + throw new Error( + `等待 OpenViking 记忆库 READY 超时。资源 ${resourceId} 已保留,请稍后使用 --resource-id ${resourceId} 继续,避免重复创建。`, + ); +} + +assertNodeVersion(); +const options = parseArgs(process.argv.slice(2)); +if (options.help) { + process.stdout.write(usage().trimStart()); + process.exit(0); +} + +const configuration = readConfiguration(); +if (!configuration.AGENT_PLAN_API_KEY) { + throw new Error(`尚未配置 Agent Plan Key。请先运行 node ${paths.skillRoot}/scripts/configure.mjs`); +} + +const summary = configurationSummary(); +if (summary.openviking) { + process.stdout.write("OpenViking 记忆库已连接,无需再次初始化,也无需输入其他 Key。\n"); + process.exit(0); +} + +const cli = controlPlaneCommand(); +const collections = collectionList( + invokeControlPlane(cli, "list", [], configuration.AGENT_PLAN_API_KEY), +); +const selected = selectCollection(collections, options); + +if (!options.apply) { + printCollections(collections); + if (selected) { + process.stdout.write( + `下一步可复用该记忆库:node ${paths.skillRoot}/scripts/setup-openviking.mjs --apply --resource-id ${collectionId(selected)}\n`, + ); + } else if (collections.length > 1) { + process.stdout.write("请选择一个 ResourceID,再使用 --apply --resource-id 继续。\n"); + } else { + process.stdout.write( + `请确认新记忆库英文名称和持续计费影响,再运行:node ${paths.skillRoot}/scripts/setup-openviking.mjs --apply --collection-name <英文名称> --yes\n`, + ); + } + process.exit(0); +} + +let resource = selected; +let created = false; +if (!resource) { + if (!options.collectionName) { + printCollections(collections); + throw new Error("存在多个记忆库时必须通过 --resource-id 选择;没有记忆库时必须提供 --collection-name。"); + } + validateCollectionName(options.collectionName); + if (!options.yes) { + throw new Error("创建 OpenViking 记忆库可能持续计费,必须在确认后同时提供 --yes。"); + } + if (collections.length >= MAX_COLLECTIONS) { + throw new Error("当前账号已有 20 个 OpenViking 记忆库,请复用已有资源或先清理闲置资源。"); + } + resource = createdResource(invokeControlPlane(cli, "create", [ + "--name", options.collectionName, + "--source", "agentplan", + "--description", "Sales intelligence workbench long-term memory", + ], configuration.AGENT_PLAN_API_KEY)); + created = true; +} + +const resourceId = collectionId(resource); +if (!resourceId) throw new Error("选中的 OpenViking 记忆库缺少 ResourceID。"); +const readyResource = collectionStatus(resource) === "READY" + ? resource + : await waitUntilReady(cli, resourceId, configuration.AGENT_PLAN_API_KEY, options.waitSeconds); +const credentials = apiCredential( + invokeControlPlane(cli, "api-key", [resourceId], configuration.AGENT_PLAN_API_KEY), +); +const name = collectionName(readyResource) || collectionName(resource) || options.collectionName || resourceId; + +writeConfiguration({ + OPENVIKING_API_KEY: credentials.apiKey, + OPENVIKING_BASE_URL: OFFICIAL_BASE_URL, + OPENVIKING_AGENT_ID: credentials.userId, + OPENVIKING_RESOURCE_ID: resourceId, + OPENVIKING_COLLECTION_NAME: name, + OPENVIKING_RUN_ENABLED: "true", +}); + +fs.chmodSync(paths.credentialsFile, 0o600); +process.stdout.write(`${created ? "已创建" : "已复用"} Agent Plan OpenViking 记忆库:${name}(${resourceId})。\n`); +process.stdout.write(`内部连接信息已安全写入 ${paths.credentialsFile},权限为 0600,未在终端显示。\n`); +process.stdout.write("用户侧仍只使用 Agent Plan Key,无需输入或管理其他 Key。\n"); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup-supabase.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup-supabase.mjs new file mode 100644 index 00000000..3bb28f9e --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup-supabase.mjs @@ -0,0 +1,267 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { + assertInstalledApp, + commandExists, + paths, + readConfiguration, + run, + runtimeEnvironment, + writeConfiguration, +} from "./lib.mjs"; + +function parseJson(stdout, label) { + try { + return JSON.parse(String(stdout || "").trim()); + } catch { + throw new Error(`${label} 未返回有效 JSON。`); + } +} + +function readOption(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? String(process.argv[index + 1] || "").trim() : ""; +} + +function workspaceItems(payload) { + if (Array.isArray(payload)) return payload; + const candidates = [ + payload?.workspaces, + payload?.Workspaces, + payload?.items, + payload?.Items, + payload?.data, + payload?.Data, + payload?.data?.items, + payload?.Data?.Items, + ]; + return candidates.find(Array.isArray) || []; +} + +function workspaceId(item) { + return String( + item?.workspace_id + || item?.WorkspaceId + || item?.WorkspaceID + || item?.id + || item?.ID + || item?.ref + || item?.Ref + || "", + ).trim(); +} + +function workspaceName(item) { + return String(item?.name || item?.Name || item?.workspace_name || item?.WorkspaceName || "未命名 Workspace").trim(); +} + +function isAgentPlanWorkspace(item) { + if (item?.is_agent_plan === true || item?.is_agent_plan_instance === true) return true; + const plan = String( + item?.plan_mode + || item?.PlanMode + || item?.billing_mode + || item?.BillingMode + || item?.source + || item?.Source + || "", + ); + return /agent[\s_-]*plan/i.test(plan); +} + +function runCli(command, args, environment) { + const result = spawnSync(command, args, { + encoding: "utf8", + env: environment, + maxBuffer: 4 * 1024 * 1024, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + const message = String(result.stderr || result.stdout || "Supabase CLI 执行失败。") + .replace(/https?:\/\/\S+/g, "[URL]") + .replace(/\b(?:ws|br)-[A-Za-z0-9_-]+\b/g, "[RESOURCE_ID]") + .replace(/\bAKL[A-Za-z0-9_-]{12,}\b/g, "[ACCESS_KEY]") + .replace(/\bark-[A-Za-z0-9_-]{16,}\b/g, "[API_KEY]") + .replace(/\beyJ[A-Za-z0-9._-]{24,}\b/g, "[TOKEN]") + .slice(0, 800); + throw new Error(message); + } + return result.stdout; +} + +function endpointOrigin(payload) { + const addresses = (payload?.Endpoints || []) + .flatMap((endpoint) => endpoint?.Addresses || []) + .filter((address) => String(address?.AddressDomain || "").trim()); + const preferred = addresses.find((address) => /public|internet|external/i.test(String(address.AddressType || ""))); + const selected = preferred || addresses[0]; + const domain = String(selected?.AddressDomain || "").trim(); + const port = Number(selected?.AddressPort || 0); + if (!domain) return ""; + return `https://${domain}${port && port !== 443 ? `:${port}` : ""}`; +} + +function serviceRoleKey(payload) { + if (!Array.isArray(payload)) return ""; + const item = payload.find((candidate) => /service.?role/i.test(String(candidate?.name || candidate?.type || ""))); + return String(item?.api_key || "").trim(); +} + +async function probeDataApi(apiUrl, key) { + const response = await fetch(`${apiUrl.replace(/\/$/, "")}/rest/v1/app_workspaces?select=id&limit=1`, { + signal: AbortSignal.timeout(15_000), + headers: { + Accept: "application/json", + apikey: key, + Authorization: `Bearer ${key}`, + }, + }); + if (!response.ok) throw new Error(`Supabase Data API 回读验证失败(HTTP ${response.status})。`); + const rows = await response.json(); + if (!Array.isArray(rows) || rows.length < 1) { + throw new Error("Supabase Data API 可达,但未读到应用 Workspace;初始化未完成。"); + } + return rows.length; +} + +assertInstalledApp(); +const apply = process.argv.includes("--apply"); +const confirmed = process.argv.includes("--yes"); +const configuration = readConfiguration(); +const command = configuration.SUPABASE_CLI_BIN || "byted-supabase-cli"; +const profile = readOption("--profile") || configuration.SUPABASE_CLI_PROFILE || "current"; +const requestedWorkspaceId = readOption("--workspace-id") || configuration.SUPABASE_WORKSPACE_ID || ""; +const requestedBranchId = readOption("--branch-id") || configuration.SUPABASE_BRANCH_ID || ""; +const profileArgs = profile === "current" ? [] : ["--profile", profile]; +const environment = runtimeEnvironment(); +if (profileArgs.length) { + delete environment.VOLCENGINE_ACCESS_KEY; + delete environment.VOLCENGINE_SECRET_KEY; + delete environment.VOLCENGINE_SESSION_TOKEN; +} + +let selectedWorkspaceId = requestedWorkspaceId; +let discovered = []; +if (!selectedWorkspaceId && commandExists(command)) { + const listed = parseJson(runCli(command, [ + ...profileArgs, + "projects", "list", + "--limit", "100", + "-o", "json", + ], environment), "Supabase projects list"); + discovered = workspaceItems(listed) + .filter((item) => isAgentPlanWorkspace(item) && workspaceId(item)) + .map((item) => ({ id: workspaceId(item), name: workspaceName(item) })); + if (discovered.length === 1) selectedWorkspaceId = discovered[0].id; +} + +if (!apply) { + const selection = selectedWorkspaceId + ? `已选 Agent Plan Workspace:${selectedWorkspaceId}` + : discovered.length > 1 + ? `发现 ${discovered.length} 个 Agent Plan Workspace,请从下列列表选择:\n${discovered + .map((item) => `- ${item.name}(${item.id})`) + .join("\n")}` + : "尚未选定 Agent Plan Workspace。"; + const applyCommand = selectedWorkspaceId + ? `setup-supabase.mjs --apply --workspace-id ${selectedWorkspaceId}${profile === "current" ? "" : ` --profile ${profile}`} --yes` + : "setup-supabase.mjs --apply --workspace-id --yes"; + process.stdout.write([ + "Supabase 初始化计划(当前未写入):", + selection, + "1. 只读确认目标 Workspace 属于 Agent Plan,且处于可用状态。", + "2. 通过已登录的官方 CLI 自动获取 Data API 端点和后端内部凭据。", + "3. 仅写入本机销售工作台私密配置,不向用户显示内部凭据。", + "4. 对目标数据库应用随应用包分发的版本化迁移。", + "5. 创建或更新 APP_WORKSPACE_ID 对应的应用 Workspace 记录。", + "6. 通过 Data API 回读验证。", + `确认目标无误后执行:${applyCommand}`, + "本命令不会创建、暂停或删除云 Workspace。", + !commandExists(command) + ? `未检测到 ${command};请先安装并运行 byted-supabase-cli login --profile agent-plan --region cn-beijing --is-agent-plan。` + : "", + "", + ].filter(Boolean).join("\n")); + process.exit(0); +} +if (!confirmed) { + throw new Error("应用迁移会修改目标数据库;确认目标无误后同时传入 --apply --yes。"); +} +if (!commandExists(command)) throw new Error(`找不到 ${command},请先安装并登录火山 Supabase CLI。`); +if (!selectedWorkspaceId) { + if (discovered.length > 1) { + throw new Error("检测到多个 Agent Plan Workspace,请使用 --workspace-id 明确选择目标。"); + } + throw new Error( + "未发现可用的 Agent Plan Workspace。请先登录官方 CLI;需要新建时,先由用户确认计费影响,再执行 projects create --is-agent-plan。", + ); +} + +const workspace = parseJson(runCli(command, [ + ...profileArgs, + "projects", "list", + "--workspace-id", selectedWorkspaceId, + "--detail", + "-o", "json", +], environment), "Supabase projects list"); +if (!workspace?.is_agent_plan && !workspace?.is_agent_plan_instance) { + throw new Error("目标不是 AI Native 应用开发底座(Supabase)的 Agent Plan Workspace。请使用 --is-agent-plan 创建新 Workspace,不要使用普通按量实例。"); +} +if (String(workspace.status || "").toLowerCase() !== "running") { + throw new Error(`目标 Agent Plan Supabase Workspace 当前状态为 ${workspace.status || "unknown"},请先恢复为 Running。`); +} + +const endpointArgs = [ + ...profileArgs, + "endpoints", "list", + "--workspace-id", selectedWorkspaceId, + "-o", "json", +]; +const keyArgs = [ + ...profileArgs, + "projects", "api-keys", + "--workspace-id", selectedWorkspaceId, + "-o", "json", +]; +if (requestedBranchId) { + endpointArgs.push("--branch-id", requestedBranchId); + keyArgs.push("--branch-id", requestedBranchId); +} + +const endpoints = parseJson(runCli(command, endpointArgs, environment), "Supabase endpoints list"); +const keys = parseJson(runCli(command, keyArgs, environment), "Supabase API keys"); +const apiOrigin = endpointOrigin(endpoints); +const key = serviceRoleKey(keys); +if (!apiOrigin) throw new Error("目标 Workspace 未返回可用的 Data API 域名。"); +if (!key) throw new Error("目标 Workspace 未返回 Service Role Key。"); + +writeConfiguration({ + ...configuration, + SUPABASE_CLI_PROFILE: profile, + SUPABASE_WORKSPACE_ID: selectedWorkspaceId, + SUPABASE_BRANCH_ID: requestedBranchId || endpoints.BranchId || "", + SUPABASE_API_URL: apiOrigin, + SUPABASE_SERVICE_ROLE_KEY: key, + SUPABASE_RUN_ENABLED: "true", +}); + +const refreshedEnvironment = runtimeEnvironment(); +run(process.execPath, [path.join(paths.installedApp, "backend", "scripts", "migrate-supabase.mjs"), "--apply"], { + cwd: path.join(paths.installedApp, "backend"), + env: refreshedEnvironment, +}); +run(process.execPath, [path.join(paths.installedApp, "backend", "scripts", "bootstrap-workspace.mjs")], { + cwd: path.join(paths.installedApp, "backend"), + env: refreshedEnvironment, +}); + +const rowCount = await probeDataApi(apiOrigin, key); +process.stdout.write(`${JSON.stringify({ + ok: true, + configuration_updated: true, + migrations_applied: true, + app_workspace_bootstrapped: true, + data_api_probe_rows: rowCount, + credentials_file: paths.credentialsFile, + runtime_file: paths.runtimeFile, +}, null, 2)}\n`); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup.mjs new file mode 100644 index 00000000..fcb161fd --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup.mjs @@ -0,0 +1,272 @@ +import fs from "node:fs"; + +import { + commandExists, + configurationSummary, + ensureDirectories, + liveDoctorEvidence, + paths, + processExists, + readConfiguration, + readPid, + serverAddress, + waitForHealth, + writePrivateJson, +} from "./lib.mjs"; + +const ALLOWED_SOURCES = new Set([ + "feishu_docs", + "feishu_chats", + "none", +]); +const ALLOWED_DEPLOYMENTS = new Set(["local", "private_network"]); + +function usage() { + return ` +销售智能工作台 Builder + +记录已确认的业务范围: + node setup.mjs --init \\ + --workspace-name <工作台名称> \\ + --sales-goal <销售目标> \\ + --target-scope <行业、区域或客户范围> \\ + --sources feishu_docs,feishu_chats \\ + --deployment local + +查看搭建进度: + node setup.mjs + node setup.mjs --json + +说明: + - 本命令不创建云资源、不调用 Agent Plan 外部能力,也不产生 AFP。 + - 业务范围不包含密钥;密钥仍由 configure.mjs 隐藏输入。 + - sources 可选:feishu_docs、feishu_chats;暂不导入历史资料时使用 none。 +`; +} + +function parseArgs(argv) { + const options = { + init: false, + json: false, + help: false, + workspaceName: "", + salesGoal: "", + targetScope: "", + sources: "", + deployment: "local", + }; + const valueOptions = new Map([ + ["--workspace-name", "workspaceName"], + ["--sales-goal", "salesGoal"], + ["--target-scope", "targetScope"], + ["--sources", "sources"], + ["--deployment", "deployment"], + ]); + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--init") options.init = true; + else if (argument === "--json") options.json = true; + else if (argument === "--help" || argument === "-h") options.help = true; + else if (valueOptions.has(argument)) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${argument} 缺少参数值。`); + options[valueOptions.get(argument)] = value.trim(); + index += 1; + } else { + throw new Error(`未知参数:${argument}`); + } + } + return options; +} + +function readJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return null; + } +} + +function parseSources(value) { + const sources = [...new Set(String(value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean))]; + const invalid = sources.filter((item) => !ALLOWED_SOURCES.has(item)); + if (invalid.length) throw new Error(`不支持的资料来源:${invalid.join("、")}`); + if (sources.includes("none") && sources.length > 1) { + throw new Error("none 不能与其他资料来源同时使用。"); + } + return sources; +} + +function saveBrief(options) { + if (!options.workspaceName) throw new Error("--workspace-name 不能为空。"); + if (!options.salesGoal) throw new Error("--sales-goal 不能为空。"); + if (!ALLOWED_DEPLOYMENTS.has(options.deployment)) { + throw new Error("--deployment 只支持 local 或 private_network。"); + } + const existing = readJson(paths.builderBriefFile); + const sources = parseSources(options.sources || "feishu_docs,feishu_chats"); + const now = new Date().toISOString(); + writePrivateJson(paths.builderBriefFile, { + schema_version: 1, + workspace_name: options.workspaceName, + sales_goal: options.salesGoal, + target_scope: options.targetScope, + source_types: sources, + deployment: options.deployment, + created_at: existing?.created_at || now, + updated_at: now, + }); +} + +function stage(id, label, status, detail) { + return { id, label, status, detail }; +} + +function completeCount(stages) { + return stages.filter((item) => ["complete", "skipped"].includes(item.status)).length; +} + +function nextAction(stages, context) { + const pending = stages.find((item) => item.status === "pending"); + if (!pending) { + return { + stage: "ready", + reason: "真实业务链路已经通过,可以开始持续导入资料和日常使用。", + command: `node ${paths.skillRoot}/scripts/backup.mjs`, + }; + } + const actions = { + brief: { + reason: "先确认要服务的销售目标和资料范围,后续配置才不会变成无目的安装。", + command: `node ${paths.skillRoot}/scripts/setup.mjs --init --workspace-name "<名称>" --sales-goal "<目标>" --target-scope "<范围>" --sources feishu_docs,feishu_chats --deployment local`, + }, + app: { + reason: "先安装经过测试的完整前后端模板,再连接真实资源。", + command: `node ${paths.skillRoot}/scripts/install.mjs`, + }, + agent_plan: { + reason: "模型、专业数据集(DataPro)、豆包搜索(联网搜索)和 Agent 记忆(OpenViking)控制面必须使用真实 Agent Plan 配置。", + command: `node ${paths.skillRoot}/scripts/configure.mjs`, + }, + supabase: { + reason: "结构化业务数据必须落入北京地域的 Agent Plan Supabase Workspace。", + command: `node ${paths.skillRoot}/scripts/setup-supabase.mjs`, + }, + openviking: { + reason: "历史资料和长期记忆需要初始化 Agent Plan OpenViking 记忆库;内部连接信息由脚本自动管理。", + command: `node ${paths.skillRoot}/scripts/setup-openviking.mjs`, + }, + feishu_cli: { + reason: "已选择飞书资料来源,需要安装并授权用户态 lark-cli,同时启用飞书 CLI 导入。", + command: "按 references/feishu-import.md 安装并登录 lark-cli,然后重新运行 configure.mjs。", + }, + live_doctor: { + reason: "配置存在不代表真实可用,需要逐项验证模型和控制台能力的数据面。", + command: `node ${paths.skillRoot}/scripts/doctor.mjs --live`, + }, + runtime: { + reason: "API 和独立 Worker 都运行后,档案任务才能完整执行。", + command: `node ${paths.skillRoot}/scripts/start.mjs`, + }, + workbench_session: { + reason: "先在浏览器创建首位管理员,再为 CLI 导入和验收建立用户会话。", + command: `打开 ${context.url} 创建管理员,然后运行 node ${paths.skillRoot}/scripts/login.mjs`, + }, + history_import: { + reason: "Cookbook 的长期资料链路要求至少完成一次真实、授权的资料导入。", + command: `node ${paths.skillRoot}/scripts/import-feishu.mjs --company-id <企业ID> --doc <飞书文档链接>`, + }, + business_acceptance: { + reason: "最后必须用获授权企业跑企业搜索、入池、档案、问答和持久化闭环。", + command: `node ${paths.skillRoot}/scripts/verify-business-chain.mjs --goal-id <销售目标ID> --company-query <完整企业名称> --question "<验收问题>" --confirm-live`, + }, + }; + return { stage: pending.id, ...actions[pending.id] }; +} + +async function buildReport() { + const brief = readJson(paths.builderBriefFile); + const configuration = readConfiguration(); + const summary = configurationSummary(); + const doctor = liveDoctorEvidence(); + const importReceipt = readJson(paths.historyImportReceiptFile); + const acceptance = readJson(paths.businessAcceptanceFile); + const installed = fs.existsSync(paths.installedApp); + const serverRunning = processExists(readPid()); + const workerRunning = processExists(readPid(paths.workerPidFile)); + const address = serverAddress(); + const health = serverRunning ? await waitForHealth(address.url, 1_200) : false; + const sources = Array.isArray(brief?.source_types) ? brief.source_types : []; + const wantsFeishu = sources.some((item) => item.startsWith("feishu_")); + const larkCliAvailable = commandExists("lark-cli"); + const planKeyReady = Boolean(configuration.AGENT_PLAN_API_KEY); + + const stages = [ + stage("brief", "确认销售场景", brief?.workspace_name && brief?.sales_goal ? "complete" : "pending", + brief ? `${brief.workspace_name};${brief.sales_goal}` : "尚未记录业务范围"), + stage("app", "安装完整应用", installed ? "complete" : "pending", + installed ? paths.installedApp : "尚未安装前后端运行时"), + stage("agent_plan", "配置 Agent Plan 模型与能力卡片", planKeyReady && summary.model && summary.datapro && summary.web_search ? "complete" : "pending", + planKeyReady ? "已配置统一 Agent Plan Key,权限待 live doctor 验证" : "尚未配置 Agent Plan Key"), + stage("supabase", "连接 Agent Plan Supabase", summary.supabase_data_api && configuration.SUPABASE_WORKSPACE_ID ? "complete" : "pending", + summary.supabase_data_api ? "Data API 已配置" : "尚未完成 Agent Plan Workspace 初始化"), + stage("openviking", "连接 OpenViking", summary.openviking ? "complete" : "pending", + summary.openviking + ? `Agent Plan 记忆库已连接${configuration.OPENVIKING_COLLECTION_NAME ? `:${configuration.OPENVIKING_COLLECTION_NAME}` : ""}` + : "尚未初始化 Agent Plan 记忆库;无需输入其他 Key"), + stage("feishu_cli", "准备飞书资料读取", !wantsFeishu ? "skipped" : summary.feishu_sync && larkCliAvailable ? "complete" : "pending", + !wantsFeishu ? "当前业务范围未选择飞书资料" : summary.feishu_sync && larkCliAvailable ? "lark-cli 已发现,授权状态需在首次读取时确认" : "飞书 CLI 导入未启用或找不到 lark-cli"), + stage("live_doctor", "验证真实数据面", doctor.fresh ? "complete" : "pending", + doctor.fresh ? "最近一次全量 live doctor 仍在有效期内" : "尚无有效的全量真实诊断"), + stage("runtime", "启动 API 与 Worker", serverRunning && workerRunning && health ? "complete" : "pending", + serverRunning && workerRunning && health ? address.url : "API、Worker 或健康检查未全部就绪"), + stage("workbench_session", "建立工作台用户会话", summary.cli_session ? "complete" : "pending", + summary.cli_session ? "CLI 用户会话已存在" : "需要先在浏览器创建管理员,再运行 login.mjs"), + stage("history_import", "导入首批历史资料", !wantsFeishu ? "skipped" : importReceipt?.ok ? "complete" : "pending", + !wantsFeishu ? "当前业务范围未选择飞书资料" : importReceipt?.ok ? `已完成 ${importReceipt.source_kind} 导入` : "尚无成功导入回执"), + stage("business_acceptance", "验收真实业务闭环", acceptance?.ok ? "complete" : "pending", + acceptance?.ok ? `已于 ${acceptance.accepted_at} 通过` : "尚未完成企业搜索、档案和资料问答验收"), + ]; + + return { + schema_version: 1, + checked_at: new Date().toISOString(), + progress: { + complete: completeCount(stages), + total: stages.length, + }, + brief, + stages, + next_action: nextAction(stages, { url: address.url }), + paths: { + app: paths.installedApp, + config: paths.configDir, + state: paths.stateDir, + }, + }; +} + +function printHuman(report) { + process.stdout.write(`销售智能工作台 Builder:${report.progress.complete}/${report.progress.total} 个阶段就绪\n`); + for (const item of report.stages) { + const marker = item.status === "complete" ? "[完成]" : item.status === "skipped" ? "[跳过]" : "[待办]"; + process.stdout.write(`${marker} ${item.label}:${item.detail}\n`); + } + process.stdout.write(`\n下一步(${report.next_action.stage}):${report.next_action.reason}\n`); + process.stdout.write(`${report.next_action.command}\n`); +} + +const options = parseArgs(process.argv.slice(2)); +if (options.help) { + process.stdout.write(usage().trimStart()); + process.exit(0); +} +ensureDirectories(); +if (options.init) saveBrief(options); +const report = await buildReport(); +if (options.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +else printHuman(report); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/smoke-async-job-queue.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/smoke-async-job-queue.mjs new file mode 100644 index 00000000..c55e8792 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/smoke-async-job-queue.mjs @@ -0,0 +1,18 @@ +import path from "node:path"; +import { + assertAppSource, + assertInstalledApp, + readOption, + resolveUserPath, + run, + runtimeEnvironment, +} from "./lib.mjs"; + +const sourceValue = readOption("--source"); +const appRoot = sourceValue ? assertAppSource(resolveUserPath(sourceValue)) : assertInstalledApp(); + +process.stdout.write("正在事务内验证异步任务入队、领取、心跳与安全重试;验证结束后会回滚测试数据。\n"); +run(process.execPath, [path.join(appRoot, "backend", "scripts", "smoke-async-job-queue.mjs")], { + cwd: path.join(appRoot, "backend"), + env: runtimeEnvironment(), +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/smoke-paid-workflow.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/smoke-paid-workflow.mjs new file mode 100644 index 00000000..50799cda --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/smoke-paid-workflow.mjs @@ -0,0 +1,18 @@ +import path from "node:path"; +import { + assertAppSource, + assertInstalledApp, + readOption, + resolveUserPath, + run, + runtimeEnvironment, +} from "./lib.mjs"; + +const sourceValue = readOption("--source"); +const appRoot = sourceValue ? assertAppSource(resolveUserPath(sourceValue)) : assertInstalledApp(); + +process.stdout.write("正在事务内验证付费工作流预约与释放;验证结束后会回滚测试数据。\n"); +run(process.execPath, [path.join(appRoot, "backend", "scripts", "smoke-paid-workflow-guard.mjs")], { + cwd: path.join(appRoot, "backend"), + env: runtimeEnvironment(), +}); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/start.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/start.mjs new file mode 100644 index 00000000..09ad1de2 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/start.mjs @@ -0,0 +1,104 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { + assertInstalledApp, + ensureDirectories, + liveDoctorEvidence, + paths, + processExists, + readConfiguration, + readPid, + run, + runtimeEnvironment, + serverAddress, + waitForHealth, +} from "./lib.mjs"; + +assertInstalledApp(); +ensureDirectories(); + +const doctor = run(process.execPath, [path.join(paths.skillRoot, "scripts", "doctor.mjs")], { + allowFailure: true, +}); +if (doctor.status !== 0) throw new Error("配置检查未通过,未启动服务。"); + +const configuration = readConfiguration(); +const evidence = liveDoctorEvidence(); +if (!evidence.fresh) { + process.stderr.write( + `提示:最近 ${Math.round(evidence.ttl_ms / 60000)} 分钟内没有全绿 live doctor 结果;服务仍会启动,依赖异常 Provider 的业务操作将严格失败且不会生成替代数据。\n`, + ); +} + +const address = serverAddress(); +if (process.argv.includes("--dry-run")) { + process.stdout.write(`启动预检通过:${address.url}\n`); + process.exit(0); +} + +const existingPid = readPid(); +const existingWorkerPid = readPid(paths.workerPidFile); +let serverPid = existingPid; +let serverStartedHere = false; +if (processExists(existingPid)) { + if (await waitForHealth(address.url, 1500)) { + process.stdout.write(`销售智能工作台 API 已在运行:${address.url}\n`); + } else { + throw new Error(`检测到仍在运行的进程 ${existingPid},但健康检查失败。请先执行 stop.mjs。`); + } +} else { + fs.rmSync(paths.pidFile, { force: true }); + if (await waitForHealth(address.url, 800)) { + throw new Error(`端口上已有其他服务响应:${address.url}`); + } + + const logFd = fs.openSync(paths.logFile, "a", 0o600); + const child = spawn(process.execPath, ["src/server.js"], { + cwd: path.join(paths.installedApp, "backend"), + detached: true, + stdio: ["ignore", logFd, logFd], + env: runtimeEnvironment({ NODE_ENV: "production" }), + }); + child.unref(); + fs.closeSync(logFd); + serverPid = child.pid; + serverStartedHere = true; + fs.writeFileSync(paths.pidFile, `${child.pid}\n`, { mode: 0o600 }); + + if (!await waitForHealth(address.url)) { + if (processExists(child.pid)) process.kill(child.pid, "SIGTERM"); + fs.rmSync(paths.pidFile, { force: true }); + throw new Error(`服务未通过健康检查,请查看 ${paths.logFile}`); + } +} + +const asyncJobsEnabled = ["1", "true", "yes", "on"].includes( + String(configuration.ASYNC_JOBS_ENABLED || "true").toLowerCase(), +); +if (asyncJobsEnabled && !processExists(existingWorkerPid)) { + fs.rmSync(paths.workerPidFile, { force: true }); + const workerLogFd = fs.openSync(paths.workerLogFile, "a", 0o600); + const worker = spawn(process.execPath, ["src/worker.js"], { + cwd: path.join(paths.installedApp, "backend"), + detached: true, + stdio: ["ignore", workerLogFd, workerLogFd], + env: runtimeEnvironment({ NODE_ENV: "production" }), + }); + worker.unref(); + fs.closeSync(workerLogFd); + fs.writeFileSync(paths.workerPidFile, `${worker.pid}\n`, { mode: 0o600 }); + await new Promise((resolve) => setTimeout(resolve, 1200)); + if (!processExists(worker.pid)) { + fs.rmSync(paths.workerPidFile, { force: true }); + if (serverStartedHere && processExists(serverPid)) process.kill(serverPid, "SIGTERM"); + if (serverStartedHere) fs.rmSync(paths.pidFile, { force: true }); + throw new Error(`后台任务 Worker 启动失败,请查看 ${paths.workerLogFile}`); + } +} else if (asyncJobsEnabled) { + process.stdout.write(`后台任务 Worker 已在运行:PID ${existingWorkerPid}\n`); +} + +process.stdout.write(`销售智能工作台已启动:${address.url}\n`); +process.stdout.write(`日志:${paths.logFile}\n`); +if (asyncJobsEnabled) process.stdout.write(`Worker 日志:${paths.workerLogFile}\n`); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/status.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/status.mjs new file mode 100644 index 00000000..1ffc6799 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/status.mjs @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import { + configurationSummary, + liveDoctorEvidence, + paths, + processExists, + readPid, + serverAddress, +} from "./lib.mjs"; + +const pid = readPid(); +const running = processExists(pid); +const workerPid = readPid(paths.workerPidFile); +const workerRunning = processExists(workerPid); +const address = serverAddress(); +let health = null; +if (running) { + try { + const response = await fetch(`${address.url}/api/health`); + health = { ok: response.ok, status: response.status, body: await response.json() }; + } catch (error) { + health = { ok: false, status: null, error: error.message }; + } +} + +const evidence = liveDoctorEvidence(); +process.stdout.write(`${JSON.stringify({ + installed: fs.existsSync(paths.installedApp), + running, + pid: running ? pid : null, + worker_running: workerRunning, + worker_pid: workerRunning ? workerPid : null, + url: address.url, + configuration: configurationSummary(), + live_doctor: { + exists: evidence.exists, + fresh: evidence.fresh, + age_ms: evidence.age_ms, + ttl_ms: evidence.ttl_ms, + }, + health, + paths: { + app: paths.installedApp, + config: paths.configDir, + state: paths.stateDir, + log: paths.logFile, + worker_log: paths.workerLogFile, + }, +}, null, 2)}\n`); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/stop.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/stop.mjs new file mode 100644 index 00000000..bce228f3 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/stop.mjs @@ -0,0 +1,28 @@ +import fs from "node:fs"; +import { paths, processExists, readPid } from "./lib.mjs"; + +async function stopProcess(pidFile, label) { + const pid = readPid(pidFile); + if (!processExists(pid)) { + fs.rmSync(pidFile, { force: true }); + return false; + } + process.kill(pid, "SIGTERM"); + const deadline = Date.now() + 35_000; + while (Date.now() < deadline && processExists(pid)) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + if (processExists(pid)) { + throw new Error(`${label}进程 ${pid} 仍在安全结束当前任务,请稍后再次运行 stop.mjs。`); + } + fs.rmSync(pidFile, { force: true }); + return true; +} + +const workerStopped = await stopProcess(paths.workerPidFile, "Worker "); +const serverStopped = await stopProcess(paths.pidFile, "API "); +if (!workerStopped && !serverStopped) { + process.stdout.write("销售智能工作台当前未运行。\n"); + process.exit(0); +} +process.stdout.write("销售智能工作台已停止,配置和业务数据未删除。\n"); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/sync-assets.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/sync-assets.mjs new file mode 100644 index 00000000..eed9672c --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/sync-assets.mjs @@ -0,0 +1,56 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { appCopyFilter, assertAppSource, paths } from "./lib.mjs"; + +const sourceRoot = assertAppSource(paths.projectRoot); +const destinationRoot = paths.sourceApp; +const stagingRoot = `${destinationRoot}.staging`; + +function manifest(rootDir) { + const files = []; + const visit = (directory) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const filePath = path.join(directory, entry.name); + if (!appCopyFilter(rootDir, filePath)) continue; + if (entry.isDirectory()) visit(filePath); + else if (entry.isFile()) { + files.push({ + path: path.relative(rootDir, filePath), + sha256: createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"), + }); + } + } + }; + visit(rootDir); + return files.sort((left, right) => left.path.localeCompare(right.path)); +} + +if (process.argv.includes("--check")) { + assertAppSource(destinationRoot); + if (JSON.stringify(manifest(sourceRoot)) !== JSON.stringify(manifest(destinationRoot))) { + throw new Error("Skill 应用包与当前仓库源码不同步,请先运行 sync-assets.mjs。"); + } + process.stdout.write("Skill 应用包与当前仓库源码一致。\n"); + process.exit(0); +} + +fs.rmSync(stagingRoot, { recursive: true, force: true }); +fs.mkdirSync(stagingRoot, { recursive: true }); +for (const directory of ["backend", "frontend", "supabase"]) { + const source = path.join(sourceRoot, directory); + fs.cpSync(source, path.join(stagingRoot, directory), { + recursive: true, + force: true, + filter: (entry) => appCopyFilter(sourceRoot, entry), + }); +} +assertAppSource(stagingRoot); +fs.rmSync(destinationRoot, { recursive: true, force: true }); +fs.renameSync(stagingRoot, destinationRoot); + +if (JSON.stringify(manifest(sourceRoot)) !== JSON.stringify(manifest(destinationRoot))) { + throw new Error("Skill 应用包同步后的文件校验失败。"); +} + +process.stdout.write(`Skill 应用包已从 ${sourceRoot} 同步到 ${destinationRoot}\n`); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/uninstall.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/uninstall.mjs new file mode 100644 index 00000000..d83769bb --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/uninstall.mjs @@ -0,0 +1,25 @@ +import fs from "node:fs"; +import { + paths, + processExists, + readPid, +} from "./lib.mjs"; + +if (processExists(readPid())) { + throw new Error("工作台仍在运行,请先执行 stop.mjs。卸载不会自动终止未知进程。"); +} + +const purge = process.argv.includes("--purge"); +const confirmed = process.argv.includes("--yes"); +if (purge && !confirmed) { + throw new Error("--purge 会删除本机配置、日志和备份,必须同时提供 --yes。"); +} + +fs.rmSync(paths.installRoot, { recursive: true, force: true }); +if (purge) { + fs.rmSync(paths.configDir, { recursive: true, force: true }); + fs.rmSync(paths.stateDir, { recursive: true, force: true }); + process.stdout.write("应用、配置、日志和本地备份已删除;云端 Supabase/OpenViking 数据未删除。\n"); +} else { + process.stdout.write("应用运行时已删除;配置、日志、备份和云端数据均已保留。\n"); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/upgrade.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/upgrade.mjs new file mode 100644 index 00000000..7f311e08 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/upgrade.mjs @@ -0,0 +1,8 @@ +import path from "node:path"; +import { paths, run } from "./lib.mjs"; + +const result = run(process.execPath, [ + path.join(paths.skillRoot, "scripts", "install.mjs"), + ...process.argv.slice(2), +], { allowFailure: true }); +if (result.status !== 0) process.exitCode = result.status; diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/verify-business-chain.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/verify-business-chain.mjs new file mode 100644 index 00000000..b1db3f34 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/verify-business-chain.mjs @@ -0,0 +1,49 @@ +import path from "node:path"; + +import { + assertInstalledApp, + paths, + run, + serverAddress, + writePrivateJson, +} from "./lib.mjs"; + +assertInstalledApp(); +const userArgs = process.argv.slice(2); +const args = [ + path.join(paths.installedApp, "backend", "scripts", "verify-business-chain.mjs"), +]; +if (!userArgs.includes("--api-url")) args.push("--api-url", serverAddress().url); +if (!userArgs.includes("--auth-session")) args.push("--auth-session", paths.cliSessionFile); +args.push(...userArgs); + +const result = run(process.execPath, args, { + allowFailure: true, + encoding: "utf8", + stdio: "pipe", +}); +if (result.stdout) process.stdout.write(result.stdout); +if (result.stderr) process.stderr.write(result.stderr); +if (result.status !== 0) { + process.exitCode = result.status; +} else { + try { + const report = JSON.parse(String(result.stdout || "").trim()); + if (report.ok) { + writePrivateJson(paths.businessAcceptanceFile, { + schema_version: 1, + ok: true, + accepted_at: report.finished_at || new Date().toISOString(), + enterprise_id: report.enterprise?.id || null, + checks: { + company_search: report.company_search?.status || "succeeded", + dossier: report.dossier?.provider_run?.status || null, + qa: report.qa?.provider_run?.status || null, + }, + usage: report.usage || null, + }); + } + } catch { + process.stderr.write("提示:真实业务验收已执行,但未能写入 Builder 脱敏回执。\n"); + } +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/verify-real-chain.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/verify-real-chain.mjs new file mode 100644 index 00000000..843262c0 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/verify-real-chain.mjs @@ -0,0 +1,18 @@ +import path from "node:path"; +import { paths, run } from "./lib.mjs"; + +if (process.argv.includes("--help") || process.argv.includes("-h")) { + process.stdout.write(`用法: + node verify-real-chain.mjs + +执行模型、DataPro、豆包搜索、OpenViking 和 Supabase 的最小真实连通性检查。 +该命令会产生少量 AFP/Token;查看本帮助不会发起任何 Provider 请求。 +`); + process.exit(0); +} + +process.stdout.write("将发起模型、DataPro、联网搜索、OpenViking 和 Supabase 的最小只读真实请求,可能产生少量 AFP/Token。\n"); +const result = run(process.execPath, [path.join(paths.skillRoot, "scripts", "doctor.mjs"), "--live"], { + allowFailure: true, +}); +if (result.status !== 0) process.exitCode = result.status; diff --git a/demohouse/sales-intelligence-workbench/supabase/functions/sales-cli-health-b1/index.ts b/demohouse/sales-intelligence-workbench/supabase/functions/sales-cli-health-b1/index.ts new file mode 100644 index 00000000..d14766c1 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/supabase/functions/sales-cli-health-b1/index.ts @@ -0,0 +1,17 @@ +Deno.serve((req) => { + const url = new URL(req.url); + const body = { + ok: true, + service: "sales-cli-health-b1", + scenario: "supabase-new-cli-sales-workbench-test", + method: req.method, + path: url.pathname, + checkedAt: new Date().toISOString(), + }; + + return new Response(JSON.stringify(body), { + headers: { + "content-type": "application/json; charset=utf-8", + }, + }); +}); diff --git a/demohouse/sales-intelligence-workbench/supabase/migrations/202607210001_stage2_core.sql b/demohouse/sales-intelligence-workbench/supabase/migrations/202607210001_stage2_core.sql new file mode 100644 index 00000000..d2601ff1 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/supabase/migrations/202607210001_stage2_core.sql @@ -0,0 +1,395 @@ +begin; + +create table if not exists public.schema_migrations ( + version text primary key, + description text not null, + applied_at timestamptz not null default now() +); + +create or replace function public.set_updated_at() +returns trigger +language plpgsql +set search_path = pg_catalog, public +as $$ +begin + new.updated_at = now(); + return new; +end; +$$; + +create table if not exists public.app_workspaces ( + id uuid primary key default gen_random_uuid(), + slug text not null unique, + name text not null, + plan_mode text not null default 'standard' check (plan_mode in ('standard', 'agent_plan')), + created_by uuid references auth.users(id) on delete set null, + settings_json jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.app_users ( + id uuid primary key references auth.users(id) on delete cascade, + display_name text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.app_workspace_members ( + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + role text not null default 'member' check (role in ('owner', 'admin', 'member', 'viewer')), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (workspace_id, user_id) +); + +create table if not exists public.provider_connections ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + provider text not null, + status text not null default 'configured', + secret_ref text, + config_json jsonb not null default '{}'::jsonb, + last_checked_at timestamptz, + created_by uuid references auth.users(id) on delete set null, + updated_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (workspace_id, provider) +); + +create table if not exists public.sales_goals ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + name text not null, + description text, + keywords jsonb not null default '[]'::jsonb, + created_by uuid references auth.users(id) on delete set null, + updated_by uuid references auth.users(id) on delete set null, + deleted_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id) +); + +create table if not exists public.sales_companies ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + name text not null, + normalized_name text generated always as (lower(btrim(name))) stored, + initial text, + industry text, + location text, + tags jsonb not null default '[]'::jsonb, + created_by uuid references auth.users(id) on delete set null, + updated_by uuid references auth.users(id) on delete set null, + deleted_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id), + unique (workspace_id, normalized_name) +); + +create table if not exists public.sales_target_enterprises ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + goal_id text not null, + company_id text not null, + status text not null default 'new', + created_by uuid references auth.users(id) on delete set null, + updated_by uuid references auth.users(id) on delete set null, + deleted_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id), + unique (workspace_id, goal_id, company_id), + foreign key (workspace_id, goal_id) references public.sales_goals(workspace_id, id) on delete cascade, + foreign key (workspace_id, company_id) references public.sales_companies(workspace_id, id) on delete cascade +); + +create table if not exists public.sales_company_search_results ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + goal_id text not null, + company_id text, + query text not null, + reason text, + created_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id), + foreign key (workspace_id, goal_id) references public.sales_goals(workspace_id, id) on delete cascade, + foreign key (workspace_id, company_id) references public.sales_companies(workspace_id, id) on delete cascade +); + +create table if not exists public.sales_progress_snapshots ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + company_id text not null, + label text, + summary text, + evidence text, + created_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id), + foreign key (workspace_id, company_id) references public.sales_companies(workspace_id, id) on delete cascade +); + +create table if not exists public.sales_dossier_records ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + company_id text not null, + title text, + summary text, + memory_summary text, + status text not null default 'completed', + provider_run_id text, + created_by uuid references auth.users(id) on delete set null, + updated_by uuid references auth.users(id) on delete set null, + deleted_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id), + foreign key (workspace_id, company_id) references public.sales_companies(workspace_id, id) on delete cascade +); + +create table if not exists public.sales_dossier_citations ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + dossier_id text not null, + citation_no text not null, + label text, + source_kind text, + url text, + created_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id), + unique (workspace_id, dossier_id, citation_no), + foreign key (workspace_id, dossier_id) references public.sales_dossier_records(workspace_id, id) on delete cascade +); + +create table if not exists public.sales_materials ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + company_id text not null, + title text not null, + source_type text, + source_url text, + content_hash text, + summary text, + occurred_at timestamptz, + openviking_uri text, + openviking_status text, + created_by uuid references auth.users(id) on delete set null, + updated_by uuid references auth.users(id) on delete set null, + deleted_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id), + foreign key (workspace_id, company_id) references public.sales_companies(workspace_id, id) on delete cascade +); + +create unique index if not exists sales_materials_content_unique + on public.sales_materials(workspace_id, company_id, content_hash) + where content_hash is not null and deleted_at is null; + +create table if not exists public.sales_qa_messages ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + company_id text not null, + session_id text not null, + role text not null check (role in ('user', 'assistant', 'system', 'tool')), + text text not null, + provider_run_id text, + created_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id), + foreign key (workspace_id, company_id) references public.sales_companies(workspace_id, id) on delete cascade +); + +create table if not exists public.sales_openviking_refs ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + company_id text, + related_type text not null, + related_id text, + ref_kind text not null, + uri text not null, + summary text, + created_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + payload_json jsonb not null default '{}'::jsonb, + unique (workspace_id, id), + unique (workspace_id, related_type, related_id, ref_kind), + foreign key (workspace_id, company_id) references public.sales_companies(workspace_id, id) on delete cascade +); + +create table if not exists public.jobs ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + job_type text not null, + status text not null default 'queued' check (status in ('queued', 'running', 'succeeded', 'failed', 'cancelled')), + entity_type text, + entity_id text, + idempotency_key text, + attempt_count integer not null default 0 check (attempt_count >= 0), + max_attempts integer not null default 3 check (max_attempts > 0), + scheduled_at timestamptz, + started_at timestamptz, + finished_at timestamptz, + error_json jsonb, + payload_json jsonb not null default '{}'::jsonb, + created_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (workspace_id, id) +); + +create unique index if not exists jobs_workspace_idempotency_unique + on public.jobs(workspace_id, idempotency_key) + where idempotency_key is not null; + +create table if not exists public.provider_runs ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + job_id text, + operation text not null, + status text not null check (status in ('running', 'succeeded', 'succeeded_with_issues', 'failed', 'cancelled')), + app_mode text not null, + entity_type text, + entity_id text, + started_at timestamptz not null, + finished_at timestamptz, + duration_ms integer check (duration_ms is null or duration_ms >= 0), + result_ref text, + error_json jsonb, + payload_json jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (workspace_id, id), + foreign key (workspace_id, job_id) references public.jobs(workspace_id, id) on delete cascade +); + +create table if not exists public.provider_run_steps ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + provider_run_id text not null, + sequence integer not null check (sequence > 0), + provider text not null, + operation text not null, + status text not null check (status in ('running', 'succeeded', 'failed', 'skipped', 'cancelled')), + input_summary text, + output_summary text, + request_id text, + raw_ref text, + usage_json jsonb, + attempts integer not null default 1 check (attempts > 0), + started_at timestamptz not null, + finished_at timestamptz, + latency_ms integer check (latency_ms is null or latency_ms >= 0), + error_json jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (workspace_id, id), + unique (workspace_id, provider_run_id, sequence), + foreign key (workspace_id, provider_run_id) references public.provider_runs(workspace_id, id) on delete cascade +); + +create table if not exists public.sync_sources ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + source_type text not null, + external_id text not null, + display_name text, + status text not null default 'active' check (status in ('active', 'paused', 'error', 'deleted')), + config_json jsonb not null default '{}'::jsonb, + last_synced_at timestamptz, + created_by uuid references auth.users(id) on delete set null, + updated_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (workspace_id, id), + unique (workspace_id, source_type, external_id) +); + +create table if not exists public.sync_checkpoints ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + source_id text not null, + checkpoint_key text not null, + checkpoint_value text, + content_hash text, + last_success_at timestamptz, + error_json jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (workspace_id, id), + unique (workspace_id, source_id, checkpoint_key), + foreign key (workspace_id, source_id) references public.sync_sources(workspace_id, id) on delete cascade +); + +create table if not exists public.audit_events ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + actor_user_id uuid references auth.users(id) on delete set null, + action text not null, + entity_type text, + entity_id text, + request_id text, + ip_hash text, + before_json jsonb, + after_json jsonb, + created_at timestamptz not null default now(), + unique (workspace_id, id) +); + +create index if not exists app_workspace_members_user_idx on public.app_workspace_members(user_id, workspace_id); +create index if not exists provider_connections_workspace_idx on public.provider_connections(workspace_id, provider); +create index if not exists sales_goals_workspace_idx on public.sales_goals(workspace_id, updated_at desc) where deleted_at is null; +create index if not exists sales_companies_workspace_idx on public.sales_companies(workspace_id, updated_at desc) where deleted_at is null; +create index if not exists sales_targets_goal_idx on public.sales_target_enterprises(workspace_id, goal_id, updated_at desc) where deleted_at is null; +create index if not exists sales_search_goal_idx on public.sales_company_search_results(workspace_id, goal_id, created_at desc); +create index if not exists sales_progress_company_idx on public.sales_progress_snapshots(workspace_id, company_id, created_at desc); +create index if not exists sales_dossiers_company_idx on public.sales_dossier_records(workspace_id, company_id, created_at desc) where deleted_at is null; +create index if not exists sales_materials_company_idx on public.sales_materials(workspace_id, company_id, updated_at desc) where deleted_at is null; +create index if not exists sales_qa_company_idx on public.sales_qa_messages(workspace_id, company_id, created_at); +create index if not exists sales_openviking_company_idx on public.sales_openviking_refs(workspace_id, company_id, created_at desc); +create index if not exists jobs_queue_idx on public.jobs(workspace_id, status, scheduled_at, created_at); +create index if not exists provider_runs_entity_idx on public.provider_runs(workspace_id, entity_type, entity_id, started_at desc); +create index if not exists provider_run_steps_run_idx on public.provider_run_steps(workspace_id, provider_run_id, sequence); +create index if not exists sync_sources_workspace_idx on public.sync_sources(workspace_id, source_type, status); +create index if not exists audit_events_entity_idx on public.audit_events(workspace_id, entity_type, entity_id, created_at desc); + +do $$ +declare + table_name text; +begin + foreach table_name in array array[ + 'app_workspaces', 'app_users', 'app_workspace_members', 'provider_connections', + 'sales_goals', 'sales_companies', 'sales_target_enterprises', 'sales_dossier_records', + 'sales_materials', 'jobs', 'provider_runs', 'provider_run_steps', 'sync_sources', 'sync_checkpoints' + ] + loop + execute format('drop trigger if exists set_%I_updated_at on public.%I', table_name, table_name); + execute format( + 'create trigger set_%I_updated_at before update on public.%I for each row execute function public.set_updated_at()', + table_name, + table_name + ); + end loop; +end; +$$; + +insert into public.schema_migrations(version, description) +values ('202607210001', 'Stage 2 multi-tenant sales workbench core schema') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/supabase/migrations/202607210002_stage2_rls.sql b/demohouse/sales-intelligence-workbench/supabase/migrations/202607210002_stage2_rls.sql new file mode 100644 index 00000000..d5a47a75 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/supabase/migrations/202607210002_stage2_rls.sql @@ -0,0 +1,221 @@ +begin; + +create or replace function public.is_workspace_member(target_workspace_id uuid) +returns boolean +language sql +stable +security definer +set search_path = pg_catalog, public +as $$ + select exists ( + select 1 + from public.app_workspace_members member + where member.workspace_id = target_workspace_id + and member.user_id = (select auth.uid()) + ); +$$; + +create or replace function public.can_write_workspace(target_workspace_id uuid) +returns boolean +language sql +stable +security definer +set search_path = pg_catalog, public +as $$ + select exists ( + select 1 + from public.app_workspace_members member + where member.workspace_id = target_workspace_id + and member.user_id = (select auth.uid()) + and member.role in ('owner', 'admin', 'member') + ); +$$; + +create or replace function public.can_admin_workspace(target_workspace_id uuid) +returns boolean +language sql +stable +security definer +set search_path = pg_catalog, public +as $$ + select exists ( + select 1 + from public.app_workspace_members member + where member.workspace_id = target_workspace_id + and member.user_id = (select auth.uid()) + and member.role in ('owner', 'admin') + ); +$$; + +revoke all on function public.is_workspace_member(uuid) from public; +revoke all on function public.can_write_workspace(uuid) from public; +revoke all on function public.can_admin_workspace(uuid) from public; +grant execute on function public.is_workspace_member(uuid) to authenticated, service_role; +grant execute on function public.can_write_workspace(uuid) to authenticated, service_role; +grant execute on function public.can_admin_workspace(uuid) to authenticated, service_role; + +alter table public.app_workspaces enable row level security; +alter table public.app_workspaces force row level security; +alter table public.app_users enable row level security; +alter table public.app_users force row level security; +alter table public.app_workspace_members enable row level security; +alter table public.app_workspace_members force row level security; +alter table public.provider_connections enable row level security; +alter table public.provider_connections force row level security; + +drop policy if exists app_workspaces_select on public.app_workspaces; +create policy app_workspaces_select on public.app_workspaces + for select to authenticated + using (public.is_workspace_member(id)); + +drop policy if exists app_workspaces_insert on public.app_workspaces; +create policy app_workspaces_insert on public.app_workspaces + for insert to authenticated + with check (created_by = (select auth.uid())); + +drop policy if exists app_workspaces_update on public.app_workspaces; +create policy app_workspaces_update on public.app_workspaces + for update to authenticated + using (public.can_admin_workspace(id)) + with check (public.can_admin_workspace(id)); + +drop policy if exists app_users_select on public.app_users; +create policy app_users_select on public.app_users + for select to authenticated + using (id = (select auth.uid())); + +drop policy if exists app_users_insert on public.app_users; +create policy app_users_insert on public.app_users + for insert to authenticated + with check (id = (select auth.uid())); + +drop policy if exists app_users_update on public.app_users; +create policy app_users_update on public.app_users + for update to authenticated + using (id = (select auth.uid())) + with check (id = (select auth.uid())); + +drop policy if exists workspace_members_select on public.app_workspace_members; +create policy workspace_members_select on public.app_workspace_members + for select to authenticated + using (public.is_workspace_member(workspace_id)); + +drop policy if exists workspace_members_insert on public.app_workspace_members; +create policy workspace_members_insert on public.app_workspace_members + for insert to authenticated + with check (public.can_admin_workspace(workspace_id)); + +drop policy if exists workspace_members_update on public.app_workspace_members; +create policy workspace_members_update on public.app_workspace_members + for update to authenticated + using (public.can_admin_workspace(workspace_id)) + with check (public.can_admin_workspace(workspace_id)); + +drop policy if exists workspace_members_delete on public.app_workspace_members; +create policy workspace_members_delete on public.app_workspace_members + for delete to authenticated + using (public.can_admin_workspace(workspace_id)); + +drop policy if exists provider_connections_select on public.provider_connections; +create policy provider_connections_select on public.provider_connections + for select to authenticated + using (public.can_admin_workspace(workspace_id)); + +drop policy if exists provider_connections_insert on public.provider_connections; +create policy provider_connections_insert on public.provider_connections + for insert to authenticated + with check (public.can_admin_workspace(workspace_id)); + +drop policy if exists provider_connections_update on public.provider_connections; +create policy provider_connections_update on public.provider_connections + for update to authenticated + using (public.can_admin_workspace(workspace_id)) + with check (public.can_admin_workspace(workspace_id)); + +drop policy if exists provider_connections_delete on public.provider_connections; +create policy provider_connections_delete on public.provider_connections + for delete to authenticated + using (public.can_admin_workspace(workspace_id)); + +do $$ +declare + table_name text; +begin + foreach table_name in array array[ + 'sales_goals', 'sales_companies', 'sales_target_enterprises', 'sales_company_search_results', + 'sales_progress_snapshots', 'sales_dossier_records', 'sales_dossier_citations', 'sales_materials', + 'sales_qa_messages', 'sales_openviking_refs', 'jobs', 'provider_runs', 'provider_run_steps', + 'sync_sources', 'sync_checkpoints' + ] + loop + execute format('alter table public.%I enable row level security', table_name); + execute format('alter table public.%I force row level security', table_name); + execute format('drop policy if exists workspace_select on public.%I', table_name); + execute format( + 'create policy workspace_select on public.%I for select to authenticated using (public.is_workspace_member(workspace_id))', + table_name + ); + execute format('drop policy if exists workspace_insert on public.%I', table_name); + execute format( + 'create policy workspace_insert on public.%I for insert to authenticated with check (public.can_write_workspace(workspace_id))', + table_name + ); + execute format('drop policy if exists workspace_update on public.%I', table_name); + execute format( + 'create policy workspace_update on public.%I for update to authenticated using (public.can_write_workspace(workspace_id)) with check (public.can_write_workspace(workspace_id))', + table_name + ); + execute format('drop policy if exists workspace_delete on public.%I', table_name); + execute format( + 'create policy workspace_delete on public.%I for delete to authenticated using (public.can_write_workspace(workspace_id))', + table_name + ); + end loop; +end; +$$; + +alter table public.audit_events enable row level security; +alter table public.audit_events force row level security; + +drop policy if exists audit_events_select on public.audit_events; +create policy audit_events_select on public.audit_events + for select to authenticated + using (public.can_admin_workspace(workspace_id)); + +drop policy if exists audit_events_insert on public.audit_events; +create policy audit_events_insert on public.audit_events + for insert to authenticated + with check (public.can_write_workspace(workspace_id)); + +revoke all on all tables in schema public from anon; +revoke all on public.schema_migrations from authenticated; +grant usage on schema public to authenticated; +grant select, insert, update, delete on public.app_workspaces to authenticated; +grant select, insert, update on public.app_users to authenticated; +grant select, insert, update, delete on public.app_workspace_members to authenticated; +grant select, insert, update, delete on public.provider_connections to authenticated; +grant select, insert, update, delete on + public.sales_goals, + public.sales_companies, + public.sales_target_enterprises, + public.sales_company_search_results, + public.sales_progress_snapshots, + public.sales_dossier_records, + public.sales_dossier_citations, + public.sales_materials, + public.sales_qa_messages, + public.sales_openviking_refs, + public.jobs, + public.provider_runs, + public.provider_run_steps, + public.sync_sources, + public.sync_checkpoints +to authenticated; +grant select, insert on public.audit_events to authenticated; +grant all on all tables in schema public to service_role; + +insert into public.schema_migrations(version, description) +values ('202607210002', 'Stage 2 row-level security and Data API grants') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/supabase/migrations/202607210003_stage2_fk_corrections.sql b/demohouse/sales-intelligence-workbench/supabase/migrations/202607210003_stage2_fk_corrections.sql new file mode 100644 index 00000000..b2f64fbf --- /dev/null +++ b/demohouse/sales-intelligence-workbench/supabase/migrations/202607210003_stage2_fk_corrections.sql @@ -0,0 +1,39 @@ +begin; + +alter table public.sales_company_search_results + drop constraint if exists sales_company_search_results_workspace_id_company_id_fkey; +alter table public.sales_company_search_results + add constraint sales_company_search_results_workspace_id_company_id_fkey + foreign key (workspace_id, company_id) + references public.sales_companies(workspace_id, id) + on delete cascade; + +alter table public.provider_runs + drop constraint if exists provider_runs_workspace_id_job_id_fkey; +alter table public.provider_runs + add constraint provider_runs_workspace_id_job_id_fkey + foreign key (workspace_id, job_id) + references public.jobs(workspace_id, id) + on delete cascade; + +alter table public.sales_dossier_records + drop constraint if exists sales_dossier_records_provider_run_id_fkey; +alter table public.sales_dossier_records + add constraint sales_dossier_records_provider_run_id_fkey + foreign key (provider_run_id) + references public.provider_runs(id) + on delete set null; + +alter table public.sales_qa_messages + drop constraint if exists sales_qa_messages_provider_run_id_fkey; +alter table public.sales_qa_messages + add constraint sales_qa_messages_provider_run_id_fkey + foreign key (provider_run_id) + references public.provider_runs(id) + on delete set null; + +insert into public.schema_migrations(version, description) +values ('202607210003', 'Correct composite delete actions and provider run references') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/supabase/migrations/202607210004_stage2_data_api_rpc.sql b/demohouse/sales-intelligence-workbench/supabase/migrations/202607210004_stage2_data_api_rpc.sql new file mode 100644 index 00000000..ba66fff9 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/supabase/migrations/202607210004_stage2_data_api_rpc.sql @@ -0,0 +1,278 @@ +begin; + +create or replace function public.persist_sales_dossier( + p_workspace_id uuid, + p_dossier jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_dossier_id text := nullif(p_dossier ->> 'id', ''); + v_company_id text := nullif(p_dossier ->> 'company_id', ''); + citation jsonb; + citation_id text; +begin + if v_dossier_id is null or v_company_id is null then + raise exception using message = 'dossier id and company_id are required', errcode = '22023'; + end if; + + if not exists ( + select 1 from public.app_workspaces where id = p_workspace_id + ) then + raise exception using message = 'application workspace was not found', errcode = 'P0002'; + end if; + + if not exists ( + select 1 + from public.sales_companies + where workspace_id = p_workspace_id and id = v_company_id and deleted_at is null + ) then + raise exception using message = 'company was not found in application workspace', errcode = 'P0002'; + end if; + + if exists ( + select 1 from public.sales_dossier_records + where id = v_dossier_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace dossier identifier conflict', errcode = '23505'; + end if; + + insert into public.sales_dossier_records ( + id, + workspace_id, + company_id, + title, + summary, + memory_summary, + provider_run_id, + created_at, + payload_json + ) + values ( + v_dossier_id, + p_workspace_id, + v_company_id, + p_dossier ->> 'title', + p_dossier ->> 'summary', + p_dossier ->> 'memory_summary', + nullif(p_dossier ->> 'provider_run_id', ''), + coalesce(nullif(p_dossier ->> 'created_at', '')::timestamptz, now()), + p_dossier + ) + on conflict (id) do update set + title = excluded.title, + summary = excluded.summary, + memory_summary = excluded.memory_summary, + provider_run_id = excluded.provider_run_id, + deleted_at = null, + payload_json = excluded.payload_json + where public.sales_dossier_records.workspace_id = excluded.workspace_id; + + delete from public.sales_dossier_citations + where workspace_id = p_workspace_id and dossier_id = v_dossier_id; + + for citation in + select value from jsonb_array_elements(coalesce(p_dossier -> 'citations', '[]'::jsonb)) + loop + citation_id := v_dossier_id || ':' || coalesce(nullif(citation ->> 'id', ''), 'citation'); + if exists ( + select 1 from public.sales_dossier_citations + where id = citation_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace citation identifier conflict', errcode = '23505'; + end if; + + insert into public.sales_dossier_citations ( + id, + workspace_id, + v_dossier_id, + citation_no, + label, + source_kind, + url, + created_at, + payload_json + ) + values ( + citation_id, + p_workspace_id, + dossier_id, + coalesce(nullif(citation ->> 'id', ''), 'citation'), + citation ->> 'label', + citation ->> 'source_kind', + coalesce(citation ->> 'url', ''), + now(), + citation + ); + end loop; + + return jsonb_build_object( + 'id', v_dossier_id, + 'workspace_id', p_workspace_id, + 'citation_count', jsonb_array_length(coalesce(p_dossier -> 'citations', '[]'::jsonb)) + ); +end; +$$; + +create or replace function public.persist_provider_run( + p_workspace_id uuid, + p_run jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_run_id text := nullif(p_run ->> 'id', ''); + step jsonb; + step_id text; +begin + if v_run_id is null then + raise exception using message = 'provider run id is required', errcode = '22023'; + end if; + + if not exists ( + select 1 from public.app_workspaces where id = p_workspace_id + ) then + raise exception using message = 'application workspace was not found', errcode = 'P0002'; + end if; + + if exists ( + select 1 from public.provider_runs + where id = v_run_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace provider run identifier conflict', errcode = '23505'; + end if; + + insert into public.provider_runs ( + id, + workspace_id, + operation, + status, + app_mode, + entity_type, + entity_id, + started_at, + finished_at, + duration_ms, + result_ref, + error_json, + payload_json + ) + values ( + v_run_id, + p_workspace_id, + coalesce(nullif(p_run ->> 'operation', ''), 'provider_workflow'), + coalesce(nullif(p_run ->> 'status', ''), 'running'), + coalesce(nullif(p_run ->> 'app_mode', ''), 'development'), + nullif(p_run ->> 'entity_type', ''), + nullif(p_run ->> 'entity_id', ''), + coalesce(nullif(p_run ->> 'started_at', '')::timestamptz, now()), + nullif(p_run ->> 'finished_at', '')::timestamptz, + nullif(p_run ->> 'duration_ms', '')::integer, + nullif(p_run ->> 'result_ref', ''), + case + when p_run -> 'error' is null or jsonb_typeof(p_run -> 'error') = 'null' then null + else p_run -> 'error' + end, + p_run + ) + on conflict (id) do update set + operation = excluded.operation, + status = excluded.status, + app_mode = excluded.app_mode, + entity_type = excluded.entity_type, + entity_id = excluded.entity_id, + started_at = excluded.started_at, + finished_at = excluded.finished_at, + duration_ms = excluded.duration_ms, + result_ref = excluded.result_ref, + error_json = excluded.error_json, + payload_json = excluded.payload_json + where public.provider_runs.workspace_id = excluded.workspace_id; + + delete from public.provider_run_steps + where workspace_id = p_workspace_id and provider_run_id = v_run_id; + + for step in + select value from jsonb_array_elements(coalesce(p_run -> 'steps', '[]'::jsonb)) + loop + step_id := nullif(step ->> 'id', ''); + if step_id is null then + raise exception using message = 'provider step id is required', errcode = '22023'; + end if; + if exists ( + select 1 from public.provider_run_steps + where id = step_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace provider step identifier conflict', errcode = '23505'; + end if; + + insert into public.provider_run_steps ( + id, + workspace_id, + provider_run_id, + sequence, + provider, + operation, + status, + input_summary, + output_summary, + request_id, + raw_ref, + usage_json, + attempts, + started_at, + finished_at, + latency_ms, + error_json + ) + values ( + step_id, + p_workspace_id, + v_run_id, + coalesce(nullif(step ->> 'sequence', '')::integer, 1), + coalesce(nullif(step ->> 'provider', ''), 'unknown'), + coalesce(nullif(step ->> 'operation', ''), 'provider_call'), + coalesce(nullif(step ->> 'status', ''), 'running'), + nullif(step ->> 'input_summary', ''), + nullif(step ->> 'output_summary', ''), + nullif(step ->> 'request_id', ''), + nullif(step ->> 'raw_ref', ''), + case + when step -> 'usage' is null or jsonb_typeof(step -> 'usage') = 'null' then null + else step -> 'usage' + end, + coalesce(nullif(step ->> 'attempts', '')::integer, 1), + coalesce(nullif(step ->> 'started_at', '')::timestamptz, now()), + nullif(step ->> 'finished_at', '')::timestamptz, + nullif(step ->> 'latency_ms', '')::integer, + case + when step -> 'error' is null or jsonb_typeof(step -> 'error') = 'null' then null + else step -> 'error' + end + ); + end loop; + + return jsonb_build_object( + 'id', v_run_id, + 'workspace_id', p_workspace_id, + 'step_count', jsonb_array_length(coalesce(p_run -> 'steps', '[]'::jsonb)) + ); +end; +$$; + +revoke all on function public.persist_sales_dossier(uuid, jsonb) from public, anon, authenticated; +revoke all on function public.persist_provider_run(uuid, jsonb) from public, anon, authenticated; +grant execute on function public.persist_sales_dossier(uuid, jsonb) to service_role; +grant execute on function public.persist_provider_run(uuid, jsonb) to service_role; + +insert into public.schema_migrations(version, description) +values ('202607210004', 'Stage 2 transactional Data API persistence functions') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/supabase/migrations/202607210005_fix_dossier_citation_rpc.sql b/demohouse/sales-intelligence-workbench/supabase/migrations/202607210005_fix_dossier_citation_rpc.sql new file mode 100644 index 00000000..d8d95f63 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/supabase/migrations/202607210005_fix_dossier_citation_rpc.sql @@ -0,0 +1,127 @@ +begin; + +create or replace function public.persist_sales_dossier( + p_workspace_id uuid, + p_dossier jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_dossier_id text := nullif(p_dossier ->> 'id', ''); + v_company_id text := nullif(p_dossier ->> 'company_id', ''); + citation jsonb; + citation_id text; +begin + if v_dossier_id is null or v_company_id is null then + raise exception using message = 'dossier id and company_id are required', errcode = '22023'; + end if; + + if not exists ( + select 1 from public.app_workspaces where id = p_workspace_id + ) then + raise exception using message = 'application workspace was not found', errcode = 'P0002'; + end if; + + if not exists ( + select 1 + from public.sales_companies + where workspace_id = p_workspace_id and id = v_company_id and deleted_at is null + ) then + raise exception using message = 'company was not found in application workspace', errcode = 'P0002'; + end if; + + if exists ( + select 1 from public.sales_dossier_records + where id = v_dossier_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace dossier identifier conflict', errcode = '23505'; + end if; + + insert into public.sales_dossier_records ( + id, + workspace_id, + company_id, + title, + summary, + memory_summary, + provider_run_id, + created_at, + payload_json + ) + values ( + v_dossier_id, + p_workspace_id, + v_company_id, + p_dossier ->> 'title', + p_dossier ->> 'summary', + p_dossier ->> 'memory_summary', + nullif(p_dossier ->> 'provider_run_id', ''), + coalesce(nullif(p_dossier ->> 'created_at', '')::timestamptz, now()), + p_dossier + ) + on conflict (id) do update set + title = excluded.title, + summary = excluded.summary, + memory_summary = excluded.memory_summary, + provider_run_id = excluded.provider_run_id, + deleted_at = null, + payload_json = excluded.payload_json + where public.sales_dossier_records.workspace_id = excluded.workspace_id; + + delete from public.sales_dossier_citations + where workspace_id = p_workspace_id and dossier_id = v_dossier_id; + + for citation in + select value from jsonb_array_elements(coalesce(p_dossier -> 'citations', '[]'::jsonb)) + loop + citation_id := v_dossier_id || ':' || coalesce(nullif(citation ->> 'id', ''), 'citation'); + if exists ( + select 1 from public.sales_dossier_citations + where id = citation_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace citation identifier conflict', errcode = '23505'; + end if; + + insert into public.sales_dossier_citations ( + id, + workspace_id, + dossier_id, + citation_no, + label, + source_kind, + url, + created_at, + payload_json + ) + values ( + citation_id, + p_workspace_id, + v_dossier_id, + coalesce(nullif(citation ->> 'id', ''), 'citation'), + citation ->> 'label', + citation ->> 'source_kind', + coalesce(citation ->> 'url', ''), + now(), + citation + ); + end loop; + + return jsonb_build_object( + 'id', v_dossier_id, + 'workspace_id', p_workspace_id, + 'citation_count', jsonb_array_length(coalesce(p_dossier -> 'citations', '[]'::jsonb)) + ); +end; +$$; + +revoke all on function public.persist_sales_dossier(uuid, jsonb) from public, anon, authenticated; +grant execute on function public.persist_sales_dossier(uuid, jsonb) to service_role; + +insert into public.schema_migrations(version, description) +values ('202607210005', 'Fix dossier citation columns in transactional Data API persistence') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/supabase/migrations/202607210006_cover_foreign_key_indexes.sql b/demohouse/sales-intelligence-workbench/supabase/migrations/202607210006_cover_foreign_key_indexes.sql new file mode 100644 index 00000000..43d4905c --- /dev/null +++ b/demohouse/sales-intelligence-workbench/supabase/migrations/202607210006_cover_foreign_key_indexes.sql @@ -0,0 +1,61 @@ +begin; + +create index if not exists app_workspaces_created_by_idx + on public.app_workspaces(created_by); +create index if not exists audit_events_actor_user_id_idx + on public.audit_events(actor_user_id); +create index if not exists jobs_created_by_idx + on public.jobs(created_by); +create index if not exists provider_connections_created_by_idx + on public.provider_connections(created_by); +create index if not exists provider_connections_updated_by_idx + on public.provider_connections(updated_by); +create index if not exists provider_runs_workspace_job_idx + on public.provider_runs(workspace_id, job_id); + +create index if not exists sales_companies_created_by_idx + on public.sales_companies(created_by); +create index if not exists sales_companies_updated_by_idx + on public.sales_companies(updated_by); +create index if not exists sales_company_search_results_created_by_idx + on public.sales_company_search_results(created_by); +create index if not exists sales_company_search_results_workspace_company_idx + on public.sales_company_search_results(workspace_id, company_id); +create index if not exists sales_dossier_records_created_by_idx + on public.sales_dossier_records(created_by); +create index if not exists sales_dossier_records_provider_run_id_idx + on public.sales_dossier_records(provider_run_id); +create index if not exists sales_dossier_records_updated_by_idx + on public.sales_dossier_records(updated_by); +create index if not exists sales_goals_created_by_idx + on public.sales_goals(created_by); +create index if not exists sales_goals_updated_by_idx + on public.sales_goals(updated_by); +create index if not exists sales_materials_created_by_idx + on public.sales_materials(created_by); +create index if not exists sales_materials_updated_by_idx + on public.sales_materials(updated_by); +create index if not exists sales_openviking_refs_created_by_idx + on public.sales_openviking_refs(created_by); +create index if not exists sales_progress_snapshots_created_by_idx + on public.sales_progress_snapshots(created_by); +create index if not exists sales_qa_messages_created_by_idx + on public.sales_qa_messages(created_by); +create index if not exists sales_qa_messages_provider_run_id_idx + on public.sales_qa_messages(provider_run_id); +create index if not exists sales_target_enterprises_created_by_idx + on public.sales_target_enterprises(created_by); +create index if not exists sales_target_enterprises_updated_by_idx + on public.sales_target_enterprises(updated_by); +create index if not exists sales_target_enterprises_workspace_company_idx + on public.sales_target_enterprises(workspace_id, company_id); +create index if not exists sync_sources_created_by_idx + on public.sync_sources(created_by); +create index if not exists sync_sources_updated_by_idx + on public.sync_sources(updated_by); + +insert into public.schema_migrations(version, description) +values ('202607210006', 'Add covering indexes for public schema foreign keys') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/supabase/migrations/202607210007_stage3_material_sync.sql b/demohouse/sales-intelligence-workbench/supabase/migrations/202607210007_stage3_material_sync.sql new file mode 100644 index 00000000..62256b44 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/supabase/migrations/202607210007_stage3_material_sync.sql @@ -0,0 +1,52 @@ +begin; + +alter table public.sales_materials + add column if not exists source_id text, + add column if not exists source_version text, + add column if not exists last_synced_at timestamptz; + +update public.sales_materials +set + source_id = coalesce(source_id, nullif(payload_json ->> 'source_id', '')), + source_version = coalesce(source_version, nullif(payload_json ->> 'source_version', '')), + last_synced_at = coalesce( + last_synced_at, + case + when coalesce(source_id, nullif(payload_json ->> 'source_id', '')) is not null then updated_at + else null + end + ) +where source_id is null + or source_version is null + or last_synced_at is null; + +create unique index if not exists sales_materials_source_unique + on public.sales_materials(workspace_id, company_id, source_id) + where source_id is not null and deleted_at is null; + +create index if not exists sales_materials_source_id_idx + on public.sales_materials(workspace_id, source_id) + where source_id is not null and deleted_at is null; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'sales_materials_workspace_source_fkey' + and conrelid = 'public.sales_materials'::regclass + ) then + alter table public.sales_materials + add constraint sales_materials_workspace_source_fkey + foreign key (workspace_id, source_id) + references public.sync_sources(workspace_id, id) + on delete restrict; + end if; +end; +$$; + +insert into public.schema_migrations(version, description) +values ('202607210007', 'Add stable sync-source identity and version metadata to sales materials') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/supabase/migrations/202607210008_stage4_evidence_versions.sql b/demohouse/sales-intelligence-workbench/supabase/migrations/202607210008_stage4_evidence_versions.sql new file mode 100644 index 00000000..57cd8d99 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/supabase/migrations/202607210008_stage4_evidence_versions.sql @@ -0,0 +1,366 @@ +begin; + +alter table public.sales_dossier_records + add column if not exists version_no integer, + add column if not exists previous_dossier_id text, + add column if not exists evidence_hash text, + add column if not exists dossier_fingerprint text, + add column if not exists change_status text, + add column if not exists data_as_of timestamptz, + add column if not exists generated_at timestamptz, + add column if not exists evidence_pack_json jsonb not null default '[]'::jsonb; + +with ranked as ( + select + id, + row_number() over ( + partition by workspace_id, company_id + order by created_at asc, id asc + )::integer as version_no + from public.sales_dossier_records +) +update public.sales_dossier_records as dossier +set version_no = ranked.version_no +from ranked +where dossier.id = ranked.id + and dossier.version_no is null; + +update public.sales_dossier_records +set + evidence_hash = coalesce(evidence_hash, nullif(payload_json ->> 'evidence_hash', '')), + dossier_fingerprint = coalesce(dossier_fingerprint, nullif(payload_json ->> 'dossier_fingerprint', '')), + change_status = coalesce(change_status, nullif(payload_json ->> 'change_status', ''), 'initial'), + data_as_of = coalesce( + data_as_of, + case + when coalesce(payload_json ->> 'data_as_of', '') ~ '^\d{4}-\d{2}-\d{2}' + then (payload_json ->> 'data_as_of')::timestamptz + else created_at + end + ), + generated_at = coalesce( + generated_at, + case + when coalesce(payload_json ->> 'generated_at', '') ~ '^\d{4}-\d{2}-\d{2}' + then (payload_json ->> 'generated_at')::timestamptz + else created_at + end + ), + evidence_pack_json = case + when evidence_pack_json = '[]'::jsonb and jsonb_typeof(payload_json -> 'evidence_pack') = 'array' + then payload_json -> 'evidence_pack' + else evidence_pack_json + end; + +alter table public.sales_dossier_records + alter column version_no set default 1, + alter column version_no set not null, + alter column change_status set default 'initial', + alter column change_status set not null, + alter column generated_at set default now(), + alter column generated_at set not null; + +do $$ +begin + if not exists ( + select 1 from pg_constraint + where conname = 'sales_dossier_records_change_status_check' + and conrelid = 'public.sales_dossier_records'::regclass + ) then + alter table public.sales_dossier_records + add constraint sales_dossier_records_change_status_check + check (change_status in ('initial', 'changed')); + end if; + + if not exists ( + select 1 from pg_constraint + where conname = 'sales_dossier_records_previous_fkey' + and conrelid = 'public.sales_dossier_records'::regclass + ) then + alter table public.sales_dossier_records + add constraint sales_dossier_records_previous_fkey + foreign key (workspace_id, previous_dossier_id) + references public.sales_dossier_records(workspace_id, id) + on delete restrict; + end if; +end; +$$; + +create unique index if not exists sales_dossier_records_company_version_unique + on public.sales_dossier_records(workspace_id, company_id, version_no) + where deleted_at is null; + +create index if not exists sales_dossier_records_evidence_hash_idx + on public.sales_dossier_records(workspace_id, company_id, evidence_hash) + where evidence_hash is not null and deleted_at is null; + +create index if not exists sales_dossier_records_previous_idx + on public.sales_dossier_records(workspace_id, previous_dossier_id) + where previous_dossier_id is not null; + +create or replace function public.persist_sales_dossier( + p_workspace_id uuid, + p_dossier jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_dossier_id text := nullif(p_dossier ->> 'id', ''); + v_company_id text := nullif(p_dossier ->> 'company_id', ''); + citation jsonb; + citation_id text; +begin + if v_dossier_id is null or v_company_id is null then + raise exception using message = 'dossier id and company_id are required', errcode = '22023'; + end if; + + if not exists (select 1 from public.app_workspaces where id = p_workspace_id) then + raise exception using message = 'application workspace was not found', errcode = 'P0002'; + end if; + + if not exists ( + select 1 from public.sales_companies + where workspace_id = p_workspace_id and id = v_company_id and deleted_at is null + ) then + raise exception using message = 'company was not found in application workspace', errcode = 'P0002'; + end if; + + if exists ( + select 1 from public.sales_dossier_records + where id = v_dossier_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace dossier identifier conflict', errcode = '23505'; + end if; + + insert into public.sales_dossier_records ( + id, workspace_id, company_id, title, summary, memory_summary, status, + provider_run_id, version_no, previous_dossier_id, evidence_hash, + dossier_fingerprint, change_status, data_as_of, generated_at, + evidence_pack_json, created_at, updated_at, payload_json + ) + values ( + v_dossier_id, + p_workspace_id, + v_company_id, + p_dossier ->> 'title', + p_dossier ->> 'summary', + p_dossier ->> 'memory_summary', + coalesce(nullif(p_dossier ->> 'status', ''), 'completed'), + nullif(p_dossier ->> 'provider_run_id', ''), + coalesce(nullif(p_dossier ->> 'version_no', '')::integer, 1), + nullif(p_dossier ->> 'previous_dossier_id', ''), + nullif(p_dossier ->> 'evidence_hash', ''), + nullif(p_dossier ->> 'dossier_fingerprint', ''), + coalesce(nullif(p_dossier ->> 'change_status', ''), 'initial'), + nullif(p_dossier ->> 'data_as_of', '')::timestamptz, + coalesce(nullif(p_dossier ->> 'generated_at', '')::timestamptz, now()), + coalesce(p_dossier -> 'evidence_pack', '[]'::jsonb), + coalesce(nullif(p_dossier ->> 'created_at', '')::timestamptz, now()), + coalesce(nullif(p_dossier ->> 'updated_at', '')::timestamptz, now()), + p_dossier + ) + on conflict (id) do update set + title = excluded.title, + summary = excluded.summary, + memory_summary = excluded.memory_summary, + status = excluded.status, + provider_run_id = excluded.provider_run_id, + version_no = excluded.version_no, + previous_dossier_id = excluded.previous_dossier_id, + evidence_hash = excluded.evidence_hash, + dossier_fingerprint = excluded.dossier_fingerprint, + change_status = excluded.change_status, + data_as_of = excluded.data_as_of, + generated_at = excluded.generated_at, + evidence_pack_json = excluded.evidence_pack_json, + updated_at = excluded.updated_at, + deleted_at = null, + payload_json = excluded.payload_json + where public.sales_dossier_records.workspace_id = excluded.workspace_id; + + delete from public.sales_dossier_citations + where workspace_id = p_workspace_id and dossier_id = v_dossier_id; + + for citation in + select value from jsonb_array_elements(coalesce(p_dossier -> 'citations', '[]'::jsonb)) + loop + citation_id := v_dossier_id || ':' || coalesce(nullif(citation ->> 'id', ''), 'citation'); + if exists ( + select 1 from public.sales_dossier_citations + where id = citation_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace citation identifier conflict', errcode = '23505'; + end if; + + insert into public.sales_dossier_citations ( + id, workspace_id, dossier_id, citation_no, label, source_kind, url, created_at, payload_json + ) + values ( + citation_id, + p_workspace_id, + v_dossier_id, + coalesce(nullif(citation ->> 'id', ''), 'citation'), + citation ->> 'label', + citation ->> 'source_kind', + coalesce(citation ->> 'url', ''), + now(), + citation + ); + end loop; + + return jsonb_build_object( + 'id', v_dossier_id, + 'workspace_id', p_workspace_id, + 'version_no', coalesce(nullif(p_dossier ->> 'version_no', '')::integer, 1), + 'citation_count', jsonb_array_length(coalesce(p_dossier -> 'citations', '[]'::jsonb)) + ); +end; +$$; + +revoke all on function public.persist_sales_dossier(uuid, jsonb) from public, anon, authenticated; +grant execute on function public.persist_sales_dossier(uuid, jsonb) to service_role; + +create or replace function public.persist_provider_run( + p_workspace_id uuid, + p_run jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_run_id text := nullif(p_run ->> 'id', ''); + v_job_id text := nullif(p_run ->> 'job_id', ''); + step jsonb; + step_id text; +begin + if v_run_id is null then + raise exception using message = 'provider run id is required', errcode = '22023'; + end if; + + if not exists (select 1 from public.app_workspaces where id = p_workspace_id) then + raise exception using message = 'application workspace was not found', errcode = 'P0002'; + end if; + + if v_job_id is not null and not exists ( + select 1 from public.jobs where workspace_id = p_workspace_id and id = v_job_id + ) then + raise exception using message = 'provider run job was not found in application workspace', errcode = 'P0002'; + end if; + + if exists ( + select 1 from public.provider_runs + where id = v_run_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace provider run identifier conflict', errcode = '23505'; + end if; + + insert into public.provider_runs ( + id, workspace_id, job_id, operation, status, app_mode, entity_type, entity_id, + started_at, finished_at, duration_ms, result_ref, error_json, payload_json + ) + values ( + v_run_id, + p_workspace_id, + v_job_id, + coalesce(nullif(p_run ->> 'operation', ''), 'provider_workflow'), + coalesce(nullif(p_run ->> 'status', ''), 'running'), + coalesce(nullif(p_run ->> 'app_mode', ''), 'development'), + nullif(p_run ->> 'entity_type', ''), + nullif(p_run ->> 'entity_id', ''), + coalesce(nullif(p_run ->> 'started_at', '')::timestamptz, now()), + nullif(p_run ->> 'finished_at', '')::timestamptz, + nullif(p_run ->> 'duration_ms', '')::integer, + nullif(p_run ->> 'result_ref', ''), + case + when p_run -> 'error' is null or jsonb_typeof(p_run -> 'error') = 'null' then null + else p_run -> 'error' + end, + p_run + ) + on conflict (id) do update set + job_id = excluded.job_id, + operation = excluded.operation, + status = excluded.status, + app_mode = excluded.app_mode, + entity_type = excluded.entity_type, + entity_id = excluded.entity_id, + started_at = excluded.started_at, + finished_at = excluded.finished_at, + duration_ms = excluded.duration_ms, + result_ref = excluded.result_ref, + error_json = excluded.error_json, + payload_json = excluded.payload_json, + updated_at = now() + where public.provider_runs.workspace_id = excluded.workspace_id; + + delete from public.provider_run_steps + where workspace_id = p_workspace_id and provider_run_id = v_run_id; + + for step in + select value from jsonb_array_elements(coalesce(p_run -> 'steps', '[]'::jsonb)) + loop + step_id := nullif(step ->> 'id', ''); + if step_id is null then + raise exception using message = 'provider step id is required', errcode = '22023'; + end if; + if exists ( + select 1 from public.provider_run_steps + where id = step_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace provider step identifier conflict', errcode = '23505'; + end if; + + insert into public.provider_run_steps ( + id, workspace_id, provider_run_id, sequence, provider, operation, status, + input_summary, output_summary, request_id, raw_ref, usage_json, attempts, + started_at, finished_at, latency_ms, error_json + ) + values ( + step_id, + p_workspace_id, + v_run_id, + coalesce(nullif(step ->> 'sequence', '')::integer, 1), + coalesce(nullif(step ->> 'provider', ''), 'unknown'), + coalesce(nullif(step ->> 'operation', ''), 'provider_call'), + coalesce(nullif(step ->> 'status', ''), 'running'), + nullif(step ->> 'input_summary', ''), + nullif(step ->> 'output_summary', ''), + nullif(step ->> 'request_id', ''), + nullif(step ->> 'raw_ref', ''), + case + when step -> 'usage' is null or jsonb_typeof(step -> 'usage') = 'null' then null + else step -> 'usage' + end, + coalesce(nullif(step ->> 'attempts', '')::integer, 1), + coalesce(nullif(step ->> 'started_at', '')::timestamptz, now()), + nullif(step ->> 'finished_at', '')::timestamptz, + nullif(step ->> 'latency_ms', '')::integer, + case + when step -> 'error' is null or jsonb_typeof(step -> 'error') = 'null' then null + else step -> 'error' + end + ); + end loop; + + return jsonb_build_object( + 'id', v_run_id, + 'workspace_id', p_workspace_id, + 'job_id', v_job_id, + 'step_count', jsonb_array_length(coalesce(p_run -> 'steps', '[]'::jsonb)) + ); +end; +$$; + +revoke all on function public.persist_provider_run(uuid, jsonb) from public, anon, authenticated; +grant execute on function public.persist_provider_run(uuid, jsonb) to service_role; + +insert into public.schema_migrations(version, description) +values ('202607210008', 'Add dossier evidence versions and atomic job-linked provider runs') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/supabase/migrations/202607230001_paid_workflow_guard.sql b/demohouse/sales-intelligence-workbench/supabase/migrations/202607230001_paid_workflow_guard.sql new file mode 100644 index 00000000..f63fb444 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/supabase/migrations/202607230001_paid_workflow_guard.sql @@ -0,0 +1,318 @@ +begin; + +alter table public.jobs + add column if not exists is_paid boolean not null default false; + +create table if not exists public.paid_workflow_reservations ( + id text primary key, + workspace_id uuid not null references public.app_workspaces(id) on delete cascade, + job_id text not null, + job_type text not null, + status text not null default 'running' + check (status in ('running', 'succeeded', 'failed', 'cancelled', 'expired')), + reserved_at timestamptz not null default now(), + released_at timestamptz, + expires_at timestamptz not null, + payload_json jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (workspace_id, id), + foreign key (workspace_id, job_id) references public.jobs(workspace_id, id) on delete cascade +); + +create index if not exists paid_workflow_reservations_active_idx + on public.paid_workflow_reservations(workspace_id, status, expires_at); + +create index if not exists paid_workflow_reservations_daily_idx + on public.paid_workflow_reservations(workspace_id, reserved_at desc); + +alter table public.paid_workflow_reservations enable row level security; +revoke all on table public.paid_workflow_reservations from public, anon, authenticated; + +drop trigger if exists set_paid_workflow_reservations_updated_at on public.paid_workflow_reservations; +create trigger set_paid_workflow_reservations_updated_at +before update on public.paid_workflow_reservations +for each row execute function public.set_updated_at(); + +create or replace function public.reserve_paid_workflow( + p_workspace_id uuid, + p_job jsonb, + p_reservation_id text, + p_max_concurrent integer, + p_daily_limit integer, + p_budget_timezone text, + p_stale_after_seconds integer +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job_id text := nullif(p_job ->> 'id', ''); + v_job_type text := nullif(p_job ->> 'job_type', ''); + v_now timestamptz := now(); + v_running integer := 0; + v_daily integer := 0; + v_timezone text := coalesce(nullif(p_budget_timezone, ''), 'UTC'); + v_stale_seconds integer := greatest(coalesce(p_stale_after_seconds, 1800), 60); +begin + if v_job_id is null or v_job_type is null or nullif(p_reservation_id, '') is null then + raise exception using message = 'paid_workflow_reservation_invalid', errcode = '22023'; + end if; + if coalesce(p_max_concurrent, 0) < 0 or coalesce(p_daily_limit, 0) < 0 then + raise exception using message = 'paid_workflow_limit_invalid', errcode = '22023'; + end if; + if not exists (select 1 from public.app_workspaces where id = p_workspace_id) then + raise exception using message = 'application workspace was not found', errcode = 'P0002'; + end if; + if not exists (select 1 from pg_catalog.pg_timezone_names where name = v_timezone) then + raise exception using message = 'paid_workflow_timezone_invalid', errcode = '22023'; + end if; + if exists ( + select 1 from public.jobs where id = v_job_id and workspace_id <> p_workspace_id + ) then + raise exception using message = 'cross-workspace job identifier conflict', errcode = '23505'; + end if; + + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_workspace_id::text)); + + update public.paid_workflow_reservations + set + status = 'expired', + released_at = v_now, + payload_json = payload_json || jsonb_build_object('release_reason', 'reservation_expired') + where workspace_id = p_workspace_id + and status = 'running' + and expires_at <= v_now; + + update public.jobs as job + set + status = 'failed', + finished_at = v_now, + error_json = jsonb_build_object( + 'code', 'paid_workflow_reservation_expired', + 'message', '任务执行超过预约时限,已自动释放并发名额。', + 'retryable', true + ), + payload_json = jsonb_set( + jsonb_set(job.payload_json, '{status}', '"failed"'::jsonb, true), + '{error}', + jsonb_build_object( + 'code', 'paid_workflow_reservation_expired', + 'message', '任务执行超过预约时限,已自动释放并发名额。', + 'retryable', true + ), + true + ) + where job.workspace_id = p_workspace_id + and job.status = 'running' + and exists ( + select 1 + from public.paid_workflow_reservations as reservation + where reservation.workspace_id = job.workspace_id + and reservation.job_id = job.id + and reservation.status = 'expired' + and reservation.released_at = v_now + ); + + select count(*)::integer + into v_running + from public.paid_workflow_reservations + where workspace_id = p_workspace_id and status = 'running'; + + if coalesce(p_max_concurrent, 0) > 0 and v_running >= p_max_concurrent then + raise exception using + message = 'paid_workflow_concurrency_exceeded', + detail = jsonb_build_object( + 'running', v_running, + 'limit', p_max_concurrent, + 'retry_after_seconds', least(v_stale_seconds, 60) + )::text, + errcode = 'P0001'; + end if; + + select count(*)::integer + into v_daily + from public.paid_workflow_reservations + where workspace_id = p_workspace_id + and pg_catalog.timezone(v_timezone, reserved_at)::date = pg_catalog.timezone(v_timezone, v_now)::date; + + if coalesce(p_daily_limit, 0) > 0 and v_daily >= p_daily_limit then + raise exception using + message = 'paid_workflow_daily_limit_exceeded', + detail = jsonb_build_object('used', v_daily, 'limit', p_daily_limit, 'timezone', v_timezone)::text, + errcode = 'P0001'; + end if; + + insert into public.jobs ( + id, workspace_id, job_type, status, entity_type, entity_id, idempotency_key, + attempt_count, max_attempts, scheduled_at, started_at, finished_at, + error_json, payload_json, is_paid, created_at, updated_at + ) + values ( + v_job_id, + p_workspace_id, + v_job_type, + 'running', + nullif(p_job ->> 'entity_type', ''), + nullif(p_job ->> 'entity_id', ''), + nullif(p_job ->> 'idempotency_key', ''), + greatest(coalesce(nullif(p_job ->> 'attempt_count', '')::integer, 1), 1), + greatest(coalesce(nullif(p_job ->> 'max_attempts', '')::integer, 1), 1), + coalesce(nullif(p_job ->> 'scheduled_at', '')::timestamptz, v_now), + coalesce(nullif(p_job ->> 'started_at', '')::timestamptz, v_now), + null, + null, + p_job || jsonb_build_object('status', 'running', 'is_paid', true, 'reservation_id', p_reservation_id), + true, + coalesce(nullif(p_job ->> 'created_at', '')::timestamptz, v_now), + v_now + ) + on conflict (id) do update set + job_type = excluded.job_type, + status = excluded.status, + entity_type = excluded.entity_type, + entity_id = excluded.entity_id, + idempotency_key = excluded.idempotency_key, + attempt_count = excluded.attempt_count, + max_attempts = excluded.max_attempts, + scheduled_at = excluded.scheduled_at, + started_at = excluded.started_at, + finished_at = null, + error_json = null, + payload_json = excluded.payload_json, + is_paid = true, + updated_at = excluded.updated_at + where public.jobs.workspace_id = excluded.workspace_id; + + insert into public.paid_workflow_reservations ( + id, workspace_id, job_id, job_type, status, reserved_at, expires_at, payload_json + ) + values ( + p_reservation_id, + p_workspace_id, + v_job_id, + v_job_type, + 'running', + v_now, + v_now + make_interval(secs => v_stale_seconds), + jsonb_build_object('attempt_count', coalesce(nullif(p_job ->> 'attempt_count', '')::integer, 1)) + ); + + return jsonb_build_object( + 'job', p_job || jsonb_build_object('status', 'running', 'is_paid', true, 'reservation_id', p_reservation_id), + 'budget', jsonb_build_object( + 'running', v_running + 1, + 'max_concurrent', p_max_concurrent, + 'used_today', v_daily + 1, + 'daily_limit', p_daily_limit, + 'timezone', v_timezone + ) + ); +end; +$$; + +create or replace function public.finish_paid_workflow( + p_workspace_id uuid, + p_job jsonb, + p_reservation_id text +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job_id text := nullif(p_job ->> 'id', ''); + v_status text := coalesce(nullif(p_job ->> 'status', ''), 'failed'); + v_now timestamptz := now(); +begin + if v_job_id is null then + raise exception using message = 'paid_workflow_job_invalid', errcode = '22023'; + end if; + if v_status not in ('succeeded', 'failed', 'cancelled') then + raise exception using message = 'paid_workflow_terminal_status_invalid', errcode = '22023'; + end if; + + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_workspace_id::text)); + + update public.jobs + set + status = v_status, + finished_at = coalesce(nullif(p_job ->> 'finished_at', '')::timestamptz, v_now), + error_json = case + when p_job -> 'error' is null or jsonb_typeof(p_job -> 'error') = 'null' then null + else p_job -> 'error' + end, + payload_json = p_job, + updated_at = v_now + where workspace_id = p_workspace_id and id = v_job_id; + + if not found then + raise exception using message = 'paid workflow job was not found', errcode = 'P0002'; + end if; + + if nullif(p_reservation_id, '') is not null then + update public.paid_workflow_reservations + set status = v_status, released_at = v_now + where workspace_id = p_workspace_id + and id = p_reservation_id + and job_id = v_job_id + and status = 'running'; + end if; + + return p_job; +end; +$$; + +create or replace function public.get_paid_workflow_usage( + p_workspace_id uuid, + p_budget_timezone text +) +returns jsonb +language sql +security definer +set search_path = pg_catalog, public +stable +as $$ + select jsonb_build_object( + 'running', count(*) filter (where status = 'running' and expires_at > now()), + 'used_today', count(*) filter ( + where pg_catalog.timezone(coalesce(nullif(p_budget_timezone, ''), 'UTC'), reserved_at)::date + = pg_catalog.timezone(coalesce(nullif(p_budget_timezone, ''), 'UTC'), now())::date + ), + 'by_job_type', coalesce(( + select jsonb_object_agg(grouped.job_type, grouped.usage_count) + from ( + select job_type, count(*)::integer as usage_count + from public.paid_workflow_reservations + where workspace_id = p_workspace_id + and pg_catalog.timezone(coalesce(nullif(p_budget_timezone, ''), 'UTC'), reserved_at)::date + = pg_catalog.timezone(coalesce(nullif(p_budget_timezone, ''), 'UTC'), now())::date + group by job_type + ) as grouped + ), '{}'::jsonb) + ) + from public.paid_workflow_reservations + where workspace_id = p_workspace_id; +$$; + +revoke all on function public.reserve_paid_workflow(uuid, jsonb, text, integer, integer, text, integer) + from public, anon, authenticated; +revoke all on function public.finish_paid_workflow(uuid, jsonb, text) + from public, anon, authenticated; +revoke all on function public.get_paid_workflow_usage(uuid, text) + from public, anon, authenticated; +grant execute on function public.reserve_paid_workflow(uuid, jsonb, text, integer, integer, text, integer) + to service_role; +grant execute on function public.finish_paid_workflow(uuid, jsonb, text) + to service_role; +grant execute on function public.get_paid_workflow_usage(uuid, text) + to service_role; + +insert into public.schema_migrations(version, description) +values ('202607230001', 'Add atomic paid workflow concurrency and daily usage guard') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/supabase/migrations/202607230002_async_job_queue.sql b/demohouse/sales-intelligence-workbench/supabase/migrations/202607230002_async_job_queue.sql new file mode 100644 index 00000000..c52b027a --- /dev/null +++ b/demohouse/sales-intelligence-workbench/supabase/migrations/202607230002_async_job_queue.sql @@ -0,0 +1,710 @@ +begin; + +alter table public.jobs + add column if not exists stage text not null default 'queued', + add column if not exists progress smallint not null default 0, + add column if not exists worker_id text, + add column if not exists lease_expires_at timestamptz, + add column if not exists heartbeat_at timestamptz, + add column if not exists cancel_requested_at timestamptz; + +alter table public.jobs + drop constraint if exists jobs_progress_check; +alter table public.jobs + add constraint jobs_progress_check check (progress between 0 and 100); + +update public.jobs +set + stage = case status + when 'queued' then 'queued' + when 'running' then 'running' + when 'succeeded' then 'succeeded' + when 'failed' then 'failed' + when 'cancelled' then 'cancelled' + else stage + end, + progress = case when status = 'succeeded' then 100 else progress end +where stage = 'queued' or (status = 'succeeded' and progress <> 100); + +create index if not exists jobs_claim_queue_idx + on public.jobs(workspace_id, scheduled_at, created_at) + where status = 'queued'; + +create index if not exists jobs_running_lease_idx + on public.jobs(workspace_id, lease_expires_at) + where status = 'running'; + +create or replace function public.enqueue_sales_job( + p_workspace_id uuid, + p_job jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_job_id text := nullif(p_job ->> 'id', ''); + v_job_type text := nullif(p_job ->> 'job_type', ''); + v_idempotency_key text := nullif(p_job ->> 'idempotency_key', ''); + v_now timestamptz := now(); +begin + if v_job_id is null or v_job_type is null then + raise exception using message = 'sales_job_invalid', errcode = '22023'; + end if; + + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_workspace_id::text)); + + if v_idempotency_key is not null then + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and idempotency_key = v_idempotency_key + limit 1; + if found then + return to_jsonb(v_job); + end if; + end if; + + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = v_job_id + limit 1; + if found then + return to_jsonb(v_job); + end if; + + insert into public.jobs ( + id, workspace_id, job_type, status, entity_type, entity_id, idempotency_key, + attempt_count, max_attempts, scheduled_at, started_at, finished_at, + error_json, payload_json, is_paid, stage, progress, worker_id, + lease_expires_at, heartbeat_at, created_by, created_at, updated_at + ) + values ( + v_job_id, + p_workspace_id, + v_job_type, + 'queued', + nullif(p_job ->> 'entity_type', ''), + nullif(p_job ->> 'entity_id', ''), + v_idempotency_key, + 0, + greatest(coalesce(nullif(p_job ->> 'max_attempts', '')::integer, 3), 1), + coalesce(nullif(p_job ->> 'scheduled_at', '')::timestamptz, v_now), + null, + null, + null, + p_job || jsonb_build_object( + 'status', 'queued', + 'stage', 'queued', + 'progress', 0, + 'attempt_count', 0, + 'started_at', null, + 'finished_at', null, + 'error', null + ), + coalesce(nullif(p_job ->> 'is_paid', '')::boolean, true), + 'queued', + 0, + null, + null, + null, + nullif(p_job ->> 'created_by', '')::uuid, + coalesce(nullif(p_job ->> 'created_at', '')::timestamptz, v_now), + v_now + ) + returning * into v_job; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.claim_sales_job( + p_workspace_id uuid, + p_worker_id text, + p_job_types text[], + p_lease_seconds integer +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); + v_lease_seconds integer := greatest(coalesce(p_lease_seconds, 600), 60); +begin + if nullif(btrim(p_worker_id), '') is null then + raise exception using message = 'sales_job_worker_invalid', errcode = '22023'; + end if; + + -- A paid task may already have reached an external provider. Do not silently + -- replay it after a worker crash; fail it and require an explicit user retry. + update public.jobs as j + set + status = 'failed', + stage = 'failed', + finished_at = v_now, + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + error_json = jsonb_build_object( + 'code', 'worker_lease_expired', + 'message', '后台任务执行中断,请确认后重试。', + 'retryable', true + ), + payload_json = j.payload_json || jsonb_build_object( + 'status', 'failed', + 'stage', 'failed', + 'finished_at', v_now, + 'worker_id', null, + 'lease_expires_at', null, + 'error', jsonb_build_object( + 'code', 'worker_lease_expired', + 'message', '后台任务执行中断,请确认后重试。', + 'retryable', true + ) + ), + updated_at = v_now + where j.workspace_id = p_workspace_id + and j.status = 'running' + and j.lease_expires_at is not null + and j.lease_expires_at <= v_now + and exists ( + select 1 + from public.paid_workflow_reservations as r + where r.workspace_id = j.workspace_id + and r.job_id = j.id + and r.status = 'running' + ); + + update public.paid_workflow_reservations as r + set status = 'expired', released_at = v_now + where r.workspace_id = p_workspace_id + and r.status = 'running' + and exists ( + select 1 + from public.jobs as j + where j.workspace_id = r.workspace_id + and j.id = r.job_id + and j.status = 'failed' + and j.error_json ->> 'code' = 'worker_lease_expired' + ); + + -- A worker that died before reserving paid capacity is safe to retry. + update public.jobs as j + set + status = case when j.attempt_count < j.max_attempts then 'queued' else 'failed' end, + stage = case when j.attempt_count < j.max_attempts then 'queued' else 'failed' end, + progress = case when j.attempt_count < j.max_attempts then 0 else j.progress end, + scheduled_at = case when j.attempt_count < j.max_attempts then v_now else j.scheduled_at end, + finished_at = case when j.attempt_count < j.max_attempts then null else v_now end, + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + error_json = jsonb_build_object( + 'code', 'worker_lease_expired', + 'message', '后台任务执行中断。', + 'retryable', j.attempt_count < j.max_attempts + ), + payload_json = j.payload_json || jsonb_build_object( + 'status', case when j.attempt_count < j.max_attempts then 'queued' else 'failed' end, + 'stage', case when j.attempt_count < j.max_attempts then 'queued' else 'failed' end, + 'progress', case when j.attempt_count < j.max_attempts then 0 else j.progress end, + 'scheduled_at', case when j.attempt_count < j.max_attempts then v_now else j.scheduled_at end, + 'finished_at', case when j.attempt_count < j.max_attempts then null else v_now end, + 'worker_id', null, + 'lease_expires_at', null, + 'error', jsonb_build_object( + 'code', 'worker_lease_expired', + 'message', '后台任务执行中断。', + 'retryable', j.attempt_count < j.max_attempts + ) + ), + updated_at = v_now + where j.workspace_id = p_workspace_id + and j.status = 'running' + and j.lease_expires_at is not null + and j.lease_expires_at <= v_now; + + select * into v_job + from public.jobs + where workspace_id = p_workspace_id + and status = 'queued' + and coalesce(scheduled_at, created_at) <= v_now + and attempt_count < max_attempts + and (coalesce(array_length(p_job_types, 1), 0) = 0 or job_type = any(p_job_types)) + order by coalesce(scheduled_at, created_at), created_at, id + for update skip locked + limit 1; + + if not found then + return null; + end if; + + update public.jobs as j + set + status = 'running', + stage = 'starting', + progress = 1, + attempt_count = j.attempt_count + 1, + started_at = v_now, + finished_at = null, + error_json = null, + worker_id = left(p_worker_id, 160), + lease_expires_at = v_now + make_interval(secs => v_lease_seconds), + heartbeat_at = v_now, + payload_json = j.payload_json || jsonb_build_object( + 'status', 'running', + 'stage', 'starting', + 'progress', 1, + 'attempt_count', j.attempt_count + 1, + 'started_at', v_now, + 'finished_at', null, + 'error', null, + 'worker_id', left(p_worker_id, 160), + 'lease_expires_at', v_now + make_interval(secs => v_lease_seconds) + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = v_job.id + returning * into v_job; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.heartbeat_sales_job( + p_workspace_id uuid, + p_job_id text, + p_worker_id text, + p_stage text, + p_progress integer, + p_lease_seconds integer +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); + v_stage text := left(coalesce(nullif(btrim(p_stage), ''), 'running'), 80); + v_progress integer := greatest(1, least(coalesce(p_progress, 1), 99)); + v_lease_seconds integer := greatest(coalesce(p_lease_seconds, 600), 60); +begin + update public.jobs as j + set + stage = case when j.cancel_requested_at is not null then 'cancelling' else v_stage end, + progress = case when j.cancel_requested_at is not null then j.progress else v_progress end, + heartbeat_at = v_now, + lease_expires_at = v_now + make_interval(secs => v_lease_seconds), + payload_json = j.payload_json || jsonb_build_object( + 'stage', case when j.cancel_requested_at is not null then 'cancelling' else v_stage end, + 'progress', case when j.cancel_requested_at is not null then j.progress else v_progress end, + 'heartbeat_at', v_now, + 'lease_expires_at', v_now + make_interval(secs => v_lease_seconds) + ), + updated_at = v_now + where j.workspace_id = p_workspace_id + and j.id = p_job_id + and j.status = 'running' + and j.worker_id = p_worker_id + returning * into v_job; + + if not found then + raise exception using message = 'sales_job_claim_lost', errcode = 'P0001'; + end if; + + update public.paid_workflow_reservations + set expires_at = greatest(expires_at, v_now + make_interval(secs => v_lease_seconds)) + where workspace_id = p_workspace_id + and job_id = p_job_id + and status = 'running'; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.request_cancel_sales_job( + p_workspace_id uuid, + p_job_id text +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); +begin + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_workspace_id::text)); + + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = p_job_id + for update; + + if not found then + raise exception using message = 'sales_job_not_found', errcode = 'P0002'; + end if; + if v_job.status in ('succeeded', 'failed', 'cancelled') then + return to_jsonb(v_job); + end if; + + if v_job.status = 'queued' then + update public.jobs as j + set + status = 'cancelled', + stage = 'cancelled', + cancel_requested_at = v_now, + finished_at = v_now, + error_json = null, + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + payload_json = j.payload_json || jsonb_build_object( + 'status', 'cancelled', + 'stage', 'cancelled', + 'cancel_requested_at', v_now, + 'finished_at', v_now, + 'error', null, + 'worker_id', null, + 'lease_expires_at', null, + 'heartbeat_at', v_now + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + else + -- Running provider calls cannot be force-aborted safely. Keep the worker + -- lease and paid reservation until it reaches the next safe checkpoint. + update public.jobs as j + set + stage = 'cancelling', + cancel_requested_at = coalesce(j.cancel_requested_at, v_now), + payload_json = j.payload_json || jsonb_build_object( + 'stage', 'cancelling', + 'cancel_requested_at', coalesce(j.cancel_requested_at, v_now) + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + end if; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.acknowledge_cancel_sales_job( + p_workspace_id uuid, + p_job_id text, + p_worker_id text +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); +begin + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_workspace_id::text)); + + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = p_job_id + for update; + + if not found then + raise exception using message = 'sales_job_not_found', errcode = 'P0002'; + end if; + if v_job.status = 'cancelled' then + return to_jsonb(v_job); + end if; + if v_job.status <> 'running' or v_job.cancel_requested_at is null then + raise exception using message = 'sales_job_cancel_not_requested', errcode = 'P0001'; + end if; + if nullif(btrim(p_worker_id), '') is null or v_job.worker_id <> p_worker_id then + raise exception using message = 'sales_job_claim_lost', errcode = 'P0001'; + end if; + + update public.jobs as j + set + status = 'cancelled', + stage = 'cancelled', + finished_at = v_now, + error_json = null, + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + payload_json = j.payload_json || jsonb_build_object( + 'status', 'cancelled', + 'stage', 'cancelled', + 'finished_at', v_now, + 'error', null, + 'worker_id', null, + 'lease_expires_at', null, + 'heartbeat_at', v_now + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + + update public.paid_workflow_reservations + set status = 'cancelled', released_at = v_now + where workspace_id = p_workspace_id + and job_id = p_job_id + and status = 'running'; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.release_sales_job_claim( + p_workspace_id uuid, + p_job_id text, + p_worker_id text, + p_error jsonb, + p_retry boolean, + p_delay_seconds integer +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); + v_has_reservation boolean := false; + v_should_retry boolean := false; +begin + select exists ( + select 1 from public.paid_workflow_reservations + where workspace_id = p_workspace_id and job_id = p_job_id and status = 'running' + ) into v_has_reservation; + + select * into v_job + from public.jobs + where workspace_id = p_workspace_id + and id = p_job_id + and status = 'running' + and worker_id = p_worker_id + for update; + + if not found then + select * into v_job from public.jobs where workspace_id = p_workspace_id and id = p_job_id; + return case when found then to_jsonb(v_job) else null end; + end if; + + v_should_retry := coalesce(p_retry, false) + and not v_has_reservation + and v_job.attempt_count < v_job.max_attempts; + + update public.jobs as j + set + status = case when v_should_retry then 'queued' else 'failed' end, + stage = case when v_should_retry then 'queued' else 'failed' end, + progress = case when v_should_retry then 0 else j.progress end, + scheduled_at = case + when v_should_retry then v_now + make_interval(secs => greatest(coalesce(p_delay_seconds, 0), 0)) + else j.scheduled_at + end, + started_at = case when v_should_retry then null else j.started_at end, + finished_at = case when v_should_retry then null else v_now end, + error_json = coalesce(p_error, jsonb_build_object('code', 'worker_failed', 'message', '后台任务执行失败。')), + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + payload_json = j.payload_json || jsonb_build_object( + 'status', case when v_should_retry then 'queued' else 'failed' end, + 'stage', case when v_should_retry then 'queued' else 'failed' end, + 'progress', case when v_should_retry then 0 else j.progress end, + 'scheduled_at', case + when v_should_retry then v_now + make_interval(secs => greatest(coalesce(p_delay_seconds, 0), 0)) + else j.scheduled_at + end, + 'started_at', case when v_should_retry then null else j.started_at end, + 'finished_at', case when v_should_retry then null else v_now end, + 'error', coalesce(p_error, jsonb_build_object('code', 'worker_failed', 'message', '后台任务执行失败。')), + 'worker_id', null, + 'lease_expires_at', null + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + + if v_has_reservation then + update public.paid_workflow_reservations + set status = 'failed', released_at = v_now + where workspace_id = p_workspace_id and job_id = p_job_id and status = 'running'; + end if; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.retry_sales_job( + p_workspace_id uuid, + p_job_id text +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); +begin + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = p_job_id + for update; + + if not found then + raise exception using message = 'sales_job_not_found', errcode = 'P0002'; + end if; + if v_job.status not in ('failed', 'cancelled') then + raise exception using message = 'sales_job_not_retryable', errcode = 'P0001'; + end if; + if v_job.attempt_count >= v_job.max_attempts then + raise exception using message = 'sales_job_attempts_exhausted', errcode = 'P0001'; + end if; + + update public.jobs as j + set + status = 'queued', + stage = 'queued', + progress = 0, + scheduled_at = v_now, + started_at = null, + finished_at = null, + error_json = null, + worker_id = null, + lease_expires_at = null, + heartbeat_at = null, + cancel_requested_at = null, + payload_json = j.payload_json || jsonb_build_object( + 'status', 'queued', + 'stage', 'queued', + 'progress', 0, + 'scheduled_at', v_now, + 'started_at', null, + 'finished_at', null, + 'error', null, + 'worker_id', null, + 'lease_expires_at', null, + 'heartbeat_at', null, + 'cancel_requested_at', null, + 'reservation_id', null + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.finish_paid_workflow( + p_workspace_id uuid, + p_job jsonb, + p_reservation_id text +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job_id text := nullif(p_job ->> 'id', ''); + v_status text := coalesce(nullif(p_job ->> 'status', ''), 'failed'); + v_now timestamptz := now(); + v_job public.jobs%rowtype; +begin + if v_job_id is null then + raise exception using message = 'paid_workflow_job_invalid', errcode = '22023'; + end if; + if v_status not in ('succeeded', 'failed', 'cancelled') then + raise exception using message = 'paid_workflow_terminal_status_invalid', errcode = '22023'; + end if; + + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_workspace_id::text)); + + update public.jobs as j + set + status = v_status, + stage = v_status, + progress = case when v_status = 'succeeded' then 100 else j.progress end, + finished_at = coalesce(nullif(p_job ->> 'finished_at', '')::timestamptz, v_now), + error_json = case + when p_job -> 'error' is null or jsonb_typeof(p_job -> 'error') = 'null' then null + else p_job -> 'error' + end, + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + cancel_requested_at = case when v_status = 'succeeded' then null else j.cancel_requested_at end, + payload_json = p_job || jsonb_build_object( + 'status', v_status, + 'stage', v_status, + 'progress', case when v_status = 'succeeded' then 100 else j.progress end, + 'worker_id', null, + 'lease_expires_at', null, + 'heartbeat_at', v_now, + 'cancel_requested_at', case when v_status = 'succeeded' then null else j.cancel_requested_at end + ), + updated_at = v_now + where j.workspace_id = p_workspace_id + and j.id = v_job_id + and j.status = 'running' + returning * into v_job; + + if not found then + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = v_job_id; + if found and v_job.status in ('succeeded', 'failed', 'cancelled') then + return to_jsonb(v_job); + end if; + raise exception using message = 'paid workflow job was not found', errcode = 'P0002'; + end if; + + if nullif(p_reservation_id, '') is not null then + update public.paid_workflow_reservations + set status = v_status, released_at = v_now + where workspace_id = p_workspace_id + and id = p_reservation_id + and job_id = v_job_id + and status = 'running'; + end if; + + return to_jsonb(v_job); +end; +$$; + +revoke all on function public.enqueue_sales_job(uuid, jsonb) from public, anon, authenticated; +revoke all on function public.claim_sales_job(uuid, text, text[], integer) from public, anon, authenticated; +revoke all on function public.heartbeat_sales_job(uuid, text, text, text, integer, integer) from public, anon, authenticated; +revoke all on function public.release_sales_job_claim(uuid, text, text, jsonb, boolean, integer) from public, anon, authenticated; +revoke all on function public.request_cancel_sales_job(uuid, text) from public, anon, authenticated; +revoke all on function public.acknowledge_cancel_sales_job(uuid, text, text) from public, anon, authenticated; +revoke all on function public.retry_sales_job(uuid, text) from public, anon, authenticated; +grant execute on function public.enqueue_sales_job(uuid, jsonb) to service_role; +grant execute on function public.claim_sales_job(uuid, text, text[], integer) to service_role; +grant execute on function public.heartbeat_sales_job(uuid, text, text, text, integer, integer) to service_role; +grant execute on function public.release_sales_job_claim(uuid, text, text, jsonb, boolean, integer) to service_role; +grant execute on function public.request_cancel_sales_job(uuid, text) to service_role; +grant execute on function public.acknowledge_cancel_sales_job(uuid, text, text) to service_role; +grant execute on function public.retry_sales_job(uuid, text) to service_role; + +insert into public.schema_migrations(version, description) +values ('202607230002', 'Add persistent asynchronous sales job queue and worker leases') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/supabase/migrations/202607230003_safe_job_cancellation.sql b/demohouse/sales-intelligence-workbench/supabase/migrations/202607230003_safe_job_cancellation.sql new file mode 100644 index 00000000..dbc4da31 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/supabase/migrations/202607230003_safe_job_cancellation.sql @@ -0,0 +1,343 @@ +begin; + +alter table public.jobs + add column if not exists cancel_requested_at timestamptz; + +create or replace function public.heartbeat_sales_job( + p_workspace_id uuid, + p_job_id text, + p_worker_id text, + p_stage text, + p_progress integer, + p_lease_seconds integer +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); + v_stage text := left(coalesce(nullif(btrim(p_stage), ''), 'running'), 80); + v_progress integer := greatest(1, least(coalesce(p_progress, 1), 99)); + v_lease_seconds integer := greatest(coalesce(p_lease_seconds, 600), 60); +begin + update public.jobs as j + set + stage = case when j.cancel_requested_at is not null then 'cancelling' else v_stage end, + progress = case when j.cancel_requested_at is not null then j.progress else v_progress end, + heartbeat_at = v_now, + lease_expires_at = v_now + make_interval(secs => v_lease_seconds), + payload_json = j.payload_json || jsonb_build_object( + 'stage', case when j.cancel_requested_at is not null then 'cancelling' else v_stage end, + 'progress', case when j.cancel_requested_at is not null then j.progress else v_progress end, + 'heartbeat_at', v_now, + 'lease_expires_at', v_now + make_interval(secs => v_lease_seconds) + ), + updated_at = v_now + where j.workspace_id = p_workspace_id + and j.id = p_job_id + and j.status = 'running' + and j.worker_id = p_worker_id + returning * into v_job; + + if not found then + raise exception using message = 'sales_job_claim_lost', errcode = 'P0001'; + end if; + + update public.paid_workflow_reservations + set expires_at = greatest(expires_at, v_now + make_interval(secs => v_lease_seconds)) + where workspace_id = p_workspace_id + and job_id = p_job_id + and status = 'running'; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.request_cancel_sales_job( + p_workspace_id uuid, + p_job_id text +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); +begin + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_workspace_id::text)); + + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = p_job_id + for update; + + if not found then + raise exception using message = 'sales_job_not_found', errcode = 'P0002'; + end if; + if v_job.status in ('succeeded', 'failed', 'cancelled') then + return to_jsonb(v_job); + end if; + + if v_job.status = 'queued' then + update public.jobs as j + set + status = 'cancelled', + stage = 'cancelled', + cancel_requested_at = v_now, + finished_at = v_now, + error_json = null, + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + payload_json = j.payload_json || jsonb_build_object( + 'status', 'cancelled', + 'stage', 'cancelled', + 'cancel_requested_at', v_now, + 'finished_at', v_now, + 'error', null, + 'worker_id', null, + 'lease_expires_at', null, + 'heartbeat_at', v_now + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + else + update public.jobs as j + set + stage = 'cancelling', + cancel_requested_at = coalesce(j.cancel_requested_at, v_now), + payload_json = j.payload_json || jsonb_build_object( + 'stage', 'cancelling', + 'cancel_requested_at', coalesce(j.cancel_requested_at, v_now) + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + end if; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.acknowledge_cancel_sales_job( + p_workspace_id uuid, + p_job_id text, + p_worker_id text +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); +begin + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_workspace_id::text)); + + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = p_job_id + for update; + + if not found then + raise exception using message = 'sales_job_not_found', errcode = 'P0002'; + end if; + if v_job.status = 'cancelled' then + return to_jsonb(v_job); + end if; + if v_job.status <> 'running' or v_job.cancel_requested_at is null then + raise exception using message = 'sales_job_cancel_not_requested', errcode = 'P0001'; + end if; + if nullif(btrim(p_worker_id), '') is null or v_job.worker_id <> p_worker_id then + raise exception using message = 'sales_job_claim_lost', errcode = 'P0001'; + end if; + + update public.jobs as j + set + status = 'cancelled', + stage = 'cancelled', + finished_at = v_now, + error_json = null, + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + payload_json = j.payload_json || jsonb_build_object( + 'status', 'cancelled', + 'stage', 'cancelled', + 'finished_at', v_now, + 'error', null, + 'worker_id', null, + 'lease_expires_at', null, + 'heartbeat_at', v_now + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + + update public.paid_workflow_reservations + set status = 'cancelled', released_at = v_now + where workspace_id = p_workspace_id + and job_id = p_job_id + and status = 'running'; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.retry_sales_job( + p_workspace_id uuid, + p_job_id text +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); +begin + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = p_job_id + for update; + + if not found then + raise exception using message = 'sales_job_not_found', errcode = 'P0002'; + end if; + if v_job.status not in ('failed', 'cancelled') then + raise exception using message = 'sales_job_not_retryable', errcode = 'P0001'; + end if; + if v_job.attempt_count >= v_job.max_attempts then + raise exception using message = 'sales_job_attempts_exhausted', errcode = 'P0001'; + end if; + + update public.jobs as j + set + status = 'queued', + stage = 'queued', + progress = 0, + scheduled_at = v_now, + started_at = null, + finished_at = null, + error_json = null, + worker_id = null, + lease_expires_at = null, + heartbeat_at = null, + cancel_requested_at = null, + payload_json = j.payload_json || jsonb_build_object( + 'status', 'queued', + 'stage', 'queued', + 'progress', 0, + 'scheduled_at', v_now, + 'started_at', null, + 'finished_at', null, + 'error', null, + 'worker_id', null, + 'lease_expires_at', null, + 'heartbeat_at', null, + 'cancel_requested_at', null, + 'reservation_id', null + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.finish_paid_workflow( + p_workspace_id uuid, + p_job jsonb, + p_reservation_id text +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job_id text := nullif(p_job ->> 'id', ''); + v_status text := coalesce(nullif(p_job ->> 'status', ''), 'failed'); + v_now timestamptz := now(); + v_job public.jobs%rowtype; +begin + if v_job_id is null then + raise exception using message = 'paid_workflow_job_invalid', errcode = '22023'; + end if; + if v_status not in ('succeeded', 'failed', 'cancelled') then + raise exception using message = 'paid_workflow_terminal_status_invalid', errcode = '22023'; + end if; + + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_workspace_id::text)); + + update public.jobs as j + set + status = v_status, + stage = v_status, + progress = case when v_status = 'succeeded' then 100 else j.progress end, + finished_at = coalesce(nullif(p_job ->> 'finished_at', '')::timestamptz, v_now), + error_json = case + when p_job -> 'error' is null or jsonb_typeof(p_job -> 'error') = 'null' then null + else p_job -> 'error' + end, + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + cancel_requested_at = case when v_status = 'succeeded' then null else j.cancel_requested_at end, + payload_json = p_job || jsonb_build_object( + 'status', v_status, + 'stage', v_status, + 'progress', case when v_status = 'succeeded' then 100 else j.progress end, + 'worker_id', null, + 'lease_expires_at', null, + 'heartbeat_at', v_now, + 'cancel_requested_at', case when v_status = 'succeeded' then null else j.cancel_requested_at end + ), + updated_at = v_now + where j.workspace_id = p_workspace_id + and j.id = v_job_id + and j.status = 'running' + returning * into v_job; + + if not found then + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = v_job_id; + if found and v_job.status in ('succeeded', 'failed', 'cancelled') then + return to_jsonb(v_job); + end if; + raise exception using message = 'paid workflow job was not found', errcode = 'P0002'; + end if; + + if nullif(p_reservation_id, '') is not null then + update public.paid_workflow_reservations + set status = v_status, released_at = v_now + where workspace_id = p_workspace_id + and id = p_reservation_id + and job_id = v_job_id + and status = 'running'; + end if; + + return to_jsonb(v_job); +end; +$$; + +revoke all on function public.request_cancel_sales_job(uuid, text) from public, anon, authenticated; +revoke all on function public.acknowledge_cancel_sales_job(uuid, text, text) from public, anon, authenticated; +grant execute on function public.request_cancel_sales_job(uuid, text) to service_role; +grant execute on function public.acknowledge_cancel_sales_job(uuid, text, text) to service_role; + +insert into public.schema_migrations(version, description) +values ('202607230003', 'Add safe cancellation checkpoints for asynchronous paid jobs') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/supabase/migrations/202607280001_openviking_qa_boundary.sql b/demohouse/sales-intelligence-workbench/supabase/migrations/202607280001_openviking_qa_boundary.sql new file mode 100644 index 00000000..1dd594b0 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/supabase/migrations/202607280001_openviking_qa_boundary.sql @@ -0,0 +1,27 @@ +begin; + +do $$ +begin + if to_regclass('public.sales_qa_messages') is not null + and to_regclass('public.sales_qa_messages_legacy') is null then + alter table public.sales_qa_messages rename to sales_qa_messages_legacy; + end if; +end +$$; + +do $$ +begin + if to_regclass('public.sales_qa_messages_legacy') is not null then + revoke all on table public.sales_qa_messages_legacy from public, anon, authenticated; + grant all on table public.sales_qa_messages_legacy to service_role; + comment on table public.sales_qa_messages_legacy is + 'Read-only migration archive. Current QA content is stored and restored by OpenViking.'; + end if; +end +$$; + +insert into public.schema_migrations(version, description) +values ('202607280001', 'Quarantine legacy QA message rows and make OpenViking the sole QA content store') +on conflict(version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/supabase/migrations/202607280002_secure_internal_tables.sql b/demohouse/sales-intelligence-workbench/supabase/migrations/202607280002_secure_internal_tables.sql new file mode 100644 index 00000000..4f9053b7 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/supabase/migrations/202607280002_secure_internal_tables.sql @@ -0,0 +1,11 @@ +begin; + +alter table public.schema_migrations enable row level security; +revoke all on table public.schema_migrations from public, anon, authenticated; +grant all on table public.schema_migrations to service_role; + +insert into public.schema_migrations(version, description) +values ('202607280002', 'Enable RLS and restrict the project migration table to the service role') +on conflict(version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/supabase/migrations/202607290001_reconcile_terminal_job_provider_runs.sql b/demohouse/sales-intelligence-workbench/supabase/migrations/202607290001_reconcile_terminal_job_provider_runs.sql new file mode 100644 index 00000000..3b282aac --- /dev/null +++ b/demohouse/sales-intelligence-workbench/supabase/migrations/202607290001_reconcile_terminal_job_provider_runs.sql @@ -0,0 +1,106 @@ +begin; + +create or replace function public.reconcile_terminal_job_provider_runs() +returns trigger +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_finished_at timestamptz := coalesce(new.finished_at, now()); + v_status text; + v_error jsonb; +begin + if new.status not in ('failed', 'cancelled') then + return new; + end if; + + v_status := case when new.status = 'cancelled' then 'cancelled' else 'failed' end; + v_error := case + when v_status = 'cancelled' then null + else jsonb_build_object( + 'code', coalesce(nullif(new.error_json ->> 'code', ''), 'job_terminated'), + 'message', '任务执行已终止,未继续等待上游返回。', + 'category', 'workflow', + 'retryable', lower(coalesce(new.error_json ->> 'retryable', 'false')) in ('1', 'true', 'yes', 'on') + ) + end; + + update public.provider_run_steps as s + set + status = v_status, + output_summary = case + when v_status = 'cancelled' then '任务已取消,未继续等待上游返回。' + else '任务执行已终止,未继续等待上游返回。' + end, + finished_at = v_finished_at, + latency_ms = least( + 2147483647, + greatest(0, floor(extract(epoch from (v_finished_at - s.started_at)) * 1000)) + )::integer, + error_json = v_error, + updated_at = v_finished_at + where s.workspace_id = new.workspace_id + and s.status = 'running' + and exists ( + select 1 + from public.provider_runs as r + where r.workspace_id = s.workspace_id + and r.id = s.provider_run_id + and r.job_id = new.id + and r.status = 'running' + ); + + update public.provider_runs as r + set + status = v_status, + finished_at = v_finished_at, + duration_ms = least( + 2147483647, + greatest(0, floor(extract(epoch from (v_finished_at - r.started_at)) * 1000)) + )::integer, + error_json = v_error, + payload_json = r.payload_json || jsonb_build_object( + 'status', v_status, + 'finished_at', v_finished_at, + 'duration_ms', least( + 2147483647, + greatest(0, floor(extract(epoch from (v_finished_at - r.started_at)) * 1000)) + )::integer, + 'error', v_error + ), + updated_at = v_finished_at + where r.workspace_id = new.workspace_id + and r.job_id = new.id + and r.status = 'running'; + + return new; +end; +$$; + +revoke all on function public.reconcile_terminal_job_provider_runs() from public, anon, authenticated; +grant execute on function public.reconcile_terminal_job_provider_runs() to service_role; + +drop trigger if exists reconcile_terminal_job_provider_runs_after_update on public.jobs; +create trigger reconcile_terminal_job_provider_runs_after_update +after update of status, error_json on public.jobs +for each row +execute function public.reconcile_terminal_job_provider_runs(); + +-- Reconcile runs that were orphaned before this trigger was installed. +update public.jobs as j +set status = j.status +where j.status in ('failed', 'cancelled') + and exists ( + select 1 + from public.provider_runs as r + where r.workspace_id = j.workspace_id + and r.job_id = j.id + and r.status = 'running' + ); + +insert into public.schema_migrations(version, description) +values ('202607290001', 'Reconcile running provider traces when their worker job terminates') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/supabase/migrations/202607300001_durable_job_checkpoints.sql b/demohouse/sales-intelligence-workbench/supabase/migrations/202607300001_durable_job_checkpoints.sql new file mode 100644 index 00000000..64763e21 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/supabase/migrations/202607300001_durable_job_checkpoints.sql @@ -0,0 +1,222 @@ +begin; + +alter table public.jobs + add column if not exists checkpoint_json jsonb not null default '{}'::jsonb, + add column if not exists progress_detail_json jsonb not null default '{}'::jsonb; + +alter table public.jobs + drop constraint if exists jobs_checkpoint_json_object_check; +alter table public.jobs + add constraint jobs_checkpoint_json_object_check + check (jsonb_typeof(checkpoint_json) = 'object'); + +alter table public.jobs + drop constraint if exists jobs_progress_detail_json_object_check; +alter table public.jobs + add constraint jobs_progress_detail_json_object_check + check (jsonb_typeof(progress_detail_json) = 'object'); + +update public.jobs +set + checkpoint_json = case + when jsonb_typeof(payload_json -> 'checkpoint') = 'object' + then payload_json -> 'checkpoint' + else checkpoint_json + end, + progress_detail_json = case + when jsonb_typeof(payload_json -> 'progress_detail') = 'object' + then payload_json -> 'progress_detail' + else progress_detail_json + end +where payload_json ? 'checkpoint' or payload_json ? 'progress_detail'; + +create or replace function public.checkpoint_sales_job( + p_workspace_id uuid, + p_job_id text, + p_worker_id text, + p_stage text, + p_progress integer, + p_progress_detail jsonb, + p_checkpoint_patch jsonb, + p_lease_seconds integer +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); + v_stage text := left(coalesce(nullif(btrim(p_stage), ''), 'running'), 80); + v_progress integer := greatest(1, least(coalesce(p_progress, 1), 99)); + v_detail jsonb := coalesce(p_progress_detail, '{}'::jsonb); + v_patch jsonb := coalesce(p_checkpoint_patch, '{}'::jsonb); + v_lease_seconds integer := greatest(coalesce(p_lease_seconds, 600), 60); +begin + if jsonb_typeof(v_detail) <> 'object' or jsonb_typeof(v_patch) <> 'object' then + raise exception using message = 'sales_job_checkpoint_invalid', errcode = '22023'; + end if; + if pg_catalog.octet_length(v_patch::text) > 524288 then + raise exception using message = 'sales_job_checkpoint_too_large', errcode = '22023'; + end if; + + update public.jobs as j + set + stage = case when j.cancel_requested_at is not null then 'cancelling' else v_stage end, + progress = case when j.cancel_requested_at is not null then j.progress else v_progress end, + progress_detail_json = case + when j.cancel_requested_at is not null + then jsonb_build_object('message', '正在安全取消任务') + else v_detail + end, + checkpoint_json = j.checkpoint_json || v_patch, + heartbeat_at = v_now, + lease_expires_at = v_now + make_interval(secs => v_lease_seconds), + payload_json = j.payload_json || jsonb_build_object( + 'stage', case when j.cancel_requested_at is not null then 'cancelling' else v_stage end, + 'progress', case when j.cancel_requested_at is not null then j.progress else v_progress end, + 'progress_detail', case + when j.cancel_requested_at is not null + then jsonb_build_object('message', '正在安全取消任务') + else v_detail + end, + 'checkpoint', j.checkpoint_json || v_patch, + 'heartbeat_at', v_now, + 'lease_expires_at', v_now + make_interval(secs => v_lease_seconds) + ), + updated_at = v_now + where j.workspace_id = p_workspace_id + and j.id = p_job_id + and j.status = 'running' + and j.worker_id = p_worker_id + returning * into v_job; + + if not found then + raise exception using message = 'sales_job_claim_lost', errcode = 'P0001'; + end if; + + update public.paid_workflow_reservations + set expires_at = greatest(expires_at, v_now + make_interval(secs => v_lease_seconds)) + where workspace_id = p_workspace_id + and job_id = p_job_id + and status = 'running'; + + return to_jsonb(v_job); +end; +$$; + +create or replace function public.release_sales_job_claim( + p_workspace_id uuid, + p_job_id text, + p_worker_id text, + p_error jsonb, + p_retry boolean, + p_delay_seconds integer +) +returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_job public.jobs%rowtype; + v_now timestamptz := now(); + v_next_retry_at timestamptz; + v_should_retry boolean := false; +begin + select * into v_job + from public.jobs + where workspace_id = p_workspace_id + and id = p_job_id + and status = 'running' + and worker_id = p_worker_id + for update; + + if not found then + select * into v_job + from public.jobs + where workspace_id = p_workspace_id and id = p_job_id; + return case when found then to_jsonb(v_job) else null end; + end if; + + v_should_retry := coalesce(p_retry, false) + and v_job.attempt_count < v_job.max_attempts; + v_next_retry_at := v_now + + make_interval(secs => greatest(coalesce(p_delay_seconds, 0), 0)); + + update public.jobs as j + set + status = case when v_should_retry then 'queued' else 'failed' end, + stage = case when v_should_retry then 'retry_wait' else 'failed' end, + progress = j.progress, + progress_detail_json = case + when v_should_retry then jsonb_build_object( + 'message', '上游服务暂时不可用,正在自动重试', + 'next_retry_at', v_next_retry_at + ) + else '{}'::jsonb + end, + scheduled_at = case when v_should_retry then v_next_retry_at else j.scheduled_at end, + started_at = case when v_should_retry then null else j.started_at end, + finished_at = case when v_should_retry then null else v_now end, + error_json = coalesce( + p_error, + jsonb_build_object('code', 'worker_failed', 'message', '后台任务执行失败。') + ), + worker_id = null, + lease_expires_at = null, + heartbeat_at = v_now, + payload_json = j.payload_json || jsonb_build_object( + 'status', case when v_should_retry then 'queued' else 'failed' end, + 'stage', case when v_should_retry then 'retry_wait' else 'failed' end, + 'progress', j.progress, + 'progress_detail', case + when v_should_retry then jsonb_build_object( + 'message', '上游服务暂时不可用,正在自动重试', + 'next_retry_at', v_next_retry_at + ) + else '{}'::jsonb + end, + 'scheduled_at', case when v_should_retry then v_next_retry_at else j.scheduled_at end, + 'started_at', case when v_should_retry then null else j.started_at end, + 'finished_at', case when v_should_retry then null else v_now end, + 'error', coalesce( + p_error, + jsonb_build_object('code', 'worker_failed', 'message', '后台任务执行失败。') + ), + 'worker_id', null, + 'lease_expires_at', null + ), + updated_at = v_now + where j.workspace_id = p_workspace_id and j.id = p_job_id + returning * into v_job; + + update public.paid_workflow_reservations + set + status = 'failed', + released_at = v_now, + payload_json = payload_json || jsonb_build_object( + 'release_reason', + case when v_should_retry then 'retryable_worker_failure' else 'worker_failure' end + ) + where workspace_id = p_workspace_id + and job_id = p_job_id + and status = 'running'; + + return to_jsonb(v_job); +end; +$$; + +revoke all on function public.checkpoint_sales_job( + uuid, text, text, text, integer, jsonb, jsonb, integer +) from public, anon, authenticated; +grant execute on function public.checkpoint_sales_job( + uuid, text, text, text, integer, jsonb, jsonb, integer +) to service_role; + +insert into public.schema_migrations(version, description) +values ('202607300001', 'Add durable job checkpoints and retryable paid-stage recovery') +on conflict (version) do nothing; + +commit; diff --git a/demohouse/sales-intelligence-workbench/supabase/tests/202607210008_stage4_evidence_smoke.sql b/demohouse/sales-intelligence-workbench/supabase/tests/202607210008_stage4_evidence_smoke.sql new file mode 100644 index 00000000..c22b050b --- /dev/null +++ b/demohouse/sales-intelligence-workbench/supabase/tests/202607210008_stage4_evidence_smoke.sql @@ -0,0 +1,214 @@ +begin; + +do $$ +declare + v_workspace_id uuid; + v_company_id text := '__stage4_smoke_company__'; + v_job_id text := '__stage4_smoke_job__'; + v_run_id text := '__stage4_smoke_run__'; + v_step_id text := '__stage4_smoke_step__'; + v_dossier_v1_id text := '__stage4_smoke_dossier_v1__'; + v_dossier_v2_id text := '__stage4_smoke_dossier_v2__'; + v_text text; + v_integer integer; +begin + select id + into v_workspace_id + from public.app_workspaces + order by created_at asc + limit 1; + + if v_workspace_id is null then + raise exception 'stage4 smoke requires one application workspace'; + end if; + + insert into public.sales_companies ( + id, workspace_id, name, initial, industry, location, tags, payload_json + ) + values ( + v_company_id, + v_workspace_id, + '__Stage4 Smoke Company__', + 'S', + 'smoke-test', + 'test-only', + '["stage4-smoke"]'::jsonb, + '{"test_only":true}'::jsonb + ); + + insert into public.jobs ( + id, workspace_id, job_type, status, entity_type, entity_id, + idempotency_key, attempt_count, max_attempts, scheduled_at, + started_at, finished_at, payload_json + ) + values ( + v_job_id, + v_workspace_id, + 'stage4_smoke', + 'succeeded', + 'company', + v_company_id, + v_job_id, + 1, + 1, + now(), + now() - interval '1 second', + now(), + '{"test_only":true}'::jsonb + ); + + perform public.persist_provider_run( + v_workspace_id, + jsonb_build_object( + 'id', v_run_id, + 'job_id', v_job_id, + 'operation', 'stage4_smoke', + 'status', 'succeeded', + 'app_mode', 'development', + 'entity_type', 'company', + 'entity_id', v_company_id, + 'started_at', now() - interval '1 second', + 'finished_at', now(), + 'duration_ms', 1000, + 'steps', jsonb_build_array( + jsonb_build_object( + 'id', v_step_id, + 'sequence', 1, + 'provider', 'ark', + 'operation', 'structured_generation', + 'status', 'succeeded', + 'input_summary', 'stage4 smoke input', + 'output_summary', 'stage4 smoke output', + 'request_id', 'stage4-smoke-request', + 'usage', jsonb_build_object( + 'prompt_tokens', 10, + 'completion_tokens', 5, + 'total_tokens', 15 + ), + 'attempts', 1, + 'started_at', now() - interval '500 milliseconds', + 'finished_at', now(), + 'latency_ms', 500 + ) + ) + ) + ); + + select job_id + into v_text + from public.provider_runs + where workspace_id = v_workspace_id and id = v_run_id; + + if v_text is distinct from v_job_id then + raise exception 'provider run job binding mismatch: %', v_text; + end if; + + select (usage_json ->> 'total_tokens')::integer + into v_integer + from public.provider_run_steps + where workspace_id = v_workspace_id and id = v_step_id; + + if v_integer is distinct from 15 then + raise exception 'provider run token usage mismatch: %', v_integer; + end if; + + perform public.persist_sales_dossier( + v_workspace_id, + jsonb_build_object( + 'id', v_dossier_v1_id, + 'company_id', v_company_id, + 'title', 'Stage 4 smoke dossier v1', + 'summary', 'Initial evidence-backed dossier.', + 'memory_summary', 'Initial memory summary.', + 'status', 'completed', + 'provider_run_id', v_run_id, + 'version_no', 1, + 'evidence_hash', 'stage4-smoke-evidence-v1', + 'dossier_fingerprint', 'stage4-smoke-fingerprint-v1', + 'change_status', 'initial', + 'data_as_of', now() - interval '1 day', + 'generated_at', now(), + 'evidence_pack', jsonb_build_array( + jsonb_build_object( + 'id', 'evidence-professional-1', + 'source_kind', 'professional_dataset', + 'label', 'Stage 4 smoke professional evidence', + 'summary', 'Version one evidence.' + ) + ), + 'citations', jsonb_build_array( + jsonb_build_object( + 'id', 'evidence-professional-1', + 'source_kind', 'professional_dataset', + 'label', 'Stage 4 smoke professional evidence', + 'url', 'https://example.invalid/stage4-smoke/v1' + ) + ) + ) + ); + + perform public.persist_sales_dossier( + v_workspace_id, + jsonb_build_object( + 'id', v_dossier_v2_id, + 'company_id', v_company_id, + 'title', 'Stage 4 smoke dossier v2', + 'summary', 'Changed evidence-backed dossier.', + 'memory_summary', 'Changed memory summary.', + 'status', 'completed', + 'provider_run_id', v_run_id, + 'version_no', 2, + 'previous_dossier_id', v_dossier_v1_id, + 'evidence_hash', 'stage4-smoke-evidence-v2', + 'dossier_fingerprint', 'stage4-smoke-fingerprint-v2', + 'change_status', 'changed', + 'data_as_of', now(), + 'generated_at', now(), + 'evidence_pack', jsonb_build_array( + jsonb_build_object( + 'id', 'evidence-professional-2', + 'source_kind', 'professional_dataset', + 'label', 'Stage 4 smoke changed evidence', + 'summary', 'Version two evidence.' + ) + ), + 'citations', jsonb_build_array( + jsonb_build_object( + 'id', 'evidence-professional-2', + 'source_kind', 'professional_dataset', + 'label', 'Stage 4 smoke changed evidence', + 'url', 'https://example.invalid/stage4-smoke/v2' + ) + ) + ) + ); + + select previous_dossier_id + into v_text + from public.sales_dossier_records + where workspace_id = v_workspace_id + and id = v_dossier_v2_id + and version_no = 2 + and change_status = 'changed' + and evidence_hash = 'stage4-smoke-evidence-v2' + and jsonb_array_length(evidence_pack_json) = 1; + + if v_text is distinct from v_dossier_v1_id then + raise exception 'dossier version chain mismatch: %', v_text; + end if; + + select count(*)::integer + into v_integer + from public.sales_dossier_citations + where workspace_id = v_workspace_id + and dossier_id in (v_dossier_v1_id, v_dossier_v2_id); + + if v_integer is distinct from 2 then + raise exception 'dossier citation count mismatch: %', v_integer; + end if; +end; +$$; + +select 'stage4_evidence_smoke_passed' as result; + +rollback; diff --git a/demohouse/sales-intelligence-workbench/supabase/tests/202607230002_paid_workflow_guard_smoke.sql b/demohouse/sales-intelligence-workbench/supabase/tests/202607230002_paid_workflow_guard_smoke.sql new file mode 100644 index 00000000..733aec86 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/supabase/tests/202607230002_paid_workflow_guard_smoke.sql @@ -0,0 +1,88 @@ +begin; + +do $paid_workflow_guard_smoke$ +declare + v_workspace_id uuid; + v_suffix text := pg_catalog.txid_current()::text; + v_job_id text := 'smoke_paid_job_' || v_suffix; + v_reservation_id text := 'smoke_paid_reservation_' || v_suffix; + v_job jsonb; + v_reserved jsonb; + v_finished jsonb; + v_status text; +begin + select id into v_workspace_id + from public.app_workspaces + order by created_at + limit 1; + + if v_workspace_id is null then + raise exception 'paid workflow smoke requires one application workspace'; + end if; + + v_job := jsonb_build_object( + 'id', v_job_id, + 'job_type', 'paid_workflow_guard_smoke', + 'status', 'running', + 'entity_type', 'smoke', + 'entity_id', v_suffix, + 'attempt_count', 1, + 'max_attempts', 1, + 'created_at', now(), + 'started_at', now(), + 'updated_at', now(), + 'is_paid', true + ); + + v_reserved := public.reserve_paid_workflow( + v_workspace_id, + v_job, + v_reservation_id, + 2147483647, + 2147483647, + 'Asia/Shanghai', + 300 + ); + + if v_reserved #>> '{job,status}' <> 'running' + or v_reserved #>> '{job,reservation_id}' <> v_reservation_id + then + raise exception 'paid workflow reservation returned an invalid payload'; + end if; + + select status into v_status + from public.paid_workflow_reservations + where workspace_id = v_workspace_id and id = v_reservation_id; + if v_status <> 'running' then + raise exception 'paid workflow reservation was not persisted as running'; + end if; + + v_job := v_job || jsonb_build_object( + 'status', 'succeeded', + 'reservation_id', v_reservation_id, + 'finished_at', now(), + 'updated_at', now() + ); + v_finished := public.finish_paid_workflow(v_workspace_id, v_job, v_reservation_id); + + if v_finished ->> 'status' <> 'succeeded' then + raise exception 'paid workflow finish returned an invalid payload'; + end if; + + select status into v_status + from public.paid_workflow_reservations + where workspace_id = v_workspace_id and id = v_reservation_id; + if v_status <> 'succeeded' then + raise exception 'paid workflow reservation was not released'; + end if; + + select status into v_status + from public.jobs + where workspace_id = v_workspace_id and id = v_job_id; + if v_status <> 'succeeded' then + raise exception 'paid workflow job was not completed'; + end if; +end; +$paid_workflow_guard_smoke$; + +rollback; diff --git a/demohouse/sales-intelligence-workbench/supabase/tests/202607230003_async_job_queue_smoke.sql b/demohouse/sales-intelligence-workbench/supabase/tests/202607230003_async_job_queue_smoke.sql new file mode 100644 index 00000000..9e4eb5c1 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/supabase/tests/202607230003_async_job_queue_smoke.sql @@ -0,0 +1,198 @@ +begin; + +do $async_job_queue_smoke$ +declare + v_workspace_id uuid; + v_suffix text := pg_catalog.txid_current()::text; + v_job_id text := 'smoke_async_job_' || v_suffix; + v_reservation_id text := 'smoke_async_reservation_' || v_suffix; + v_worker_id text := 'smoke-worker-' || v_suffix; + v_cancel_job_id text := 'smoke_async_cancel_job_' || v_suffix; + v_cancel_reservation_id text := 'smoke_async_cancel_reservation_' || v_suffix; + v_job jsonb; + v_result jsonb; + v_status text; +begin + select id into v_workspace_id + from public.app_workspaces + order by created_at + limit 1; + + if v_workspace_id is null then + raise exception 'async job queue smoke requires one application workspace'; + end if; + + v_job := jsonb_build_object( + 'id', v_job_id, + 'job_type', 'async_job_queue_smoke', + 'status', 'queued', + 'stage', 'queued', + 'progress', 0, + 'entity_type', 'smoke', + 'entity_id', v_suffix, + 'idempotency_key', 'async-job-smoke-' || v_suffix, + 'attempt_count', 0, + 'max_attempts', 3, + 'is_paid', true, + 'created_at', now(), + 'updated_at', now() + ); + + v_result := public.enqueue_sales_job(v_workspace_id, v_job); + if v_result ->> 'status' <> 'queued' or (v_result ->> 'attempt_count')::integer <> 0 then + raise exception 'async job was not queued correctly'; + end if; + + v_result := public.claim_sales_job( + v_workspace_id, + v_worker_id, + array['async_job_queue_smoke']::text[], + 120 + ); + if v_result ->> 'status' <> 'running' + or v_result ->> 'worker_id' <> v_worker_id + or (v_result ->> 'attempt_count')::integer <> 1 + then + raise exception 'async job was not claimed correctly'; + end if; + + v_result := public.heartbeat_sales_job( + v_workspace_id, + v_job_id, + v_worker_id, + 'validating_evidence', + 50, + 120 + ); + if v_result ->> 'stage' <> 'validating_evidence' or (v_result ->> 'progress')::integer <> 50 then + raise exception 'async job heartbeat was not persisted'; + end if; + + v_result := public.release_sales_job_claim( + v_workspace_id, + v_job_id, + v_worker_id, + jsonb_build_object('code', 'smoke_retry', 'message', 'retry safely before reservation'), + true, + 0 + ); + if v_result ->> 'status' <> 'queued' or v_result ->> 'worker_id' is not null then + raise exception 'unreserved async job was not safely requeued'; + end if; + + v_result := public.claim_sales_job( + v_workspace_id, + v_worker_id, + array['async_job_queue_smoke']::text[], + 120 + ); + if (v_result ->> 'attempt_count')::integer <> 2 then + raise exception 'async job retry attempt was not incremented'; + end if; + + v_result := public.reserve_paid_workflow( + v_workspace_id, + v_result, + v_reservation_id, + 2147483647, + 2147483647, + 'Asia/Shanghai', + 300 + ); + if v_result #>> '{job,reservation_id}' <> v_reservation_id then + raise exception 'async job paid reservation was not created'; + end if; + + v_job := public.heartbeat_sales_job( + v_workspace_id, + v_job_id, + v_worker_id, + 'persisting_result', + 95, + 120 + ); + v_job := v_job || jsonb_build_object( + 'status', 'succeeded', + 'finished_at', now(), + 'result', jsonb_build_object('status', 'ok') + ); + v_result := public.finish_paid_workflow(v_workspace_id, v_job, v_reservation_id); + if v_result ->> 'status' <> 'succeeded' + or (v_result ->> 'progress')::integer <> 100 + or v_result ->> 'worker_id' is not null + then + raise exception 'async job did not finish cleanly'; + end if; + + select status into v_status + from public.paid_workflow_reservations + where workspace_id = v_workspace_id and id = v_reservation_id; + if v_status <> 'succeeded' then + raise exception 'async job paid reservation was not released'; + end if; + + v_job := jsonb_build_object( + 'id', v_cancel_job_id, + 'job_type', 'async_job_queue_smoke', + 'status', 'queued', + 'stage', 'queued', + 'progress', 0, + 'entity_type', 'smoke', + 'entity_id', v_suffix, + 'idempotency_key', 'async-job-cancel-smoke-' || v_suffix, + 'attempt_count', 0, + 'max_attempts', 3, + 'is_paid', true, + 'created_at', now(), + 'updated_at', now() + ); + perform public.enqueue_sales_job(v_workspace_id, v_job); + v_job := public.claim_sales_job( + v_workspace_id, + v_worker_id, + array['async_job_queue_smoke']::text[], + 120 + ); + v_result := public.reserve_paid_workflow( + v_workspace_id, + v_job, + v_cancel_reservation_id, + 2147483647, + 2147483647, + 'Asia/Shanghai', + 300 + ); + + v_result := public.request_cancel_sales_job(v_workspace_id, v_cancel_job_id); + if v_result ->> 'status' <> 'running' + or v_result ->> 'stage' <> 'cancelling' + or v_result ->> 'worker_id' <> v_worker_id + then + raise exception 'running cancellation released the worker before a safe checkpoint'; + end if; + + select status into v_status + from public.paid_workflow_reservations + where workspace_id = v_workspace_id and id = v_cancel_reservation_id; + if v_status <> 'running' then + raise exception 'running cancellation released paid capacity too early'; + end if; + + v_result := public.acknowledge_cancel_sales_job(v_workspace_id, v_cancel_job_id, v_worker_id); + if v_result ->> 'status' <> 'cancelled' + or v_result ->> 'stage' <> 'cancelled' + or v_result ->> 'worker_id' is not null + then + raise exception 'worker did not acknowledge cancellation cleanly'; + end if; + + select status into v_status + from public.paid_workflow_reservations + where workspace_id = v_workspace_id and id = v_cancel_reservation_id; + if v_status <> 'cancelled' then + raise exception 'acknowledged cancellation did not release paid capacity'; + end if; +end; +$async_job_queue_smoke$; + +rollback;