From 2193d7c8e1fc4dff5311e2961480d54cb82438a1 Mon Sep 17 00:00:00 2001 From: hungtran24 Date: Fri, 2 Oct 2026 11:58:51 +0700 Subject: [PATCH] chore: remove historical archive and refresh repository docs Remove old_repo now that the rebuilt platform is the active implementation. Point documentation to current code and Git history, preserve local environment setup guidance, and drop obsolete archive exclusions from build and quality tools. --- .dockerignore | 3 +- .pre-commit-config.yaml | 2 - AGENTS.md | 2 +- ARCHITECTURE.md | 2 +- CONTEXT.md | 2 +- README.md | 6 +- old_repo/.env.example | 19 - old_repo/.gitignore | 76 - old_repo/.python-version | 1 - old_repo/AGENTS.md | 172 -- old_repo/CLAUDE.md | 159 -- old_repo/README.md | 133 -- old_repo/alembic.ini | 79 - old_repo/alembic/env.py | 83 - old_repo/alembic/script.py.mako | 26 - old_repo/alembic/versions/0001_baseline.py | 21 - .../versions/0002_create_scoring_runs.py | 53 - .../versions/0003_rule_only_execution.py | 59 - .../versions/0004_medium_ai_pipeline.py | 32 - .../versions/0005_review_api_and_audit.py | 87 - .../versions/0006_reevaluate_child_lineage.py | 57 - .../versions/0007_outbox_delivery_state.py | 87 - .../0008_retention_redaction_state.py | 45 - .../alembic/versions/0009_execution_trace.py | 27 - .../alembic/versions/0010_dotb_headers.py | 25 - old_repo/apps/dev-console/.env.example | 7 - old_repo/apps/dev-console/bun.lock | 326 --- old_repo/apps/dev-console/index.html | 13 - old_repo/apps/dev-console/package.json | 26 - .../dev-console/public/brand-assets/logo.svg | 124 - .../public/brand-assets/logo_dark.svg | 124 - .../public/brand-assets/logo_thumbnail.svg | 61 - old_repo/apps/dev-console/public/favicon.ico | Bin 15086 -> 0 bytes old_repo/apps/dev-console/public/favicon.svg | 61 - .../public/images/lead-counseling-review.jpg | Bin 128093 -> 0 bytes .../images/lead-enterprise-training.jpg | Bin 100011 -> 0 bytes .../public/images/lead-ielts-student.jpg | Bin 158358 -> 0 bytes .../public/images/lead-system-fault.jpg | Bin 110676 -> 0 bytes .../public/lead-scoring-api-docs.html | 41 - old_repo/apps/dev-console/src/App.svelte | 436 ---- old_repo/apps/dev-console/src/app.css | 134 -- .../apps/dev-console/src/lib/api/client.ts | 209 -- .../apps/dev-console/src/lib/api/schema.d.ts | 1122 --------- .../apps/dev-console/src/lib/api/types.ts | 38 - .../src/lib/components/ChurnPrediction.svelte | 302 --- .../src/lib/components/DemoFlow.svelte | 1858 --------------- .../lib/components/DynamicRuleBuilder.svelte | 616 ----- .../src/lib/components/FixtureSelector.svelte | 518 ---- .../src/lib/components/Icon.svelte | 159 -- .../src/lib/components/RunInspector.svelte | 747 ------ .../src/lib/components/RunStatusBadge.svelte | 134 -- .../src/lib/components/ScoreBreakdown.svelte | 587 ----- .../src/lib/components/ScoreMeter.svelte | 195 -- .../dev-console/src/lib/fixtures/golden.ts | 238 -- .../src/lib/state/consoleState.svelte.ts | 201 -- old_repo/apps/dev-console/src/main.ts | 14 - .../apps/dev-console/src/tests/client.test.ts | 163 -- .../dev-console/src/tests/fixtures.test.ts | 27 - .../apps/dev-console/src/tests/state.test.ts | 123 - old_repo/apps/dev-console/svelte.config.js | 5 - old_repo/apps/dev-console/tsconfig.json | 20 - old_repo/apps/dev-console/vite.config.ts | 56 - old_repo/compose.yaml | 14 - .../lead-scoring-contract-pack/v1/SHA256SUMS | 72 - .../v1/compatibility.json | 30 - .../v1/contract.json | 62 - .../v1/error-taxonomy.json | 252 -- .../v1/fixture-index.json | 191 -- .../v1/fixtures/admission-request.json | 64 - .../v1/fixtures/admission-response.json | 9 - .../v1/fixtures/error-validation.json | 4 - .../v1/fixtures/lineage-child.json | 5 - .../v1/fixtures/lineage-root.json | 5 - .../deleted-block-followed-by-append.json | 17 - .../marker/empty-description-append.json | 17 - .../fixtures/marker/failed-run-no-render.json | 9 - .../marker/multiple-complete-blocks.json | 17 - .../v1/fixtures/marker/nested-block.json | 17 - .../v1/fixtures/marker/orphan-closing.json | 17 - .../v1/fixtures/marker/orphan-opening.json | 17 - .../v1/fixtures/marker/overlapping-block.json | 17 - .../marker/plain-description-append.json | 17 - .../marker/single-valid-block-replace.json | 17 - .../unicode-multiline-preservation.json | 17 - .../negative/admission-subject-mismatch.json | 64 - .../negative/event-missing-version.json | 15 - .../review-override-out-of-range.json | 11 - .../negative/snapshot-future-message.json | 50 - .../v1/fixtures/polling/completed.json | 33 - .../v1/fixtures/polling/failed.json | 29 - .../v1/fixtures/polling/pending.json | 29 - .../v1/fixtures/polling/running.json | 29 - .../v1/fixtures/review-approve-request.json | 10 - .../v1/fixtures/review-override-request.json | 11 - .../fixtures/review-re_evaluate-request.json | 68 - .../v1/fixtures/review-response.json | 14 - .../v1/fixtures/scoring-config-selection.json | 3 - .../v1/fixtures/scoring-config.json | 56 - .../v1/fixtures/snapshot.json | 58 - .../v1/fixtures/tenant-unavailable.json | 4 - .../v1/fixtures/webhook/completed.json | 26 - .../v1/fixtures/webhook/review_required.json | 26 - .../v1/fixtures/webhook/reviewed_approve.json | 19 - .../fixtures/webhook/reviewed_override.json | 19 - .../fixtures/webhook/reviewed_reevaluate.json | 19 - .../fixtures/webhook/tenant-unavailable.json | 4 - .../fixtures/webhook/worker_interrupted.json | 26 - .../v1/openapi.json | 2084 ----------------- .../v1/outcome-taxonomy.json | 80 - .../v1/profiles/polling/contract.json | 49 - .../polling/error-response.schema.json | 20 - .../polling/scoring-run-response.schema.json | 864 ------- .../v1/profiles/webhook/contract.json | 19 - .../lead-scoring-reviewed-event.schema.json | 140 -- .../lead-scoring-terminal-event.schema.json | 251 -- .../profiles/webhook/signature-vectors.json | 57 - .../webhook-ingest-error-response.schema.json | 21 - .../webhook-ingest-response.schema.json | 26 - .../v1/schemas/admission-request.schema.json | 532 ----- .../v1/schemas/admission-response.schema.json | 68 - .../v1/schemas/canonical-snapshot.schema.json | 390 --- .../v1/schemas/error-response.schema.json | 20 - .../v1/schemas/error-taxonomy.schema.json | 92 - .../v1/schemas/lineage.schema.json | 39 - .../v1/schemas/outcome-taxonomy.schema.json | 49 - .../v1/schemas/polling-response.schema.json | 864 ------- .../v1/schemas/review-request.schema.json | 594 ----- .../v1/schemas/review-response.schema.json | 142 -- .../v1/schemas/reviewed-event.schema.json | 140 -- .../scoring-config-selection.schema.json | 17 - .../v1/schemas/scoring-config.schema.json | 427 ---- .../v1/schemas/terminal-event.schema.json | 251 -- .../webhook-event-envelope.schema.json | 399 ---- .../webhook-ingest-error-response.schema.json | 21 - .../webhook-ingest-response.schema.json | 26 - .../v1/security-profile.json | 36 - .../lead-scoring-polling/v1/contract.json | 49 - .../v1/error-response.schema.json | 20 - .../v1/fixtures/completed.json | 33 - .../v1/fixtures/failed.json | 29 - .../v1/fixtures/pending.json | 29 - .../v1/fixtures/running.json | 29 - .../v1/scoring-run-response.schema.json | 864 ------- .../lead-scoring-webhook/v1/contract.json | 19 - .../v1/fixtures/completed.json | 26 - .../v1/fixtures/review_required.json | 26 - .../v1/fixtures/reviewed_approve.json | 19 - .../v1/fixtures/reviewed_override.json | 19 - .../v1/fixtures/reviewed_reevaluate.json | 19 - .../v1/fixtures/tenant-unavailable.json | 4 - .../v1/fixtures/worker_interrupted.json | 26 - .../lead-scoring-reviewed-event.schema.json | 140 -- .../lead-scoring-terminal-event.schema.json | 251 -- .../v1/signature-vectors.json | 57 - .../webhook-ingest-error-response.schema.json | 21 - .../v1/webhook-ingest-response.schema.json | 26 - .../lead-scoring-contract-pack-v1.md | 122 - .../integrations/lead-scoring-polling-v1.md | 90 - .../integrations/lead-scoring-webhook-v1.md | 145 -- .../operations/lead-scoring-observability.md | 61 - .../docs/specs/lead-scoring-customer-pilot.md | 454 ---- old_repo/pyproject.toml | 67 - old_repo/src/lead_scoring/__init__.py | 10 - old_repo/src/lead_scoring/aihub/__init__.py | 51 - old_repo/src/lead_scoring/aihub/client.py | 843 ------- old_repo/src/lead_scoring/aihub/errors.py | 180 -- old_repo/src/lead_scoring/aihub/schemas.py | 124 - old_repo/src/lead_scoring/api/__init__.py | 1 - old_repo/src/lead_scoring/api/deps.py | 24 - old_repo/src/lead_scoring/api/v1/__init__.py | 1 - .../src/lead_scoring/api/v1/scoring_runs.py | 672 ------ old_repo/src/lead_scoring/auth.py | 96 - old_repo/src/lead_scoring/cli/__init__.py | 1 - .../src/lead_scoring/cli/contract_pack.py | 996 -------- old_repo/src/lead_scoring/cli/openapi.py | 83 - .../src/lead_scoring/cli/polling_contract.py | 285 --- .../src/lead_scoring/cli/webhook_contract.py | 273 --- old_repo/src/lead_scoring/config.py | 215 -- old_repo/src/lead_scoring/db/__init__.py | 19 - old_repo/src/lead_scoring/db/base.py | 5 - .../src/lead_scoring/db/models/__init__.py | 5 - .../lead_scoring/db/models/outbox_event.py | 53 - .../lead_scoring/db/models/review_audit.py | 44 - .../src/lead_scoring/db/models/scoring_run.py | 72 - old_repo/src/lead_scoring/db/session.py | 39 - old_repo/src/lead_scoring/dynamic_rules.py | 215 -- old_repo/src/lead_scoring/execution.py | 632 ----- old_repo/src/lead_scoring/main.py | 286 --- old_repo/src/lead_scoring/observability.py | 233 -- old_repo/src/lead_scoring/retention.py | 371 --- old_repo/src/lead_scoring/schemas/__init__.py | 129 - .../src/lead_scoring/schemas/dynamic_rule.py | 57 - old_repo/src/lead_scoring/schemas/errors.py | 275 --- old_repo/src/lead_scoring/schemas/health.py | 13 - old_repo/src/lead_scoring/schemas/lineage.py | 13 - old_repo/src/lead_scoring/schemas/outcomes.py | 100 - old_repo/src/lead_scoring/schemas/review.py | 146 -- .../lead_scoring/schemas/rule_breakdown.py | 41 - .../lead_scoring/schemas/scoring_config.py | 170 -- .../src/lead_scoring/schemas/scoring_run.py | 159 -- old_repo/src/lead_scoring/schemas/snapshot.py | 139 -- old_repo/src/lead_scoring/schemas/webhook.py | 91 - old_repo/src/lead_scoring/scoring.py | 445 ---- old_repo/src/lead_scoring/webhook/__init__.py | 1 - .../src/lead_scoring/webhook/dispatcher.py | 517 ---- old_repo/src/lead_scoring/webhook/signing.py | 33 - old_repo/stack.md | 113 - old_repo/tests/conftest.py | 57 - ...lead-scoring-local.postman_collection.json | 823 ------- ...ead-scoring-local.postman_environment.json | 99 - old_repo/tests/test_aihub_client.py | 1595 ------------- .../tests/test_bounded_profiles_execution.py | 1327 ----------- .../tests/test_configurable_demo_backend.py | 899 ------- old_repo/tests/test_contract_pack_cli.py | 219 -- old_repo/tests/test_dynamic_rules.py | 465 ---- old_repo/tests/test_execution.py | 142 -- old_repo/tests/test_health.py | 155 -- old_repo/tests/test_medium_execution.py | 754 ------ old_repo/tests/test_medium_scoring.py | 192 -- old_repo/tests/test_migrations.py | 765 ------ old_repo/tests/test_observability.py | 125 - old_repo/tests/test_openapi_cli.py | 102 - old_repo/tests/test_polling_contract_cli.py | 153 -- old_repo/tests/test_retention.py | 1119 --------- old_repo/tests/test_rule_scoring.py | 145 -- old_repo/tests/test_scoring_runs_api.py | 1787 -------------- old_repo/tests/test_webhook_contract_cli.py | 171 -- old_repo/tests/test_webhook_dispatcher.py | 1394 ----------- old_repo/uv.lock | 1165 --------- pyrightconfig.json | 2 +- 230 files changed, 8 insertions(+), 43734 deletions(-) delete mode 100644 old_repo/.env.example delete mode 100644 old_repo/.gitignore delete mode 100644 old_repo/.python-version delete mode 100644 old_repo/AGENTS.md delete mode 100644 old_repo/CLAUDE.md delete mode 100644 old_repo/README.md delete mode 100644 old_repo/alembic.ini delete mode 100644 old_repo/alembic/env.py delete mode 100644 old_repo/alembic/script.py.mako delete mode 100644 old_repo/alembic/versions/0001_baseline.py delete mode 100644 old_repo/alembic/versions/0002_create_scoring_runs.py delete mode 100644 old_repo/alembic/versions/0003_rule_only_execution.py delete mode 100644 old_repo/alembic/versions/0004_medium_ai_pipeline.py delete mode 100644 old_repo/alembic/versions/0005_review_api_and_audit.py delete mode 100644 old_repo/alembic/versions/0006_reevaluate_child_lineage.py delete mode 100644 old_repo/alembic/versions/0007_outbox_delivery_state.py delete mode 100644 old_repo/alembic/versions/0008_retention_redaction_state.py delete mode 100644 old_repo/alembic/versions/0009_execution_trace.py delete mode 100644 old_repo/alembic/versions/0010_dotb_headers.py delete mode 100644 old_repo/apps/dev-console/.env.example delete mode 100644 old_repo/apps/dev-console/bun.lock delete mode 100644 old_repo/apps/dev-console/index.html delete mode 100644 old_repo/apps/dev-console/package.json delete mode 100644 old_repo/apps/dev-console/public/brand-assets/logo.svg delete mode 100644 old_repo/apps/dev-console/public/brand-assets/logo_dark.svg delete mode 100644 old_repo/apps/dev-console/public/brand-assets/logo_thumbnail.svg delete mode 100644 old_repo/apps/dev-console/public/favicon.ico delete mode 100644 old_repo/apps/dev-console/public/favicon.svg delete mode 100644 old_repo/apps/dev-console/public/images/lead-counseling-review.jpg delete mode 100644 old_repo/apps/dev-console/public/images/lead-enterprise-training.jpg delete mode 100644 old_repo/apps/dev-console/public/images/lead-ielts-student.jpg delete mode 100644 old_repo/apps/dev-console/public/images/lead-system-fault.jpg delete mode 100644 old_repo/apps/dev-console/public/lead-scoring-api-docs.html delete mode 100644 old_repo/apps/dev-console/src/App.svelte delete mode 100644 old_repo/apps/dev-console/src/app.css delete mode 100644 old_repo/apps/dev-console/src/lib/api/client.ts delete mode 100644 old_repo/apps/dev-console/src/lib/api/schema.d.ts delete mode 100644 old_repo/apps/dev-console/src/lib/api/types.ts delete mode 100644 old_repo/apps/dev-console/src/lib/components/ChurnPrediction.svelte delete mode 100644 old_repo/apps/dev-console/src/lib/components/DemoFlow.svelte delete mode 100644 old_repo/apps/dev-console/src/lib/components/DynamicRuleBuilder.svelte delete mode 100644 old_repo/apps/dev-console/src/lib/components/FixtureSelector.svelte delete mode 100644 old_repo/apps/dev-console/src/lib/components/Icon.svelte delete mode 100644 old_repo/apps/dev-console/src/lib/components/RunInspector.svelte delete mode 100644 old_repo/apps/dev-console/src/lib/components/RunStatusBadge.svelte delete mode 100644 old_repo/apps/dev-console/src/lib/components/ScoreBreakdown.svelte delete mode 100644 old_repo/apps/dev-console/src/lib/components/ScoreMeter.svelte delete mode 100644 old_repo/apps/dev-console/src/lib/fixtures/golden.ts delete mode 100644 old_repo/apps/dev-console/src/lib/state/consoleState.svelte.ts delete mode 100644 old_repo/apps/dev-console/src/main.ts delete mode 100644 old_repo/apps/dev-console/src/tests/client.test.ts delete mode 100644 old_repo/apps/dev-console/src/tests/fixtures.test.ts delete mode 100644 old_repo/apps/dev-console/src/tests/state.test.ts delete mode 100644 old_repo/apps/dev-console/svelte.config.js delete mode 100644 old_repo/apps/dev-console/tsconfig.json delete mode 100644 old_repo/apps/dev-console/vite.config.ts delete mode 100644 old_repo/compose.yaml delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/SHA256SUMS delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/compatibility.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/contract.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/error-taxonomy.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixture-index.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/admission-request.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/admission-response.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/error-validation.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/lineage-child.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/lineage-root.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/deleted-block-followed-by-append.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/empty-description-append.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/failed-run-no-render.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/multiple-complete-blocks.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/nested-block.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/orphan-closing.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/orphan-opening.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/overlapping-block.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/plain-description-append.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/single-valid-block-replace.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/unicode-multiline-preservation.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/negative/admission-subject-mismatch.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/negative/event-missing-version.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/negative/review-override-out-of-range.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/negative/snapshot-future-message.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/polling/completed.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/polling/failed.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/polling/pending.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/polling/running.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/review-approve-request.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/review-override-request.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/review-re_evaluate-request.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/review-response.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/scoring-config-selection.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/scoring-config.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/snapshot.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/tenant-unavailable.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/completed.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/review_required.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/reviewed_approve.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/reviewed_override.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/reviewed_reevaluate.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/tenant-unavailable.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/worker_interrupted.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/openapi.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/outcome-taxonomy.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/profiles/polling/contract.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/profiles/polling/error-response.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/profiles/polling/scoring-run-response.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/contract.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/lead-scoring-reviewed-event.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/lead-scoring-terminal-event.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/signature-vectors.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/webhook-ingest-error-response.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/webhook-ingest-response.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/schemas/admission-request.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/schemas/admission-response.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/schemas/canonical-snapshot.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/schemas/error-response.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/schemas/error-taxonomy.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/schemas/lineage.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/schemas/outcome-taxonomy.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/schemas/polling-response.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/schemas/review-request.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/schemas/review-response.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/schemas/reviewed-event.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/schemas/scoring-config-selection.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/schemas/scoring-config.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/schemas/terminal-event.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/schemas/webhook-event-envelope.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/schemas/webhook-ingest-error-response.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/schemas/webhook-ingest-response.schema.json delete mode 100644 old_repo/contracts/lead-scoring-contract-pack/v1/security-profile.json delete mode 100644 old_repo/contracts/lead-scoring-polling/v1/contract.json delete mode 100644 old_repo/contracts/lead-scoring-polling/v1/error-response.schema.json delete mode 100644 old_repo/contracts/lead-scoring-polling/v1/fixtures/completed.json delete mode 100644 old_repo/contracts/lead-scoring-polling/v1/fixtures/failed.json delete mode 100644 old_repo/contracts/lead-scoring-polling/v1/fixtures/pending.json delete mode 100644 old_repo/contracts/lead-scoring-polling/v1/fixtures/running.json delete mode 100644 old_repo/contracts/lead-scoring-polling/v1/scoring-run-response.schema.json delete mode 100644 old_repo/contracts/lead-scoring-webhook/v1/contract.json delete mode 100644 old_repo/contracts/lead-scoring-webhook/v1/fixtures/completed.json delete mode 100644 old_repo/contracts/lead-scoring-webhook/v1/fixtures/review_required.json delete mode 100644 old_repo/contracts/lead-scoring-webhook/v1/fixtures/reviewed_approve.json delete mode 100644 old_repo/contracts/lead-scoring-webhook/v1/fixtures/reviewed_override.json delete mode 100644 old_repo/contracts/lead-scoring-webhook/v1/fixtures/reviewed_reevaluate.json delete mode 100644 old_repo/contracts/lead-scoring-webhook/v1/fixtures/tenant-unavailable.json delete mode 100644 old_repo/contracts/lead-scoring-webhook/v1/fixtures/worker_interrupted.json delete mode 100644 old_repo/contracts/lead-scoring-webhook/v1/lead-scoring-reviewed-event.schema.json delete mode 100644 old_repo/contracts/lead-scoring-webhook/v1/lead-scoring-terminal-event.schema.json delete mode 100644 old_repo/contracts/lead-scoring-webhook/v1/signature-vectors.json delete mode 100644 old_repo/contracts/lead-scoring-webhook/v1/webhook-ingest-error-response.schema.json delete mode 100644 old_repo/contracts/lead-scoring-webhook/v1/webhook-ingest-response.schema.json delete mode 100644 old_repo/docs/integrations/lead-scoring-contract-pack-v1.md delete mode 100644 old_repo/docs/integrations/lead-scoring-polling-v1.md delete mode 100644 old_repo/docs/integrations/lead-scoring-webhook-v1.md delete mode 100644 old_repo/docs/operations/lead-scoring-observability.md delete mode 100644 old_repo/docs/specs/lead-scoring-customer-pilot.md delete mode 100644 old_repo/pyproject.toml delete mode 100644 old_repo/src/lead_scoring/__init__.py delete mode 100644 old_repo/src/lead_scoring/aihub/__init__.py delete mode 100644 old_repo/src/lead_scoring/aihub/client.py delete mode 100644 old_repo/src/lead_scoring/aihub/errors.py delete mode 100644 old_repo/src/lead_scoring/aihub/schemas.py delete mode 100644 old_repo/src/lead_scoring/api/__init__.py delete mode 100644 old_repo/src/lead_scoring/api/deps.py delete mode 100644 old_repo/src/lead_scoring/api/v1/__init__.py delete mode 100644 old_repo/src/lead_scoring/api/v1/scoring_runs.py delete mode 100644 old_repo/src/lead_scoring/auth.py delete mode 100644 old_repo/src/lead_scoring/cli/__init__.py delete mode 100644 old_repo/src/lead_scoring/cli/contract_pack.py delete mode 100644 old_repo/src/lead_scoring/cli/openapi.py delete mode 100644 old_repo/src/lead_scoring/cli/polling_contract.py delete mode 100644 old_repo/src/lead_scoring/cli/webhook_contract.py delete mode 100644 old_repo/src/lead_scoring/config.py delete mode 100644 old_repo/src/lead_scoring/db/__init__.py delete mode 100644 old_repo/src/lead_scoring/db/base.py delete mode 100644 old_repo/src/lead_scoring/db/models/__init__.py delete mode 100644 old_repo/src/lead_scoring/db/models/outbox_event.py delete mode 100644 old_repo/src/lead_scoring/db/models/review_audit.py delete mode 100644 old_repo/src/lead_scoring/db/models/scoring_run.py delete mode 100644 old_repo/src/lead_scoring/db/session.py delete mode 100644 old_repo/src/lead_scoring/dynamic_rules.py delete mode 100644 old_repo/src/lead_scoring/execution.py delete mode 100644 old_repo/src/lead_scoring/main.py delete mode 100644 old_repo/src/lead_scoring/observability.py delete mode 100644 old_repo/src/lead_scoring/retention.py delete mode 100644 old_repo/src/lead_scoring/schemas/__init__.py delete mode 100644 old_repo/src/lead_scoring/schemas/dynamic_rule.py delete mode 100644 old_repo/src/lead_scoring/schemas/errors.py delete mode 100644 old_repo/src/lead_scoring/schemas/health.py delete mode 100644 old_repo/src/lead_scoring/schemas/lineage.py delete mode 100644 old_repo/src/lead_scoring/schemas/outcomes.py delete mode 100644 old_repo/src/lead_scoring/schemas/review.py delete mode 100644 old_repo/src/lead_scoring/schemas/rule_breakdown.py delete mode 100644 old_repo/src/lead_scoring/schemas/scoring_config.py delete mode 100644 old_repo/src/lead_scoring/schemas/scoring_run.py delete mode 100644 old_repo/src/lead_scoring/schemas/snapshot.py delete mode 100644 old_repo/src/lead_scoring/schemas/webhook.py delete mode 100644 old_repo/src/lead_scoring/scoring.py delete mode 100644 old_repo/src/lead_scoring/webhook/__init__.py delete mode 100644 old_repo/src/lead_scoring/webhook/dispatcher.py delete mode 100644 old_repo/src/lead_scoring/webhook/signing.py delete mode 100644 old_repo/stack.md delete mode 100644 old_repo/tests/conftest.py delete mode 100644 old_repo/tests/e2e/api/lead-scoring-local.postman_collection.json delete mode 100644 old_repo/tests/e2e/api/lead-scoring-local.postman_environment.json delete mode 100644 old_repo/tests/test_aihub_client.py delete mode 100644 old_repo/tests/test_bounded_profiles_execution.py delete mode 100644 old_repo/tests/test_configurable_demo_backend.py delete mode 100644 old_repo/tests/test_contract_pack_cli.py delete mode 100644 old_repo/tests/test_dynamic_rules.py delete mode 100644 old_repo/tests/test_execution.py delete mode 100644 old_repo/tests/test_health.py delete mode 100644 old_repo/tests/test_medium_execution.py delete mode 100644 old_repo/tests/test_medium_scoring.py delete mode 100644 old_repo/tests/test_migrations.py delete mode 100644 old_repo/tests/test_observability.py delete mode 100644 old_repo/tests/test_openapi_cli.py delete mode 100644 old_repo/tests/test_polling_contract_cli.py delete mode 100644 old_repo/tests/test_retention.py delete mode 100644 old_repo/tests/test_rule_scoring.py delete mode 100644 old_repo/tests/test_scoring_runs_api.py delete mode 100644 old_repo/tests/test_webhook_contract_cli.py delete mode 100644 old_repo/tests/test_webhook_dispatcher.py delete mode 100644 old_repo/uv.lock diff --git a/.dockerignore b/.dockerignore index cca140c..06f1b51 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,4 @@ -# Never send the archived codebase or local configuration to a build context. -old_repo/ +# Never send local configuration to a build context. .env .env.* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0db4180..7e2c1e9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,3 @@ -exclude: ^old_repo/ - repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.12.11 diff --git a/AGENTS.md b/AGENTS.md index 5645805..b22fad8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Repository workflow -This repository is the rebuilt AI Platform. `old_repo/` is historical and excluded from builds. The approved rebuild plan authorizes implementation; `docs/specs/ai-platform-lead-scoring.md` is review-ready for cross-service acceptance, so unverified Omni/AIHub wire details are not release claims. +This repository is the rebuilt AI Platform. Historical implementation is retained only in Git history; current code lives under `src/ai_platform/` and `apps/web/`. The approved rebuild plan authorizes implementation; `docs/specs/ai-platform-lead-scoring.md` is review-ready for cross-service acceptance, so unverified Omni/AIHub wire details are not release claims. 1. Before changing Lead Scoring behavior, read `docs/specs/ai-platform-lead-scoring.md`, `CONTEXT.md`, and `ARCHITECTURE.md`. Read `stack.md` when choosing a runtime or dependency. Generated OpenAPI and small Omni fixtures own exact wire shapes. 2. Put app/admin authentication, configuration and shared DB setup in `platform`; put deterministic rules/metrics and run lifecycle in `lead_scoring/domain`; orchestration in `application`, I/O in `infrastructure`, HTTP mapping in `presentation`, and wiring in `bootstrap`. Churn follows the same layout only when a specification exists. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2f67745..d123f03 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,7 +2,7 @@ ## Status and scope -This document records boundaries for the approved AI Platform rebuild and app-auth baseline `omni-ai-platform-app-auth` revision 2. The local implementation is covered by provider checks documented in `docs/contracts/app-auth-provider-handoff.md`; cross-service and production acceptance remain pending. The root Lead Scoring provider specification `docs/specs/ai-platform-lead-scoring.md` remains `review_ready` for that acceptance. The archived code and documents under `old_repo/` are historical evidence, not the new architecture or implementation. +This document records boundaries for the approved AI Platform rebuild and app-auth baseline `omni-ai-platform-app-auth` revision 2. The local implementation is covered by provider checks documented in `docs/contracts/app-auth-provider-handoff.md`; cross-service and production acceptance remain pending. The root Lead Scoring provider specification `docs/specs/ai-platform-lead-scoring.md` remains `review_ready` for that acceptance. Historical implementation is available in Git history only; the current backend and Admin Web live under `src/ai_platform/` and `apps/web/`. AI Platform is a provider service: it executes bounded rule scoring and a selected AI profile, maintains scoring-run lifecycle, and exposes result retrieval. DotB-Omni owns customer-configured rules/weights, HITL and customer actions, snapshot construction, user authorization, and CRM writeback. Omni is notified of terminal outcome through an agreed signed HMAC webhook and then calls the authenticated GET endpoint for run details. DotB EMS remains the CRM system of record. There is no customer-facing settings or review UI in this service scope. diff --git a/CONTEXT.md b/CONTEXT.md index 37da487..a40a4df 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,6 @@ # Context -Canonical language for the AI Platform Lead Scoring provider. The approved rebuild and app-auth baseline `omni-ai-platform-app-auth` revision 2 are implemented and covered by local provider tests. The provider specification `docs/specs/ai-platform-lead-scoring.md` remains `review_ready` for cross-service acceptance. Local checks do not establish Omni/EMS integration or production readiness; see `docs/contracts/app-auth-provider-handoff.md` for evidence and remaining checks. +Canonical language for the AI Platform Lead Scoring provider. The approved rebuild and app-auth baseline `omni-ai-platform-app-auth` revision 2 are implemented and covered by local provider tests. The provider specification `docs/specs/ai-platform-lead-scoring.md` remains `review_ready` for cross-service acceptance. Local checks do not establish Omni/EMS integration or production readiness; see `docs/contracts/app-auth-provider-handoff.md` for evidence and remaining checks. Current implementation is under `src/ai_platform/` and `apps/web/`; historical code and contracts are available in Git history only. ## Systems and ownership diff --git a/README.md b/README.md index a77ee15..e6b13ad 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ AI Platform provides Lead Scoring with a Python backend and internal SvelteKit A - PostgreSQL 16 with a new, project-specific Compose volume named `ai_platform_pgdata`. - Alembic schema changes are applied as a one-shot Compose service before the backend starts. - SvelteKit is a separate web image under `apps/web`. -- `old_repo/` is archived source only and is excluded from Docker build contexts and local checks. +- Historical implementation is retained in Git history, not as a separate source tree. The local Compose stack runs one backend process. It is for development only; default credentials and loopback port mappings are not deployment settings. Keep production secrets outside `.env` and inject them through the deployment environment or a secrets manager. ## Local setup -Copy the backend/Compose template and the separate SvelteKit server-only template. Both `.env` files are ignored by Git; the previous root `.env` is preserved under `old_repo/.env` as historical local data, not loaded by the rebuilt service. +Copy the backend/Compose template and the separate SvelteKit server-only template only when setting up a new workspace. Both `.env` files are ignored by Git; preserve any existing `.env` and `apps/web/.env` instead of overwriting them. ```sh cp .env.example .env @@ -45,7 +45,7 @@ Compose can instead build and start all services, including the one-shot migrati docker compose up --build ``` -The database is reachable from the host at `127.0.0.1:55432`, backend at `127.0.0.1:8000`, and web at `127.0.0.1:3000` by default. To discard only this new database and its data, run `docker compose down -v`; this does not target archived or unrelated volumes. +The database is reachable from the host at `127.0.0.1:55432`, backend at `127.0.0.1:8000`, and web at `127.0.0.1:3000` by default. To discard this Compose stack's database and its data, run `docker compose down -v`; unrelated project volumes are not targeted. The local database starts with only the development database. To run tests safely, create a separate database in that same new PostgreSQL instance, then pass its URL explicitly: diff --git a/old_repo/.env.example b/old_repo/.env.example deleted file mode 100644 index a76635e..0000000 --- a/old_repo/.env.example +++ /dev/null @@ -1,19 +0,0 @@ -# Local development only. Replace all credentials in deployed environments. -ENVIRONMENT=development -DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5433/lead_scoring -AIHUB_BASE_URL=http://localhost:8080 -DEBUG=false - -# JSON arrays consumed by pydantic-settings. These values work with the Dev Console example. -SERVICE_CALLERS=[{"caller_id":"dev-console","token":"local-dev-token","allowed_apps":["dev-console"],"allowed_features":["lead-scoring"],"allowed_tenants":["omni-account-42","omni-test-tenant-42"]}] -TENANT_ALLOWLIST=["omni-account-42","omni-test-tenant-42"] -SCORING_CONFIGS=[{"version":"1.0.0","confidence":0.8,"confidence_threshold":0.7,"hitl_policy_version":"1.0.0","effort_profile":"medium","fallback_policy":"rules_only_review","ai_model":"openai/gpt-4o-mini"}] - -WORKER_LEASE_SECONDS=30 -WORKER_RECOVERY_INTERVAL_SECONDS=5 -KILL_SWITCH_ENABLED=false - -# Enable webhook delivery by setting both values. -# WEBHOOK_SUBSCRIBER_URL=http://localhost:8080/api/v1/internal/lead-scoring/events -# WEBHOOK_SIGNING_SECRET=replace-with-local-signing-secret -WEBHOOK_SIGNING_KEY_ID=pilot-2026-01 diff --git a/old_repo/.gitignore b/old_repo/.gitignore deleted file mode 100644 index fe13e33..0000000 --- a/old_repo/.gitignore +++ /dev/null @@ -1,76 +0,0 @@ -# Python & Virtual Environments -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -.venv/ -venv/ -ENV/ -env/ -.uv/ - -# Linters, Type Checkers & Test Caches -.ruff_cache/ -.pytest_cache/ -.mypy_cache/ -.pyre/ -.pytype/ -.pyright/ -.coverage -.coverage.* -htmlcov/ -nosetests.xml -coverage.xml -*.cover -*.py,cover - -# Node / Bun / Frontend -node_modules/ -node_modules -apps/dev-console/node_modules -.bun/ -dist-ssr/ -*.local -.svelte-kit/ - -# Playwright & Testing Reports -playwright-report/ -test-results/ -blob-report/ - -# Environment Variables & Local Secrets -.env -.env.* -!.env.example - -# Project-local -vault/ -.scratch/ -.codex/ -docs/agents/ -.claude/ -CLAUDE.LOCAL.md -# Generated artifacts -/openapi.json - -# IDE, OS & Editor Artifacts -.DS_Store -Thumbs.db -*.swp -*.swo -*~ diff --git a/old_repo/.python-version b/old_repo/.python-version deleted file mode 100644 index 24ee5b1..0000000 --- a/old_repo/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.13 diff --git a/old_repo/AGENTS.md b/old_repo/AGENTS.md deleted file mode 100644 index 85982db..0000000 --- a/old_repo/AGENTS.md +++ /dev/null @@ -1,172 +0,0 @@ -# AGENTS.md - -This file provides guidance to Codex (Codex.ai/code) when working with this repository. - -## Purpose and status - -This repository is the Python-first AI Platform workspace. Lead Scoring is the first service; related systems are `DotB-Omni`, `lead-scoring-agent`, and `aihub` under `/home/hung/code/dotb_workspace/`. - -The repository contains specifications, project documentation, and the initialized baseline for the Lead Scoring service (`src/lead_scoring/`). - -Before implementing or changing Lead Scoring Customer Pilot behavior, read: - -- `docs/specs/lead-scoring-customer-pilot.md`: authoritative product behavior, scope, invariants, acceptance boundaries, and cross-service ownership. -- `stack.md`: technology decisions, candidates, evidence, and unresolved stack questions. It does not override the approved specification. - -The DotB Vault RFC, initial brief, Grill Brief, legacy reports, drafts, and agent messages are historical/supporting context rather than implementation authority. Exact wire formats become authoritative through released Pydantic models, generated OpenAPI/JSON Schemas, and shared fixtures. - -## Communication - -- Keep responses short, specific, and decision-oriented. -- Put the decision or conclusion first, followed by only the evidence and next action needed. -- Keep the main context high-signal. Put raw logs, scratch data, and long intermediate reports in `/tmp/` or the appropriate durable document. -- Use lists and compact structure when they make the result easier to scan. -- State assumptions, unresolved decisions, and verification status explicitly. - -## Writing - -- Write directly and precisely. Avoid buzzwords, filler, and repeated explanations. -- Comments explain intent, invariants, trade-offs, or non-obvious constraints, not syntax or line-by-line behavior. -- Do not add speculative documentation, abstractions, or metadata that the repository does not need. -- Do not use emoji or em dashes in new prose, comments, or reports. Preserve emoji and punctuation that are part of existing paths or quoted source text. - -## Delegation - -- Prioritize standard subagents over teammates to minimize token overhead. Spawn multiple subagents when parallel execution is genuinely needed. -- Pick the agent type matching the task from available configured agents (e.g., `explorer`, `implementer`, `tester`, `researcher`, `adversarial-reviewer`, `general`, `codebase-memory`). -- Delegate context-heavy or tool-heavy work with a clear scope, completion criteria, and evidence requirements. -- Require condensed handoffs containing conclusions, changed paths, verification, and uncertainty. Do not paste raw logs into the main context. -- The main context retains final decisions and integration responsibility. Agent messages are evidence, not user approval. - -## Agent skills - -### Issue tracker - -Local Markdown issues are stored under `.scratch//`. See `docs/agents/issue-tracker.md`. - -### Domain docs - -The repository uses a single-context domain layout. See `docs/agents/domain.md`. - -## Project knowledge - -- `AGENTS.md` is the project map for conventions, verified commands, boundaries, and gotchas. -- `docs/specs/lead-scoring-customer-pilot.md` is the approved source of truth for Lead Scoring Customer Pilot product behavior and build boundaries. -- `stack.md` is the primary record for technology decisions and unresolved stack questions; it cannot override the approved spec. -- `docs/` contains shared project knowledge intended for version control. -- `/tmp/` is for scratch data, raw logs, and temporary handoffs. -- Released Pydantic models, generated OpenAPI/JSON Schemas, tests, and shared fixtures become the exact executable contract when they exist. - -## Documentation - -- Put temporary reports, raw logs, and scratch material in `/tmp/`. -- Put durable team knowledge in `docs/` and include it in version control. -- Use `vault/` only for durable project-local knowledge that must not be committed. -- New durable Markdown documents under `docs/` or `vault/` use minimal YAML frontmatter: - -```yaml ---- -title: Document title -description: A short description of what the document contains and when it is useful. -status: draft -tags: [] ---- -``` - -- `title` and `description` are required. For governed specs, `status` may be `draft`, `review_ready`, `approved`, or `deprecated`; other existing document schemas keep their established status vocabulary. `tags` is optional. -- Do not add metadata that Git, the filesystem, or existing tooling can derive. -- Preserve the format and metadata schema of existing documents. Do not add frontmatter, reformat, move, or normalize existing files unless the task explicitly requests migration. -- `AGENTS.md`, README files, and files with an existing schema keep their established format. - -## Repository structure - -- `src/lead_scoring/`: Lead Scoring production service package and CLI tools. -- `alembic/`: database migrations for PostgreSQL. -- `tests/`: test suite including health check, OpenAPI CLI, and migration lifecycle. -- `docs/specs/lead-scoring-customer-pilot.md`: approved Lead Scoring Customer Pilot specification. -- `docs/`: durable shared project documentation. -- `.scratch/`: local untracked draft specs and issue tickets used by agent skills. -- `stack.md`: technology decisions and unresolved stack questions. -- `.codex/settings.json`: project hook configuration. -- `.codex/hooks/post-edit-check.py`: Python-only PostToolUse feedback hook. - -## Product and frontend boundary - -The frontend boundary is approved for Customer Pilot: DotB-Omni owns all customer-facing Lead Scoring configuration and HITL UI. AI Platform may provide only an internal Dev Console for fixtures, contract testing, failure testing, and allowlisted integration verification; it must not duplicate Omni business UI. - -When the internal Dev Console is implemented: - -- Use Svelte 5 and TypeScript with Bun, Vite, and Vitest. Add SvelteKit only when routing, SSR, or deployment requires it. -- Generate types from backend OpenAPI and consume them through `openapi-fetch`. -- Keep shared transport concerns such as authentication and error handling outside generated code. -- Use the official Svelte tooling, including `@sveltejs/mcp` autofixer feedback and `svelte-check`. -- Use Playwright only for important end-to-end user flows. - -TypeScript 7 native compiler and language server are pilot candidates, not the adopted project default. Test them project-locally before changing global runtimes or the shared `mcpls` configuration. - -## Engineering principles - -- Follow YAGNI. Implement only what the current requirement needs; do not add speculative abstractions, extension points, compatibility layers, or infrastructure. -- Prefer the simplest design that preserves clear boundaries and can be changed safely. -- Reuse the standard library and established, actively maintained packages when they reduce code and operational burden. Avoid dependencies for trivial behavior. -- Keep code explicit. Use precise names, short control flow, and direct data transformations. Avoid cleverness, hidden side effects, global mutable state, and premature optimization. -- Prefer composition over inheritance and functions over classes. Introduce a class only when state, lifecycle, or a framework contract genuinely requires one. -- Keep a functional core with side effects at the boundaries. Isolate I/O, persistence, network calls, clocks, and environment access. -- Organize modules by capability or domain responsibility, not by arbitrary technical layers. -- Avoid wrappers that only rename another API. Every abstraction must remove meaningful complexity or enforce a real boundary. -- Preserve one clear source of truth for contracts, configuration, and domain rules. - -## AI-native code - -Write code that humans and coding agents can understand with minimal hidden context: - -- Make dependencies, inputs, outputs, errors, and side effects explicit. -- Use typed, stable contracts at module and service boundaries. -- Keep related implementation and tests easy to locate. -- Prefer deterministic behavior and small composable functions. -- Document decisions and non-obvious constraints, not syntax or line-by-line behavior. -- Match existing conventions before introducing a new pattern. -- Make the smallest coherent change and remove superseded code instead of leaving parallel implementations. - -## Verified commands - -- `uv sync`: sync workspace dependencies and environment. -- `uv run pytest`: run pytest test suite against Python 3.13. -- `uv run ruff check src tests`: run lint checks across source and tests. -- `uv run ruff format --check src tests`: check code formatting. -- `uv run pyright`: run Pyright static type checker. -- `uv run ty check`: run project-local ty type checker. -- `uv run generate-openapi`: deterministically generate root `openapi.json`. -- `uv run generate-openapi --check`: verify that `openapi.json` is present and matches the current backend contract. -- `uv run generate-webhook-contract`: deterministically generate terminal webhook contract pack. -- `uv run generate-webhook-contract --check`: verify that webhook contract pack is present and matches current models. -- `uv run generate-polling-contract`: deterministically generate scoring run polling contract pack. -- `uv run generate-polling-contract --check`: verify that polling contract pack is present and matches current models. -- `uv run generate-contract-pack`: deterministically generate the versioned shared Contract Pack. -- `uv run generate-contract-pack --validate-fixtures`: validate provider and consumer fixture expectations. -- `uv run generate-contract-pack --check`: verify the versioned Contract Pack and checksums are present and current. -- `uv run alembic upgrade head`: apply database migrations up to head. -- `uv run alembic downgrade base`: revert all database migrations. - -## Deterministic generation - -- When a verified project generator exists, use it instead of manually editing derived files. -- Treat OpenAPI output and generated frontend types as derived artifacts. Regenerate them from the backend contract and require the authoritative verification command to detect drift. -- Do not edit generated files manually unless the project workflow explicitly requires it. - -## Safety and scope - -- Inspect a path before overwriting or deleting it. Preserve existing configuration, documentation, code, and data unless the task explicitly authorizes removal or migration. -- Do not expose secrets in logs, reports, handoffs, or generated artifacts. -- Do not install packages, modify system configuration, or create permissions and background services without explicit approval. -- Bootstrap production code only after the relevant product and implementation decision. Customer-facing Lead Scoring UI remains owned by DotB-Omni. - -## Tooling and verification gotchas - -- Root `openapi.json` and frontend build output remain covered by the ignore policy; the versioned Contract Pack OpenAPI artifact is generated and committed, and must not be edited manually. -- The project PostToolUse hook is configured in `.codex/settings.json` for `Write` and `Edit`. It handles Python files via `ruff` (safe fix and format). Full type-checking (`pyright`, `ty`) and test suites belong to explicit verification steps. Preserve the working-tree modification to `.codex/hooks/post-edit-check.py` unless explicitly asked to change or reset it. -- Do not modify `/etc`, package-manager state, system services, drivers, kernel settings, global Node/TypeScript versions, or the shared `mcpls` configuration as part of repository work unless explicitly approved. - -## Quality of life - -Pyright LSP is available for semantic navigation. Use it for definitions, references, hover types, document and workspace symbols, and call hierarchy; then read the relevant implementation to understand business rules and side effects. diff --git a/old_repo/CLAUDE.md b/old_repo/CLAUDE.md deleted file mode 100644 index a767f37..0000000 --- a/old_repo/CLAUDE.md +++ /dev/null @@ -1,159 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with this repository. - -## Purpose and status - -This repository is the Python-first AI Platform workspace. Lead Scoring is the first service; related systems are `DotB-Omni`, `lead-scoring-agent`, and `aihub` under `/home/hung/code/dotb_workspace/`. - -The repository contains specifications, project documentation, and the initialized baseline for the Lead Scoring service (`src/lead_scoring/`). - -Before implementing or changing Lead Scoring Customer Pilot behavior, read: - -- `docs/specs/lead-scoring-customer-pilot.md`: authoritative product behavior, scope, invariants, acceptance boundaries, and cross-service ownership. -- `stack.md`: technology decisions, candidates, evidence, and unresolved stack questions. It does not override the approved specification. - -The DotB Vault RFC, initial brief, Grill Brief, legacy reports, drafts, and agent messages are historical/supporting context rather than implementation authority. Exact wire formats become authoritative through released Pydantic models, generated OpenAPI/JSON Schemas, and shared fixtures. - -## Communication - -- Keep responses short, specific, and decision-oriented. -- Put the decision or conclusion first, followed by only the evidence and next action needed. -- Keep the main context high-signal. Put raw logs, scratch data, and long intermediate reports in `/tmp/` or the appropriate durable document. -- Use lists and compact structure when they make the result easier to scan. -- State assumptions, unresolved decisions, and verification status explicitly. - -## Writing - -- Write directly and precisely. Avoid buzzwords, filler, and repeated explanations. -- Comments explain intent, invariants, trade-offs, or non-obvious constraints, not syntax or line-by-line behavior. -- Do not add speculative documentation, abstractions, or metadata that the repository does not need. -- Do not use emoji or em dashes in new prose, comments, or reports. Preserve emoji and punctuation that are part of existing paths or quoted source text. - -## Delegation - -- Use the configured `researcher` agent for read-only exploration and research. -- Use the configured `primary-engineer` agent for implementation, debugging, and multi-step engineering work. If it is unavailable, use the narrowest suitable implementation agent. -- Implementation agents own TDD tests; use the `tester` agent for independent verification before completion. -- Delegate context-heavy or tool-heavy work with a clear scope, completion criteria, and evidence requirements. -- Require condensed handoffs containing conclusions, changed paths, verification, and uncertainty. Do not paste raw logs into the main context. -- The main context retains final decisions and integration responsibility. Agent messages are evidence, not user approval. - -## Project knowledge - -- `CLAUDE.md` is the project map for conventions, verified commands, boundaries, and gotchas. -- `docs/specs/lead-scoring-customer-pilot.md` is the approved source of truth for Lead Scoring Customer Pilot product behavior and build boundaries. -- `stack.md` is the primary record for technology decisions and unresolved stack questions; it cannot override the approved spec. -- `docs/` contains shared project knowledge intended for version control. -- `/tmp/` is for scratch data, raw logs, and temporary handoffs. -- Released Pydantic models, generated OpenAPI/JSON Schemas, tests, and shared fixtures become the exact executable contract when they exist. - -## Documentation - -- Put temporary reports, raw logs, and scratch material in `/tmp/`. -- Put durable team knowledge in `docs/` and include it in version control. -- Use `vault/` only for durable project-local knowledge that must not be committed. -- New durable Markdown documents under `docs/` or `vault/` use minimal YAML frontmatter: - -```yaml ---- -title: Document title -description: A short description of what the document contains and when it is useful. -status: draft -tags: [] ---- -``` - -- `title` and `description` are required. For governed specs, `status` may be `draft`, `review_ready`, `approved`, or `deprecated`; other existing document schemas keep their established status vocabulary. `tags` is optional. -- Do not add metadata that Git, the filesystem, or existing tooling can derive. -- Preserve the format and metadata schema of existing documents. Do not add frontmatter, reformat, move, or normalize existing files unless the task explicitly requests migration. -- `CLAUDE.md`, README files, and files with an existing schema keep their established format. - -## Repository structure - -- `src/lead_scoring/`: Lead Scoring production service package and CLI tools. -- `alembic/`: database migrations for PostgreSQL. -- `tests/`: test suite including health check, OpenAPI CLI, and migration lifecycle. -- `docs/specs/lead-scoring-customer-pilot.md`: approved Lead Scoring Customer Pilot specification. -- `docs/`: durable shared project documentation. -- `stack.md`: technology decisions and unresolved stack questions. -- `.claude/settings.json`: project hook configuration. -- `.claude/hooks/post-edit-check.py`: Python-only PostToolUse feedback hook. - -## Product and frontend boundary - -The frontend boundary is approved for Customer Pilot: DotB-Omni owns all customer-facing Lead Scoring configuration and HITL UI. AI Platform may provide only an internal Dev Console for fixtures, contract testing, failure testing, and allowlisted integration verification; it must not duplicate Omni business UI. - -When the internal Dev Console is implemented: - -- Use Svelte 5 and TypeScript with Bun, Vite, and Vitest. Add SvelteKit only when routing, SSR, or deployment requires it. -- Generate types from backend OpenAPI and consume them through `openapi-fetch`. -- Keep shared transport concerns such as authentication and error handling outside generated code. -- Use the official Svelte tooling, including `@sveltejs/mcp` autofixer feedback and `svelte-check`. -- Use Playwright only for important end-to-end user flows. - -TypeScript 7 native compiler and language server are pilot candidates, not the adopted project default. Test them project-locally before changing global runtimes or the shared `mcpls` configuration. - -## Engineering principles - -- Follow YAGNI. Implement only what the current requirement needs; do not add speculative abstractions, extension points, compatibility layers, or infrastructure. -- Prefer the simplest design that preserves clear boundaries and can be changed safely. -- Reuse the standard library and established, actively maintained packages when they reduce code and operational burden. Avoid dependencies for trivial behavior. -- Keep code explicit. Use precise names, short control flow, and direct data transformations. Avoid cleverness, hidden side effects, global mutable state, and premature optimization. -- Prefer composition over inheritance and functions over classes. Introduce a class only when state, lifecycle, or a framework contract genuinely requires one. -- Keep a functional core with side effects at the boundaries. Isolate I/O, persistence, network calls, clocks, and environment access. -- Organize modules by capability or domain responsibility, not by arbitrary technical layers. -- Avoid wrappers that only rename another API. Every abstraction must remove meaningful complexity or enforce a real boundary. -- Preserve one clear source of truth for contracts, configuration, and domain rules. - -## AI-native code - -Write code that humans and coding agents can understand with minimal hidden context: - -- Make dependencies, inputs, outputs, errors, and side effects explicit. -- Use typed, stable contracts at module and service boundaries. -- Keep related implementation and tests easy to locate. -- Prefer deterministic behavior and small composable functions. -- Document decisions and non-obvious constraints, not syntax or line-by-line behavior. -- Match existing conventions before introducing a new pattern. -- Make the smallest coherent change and remove superseded code instead of leaving parallel implementations. - -## Verified commands - -- `uv sync`: sync workspace dependencies and environment. -- `uv run pytest`: run pytest test suite against Python 3.13. -- `uv run ruff check src tests`: run lint checks across source and tests. -- `uv run ruff format --check src tests`: check code formatting. -- `uv run pyright`: run Pyright static type checker. -- `uv run ty check`: run project-local ty type checker. -- `uv run generate-openapi`: deterministically generate root `openapi.json`. -- `uv run generate-openapi --check`: verify that `openapi.json` is present and matches the current backend contract. -- `uv run generate-webhook-contract`: deterministically generate terminal webhook contract pack. -- `uv run generate-webhook-contract --check`: verify that webhook contract pack is present and matches current models. -- `uv run generate-polling-contract`: deterministically generate scoring run polling contract pack. -- `uv run generate-polling-contract --check`: verify that polling contract pack is present and matches current models. -- `uv run alembic upgrade head`: apply database migrations up to head. -- `uv run alembic downgrade base`: revert all database migrations. - -## Deterministic generation - -- When a verified project generator exists, use it instead of manually editing derived files. -- Treat OpenAPI output and generated frontend types as derived artifacts. Regenerate them from the backend contract and require the authoritative verification command to detect drift. -- Do not edit generated files manually unless the project workflow explicitly requires it. - -## Safety and scope - -- Inspect a path before overwriting or deleting it. Preserve existing configuration, documentation, code, and data unless the task explicitly authorizes removal or migration. -- Do not expose secrets in logs, reports, handoffs, or generated artifacts. -- Do not install packages, modify system configuration, or create permissions and background services without explicit approval. -- Bootstrap production code only after the relevant product and implementation decision. Customer-facing Lead Scoring UI remains owned by DotB-Omni. - -## Tooling and verification gotchas - -- Generated OpenAPI and frontend build output must remain covered by the existing ignore policy and should not be edited manually. -- The project PostToolUse hook is configured in `.claude/settings.json` for `Write` and `Edit`. It currently handles Python files only and invokes the existing `ruff` and `ty` tools. Preserve the pre-existing working-tree modification to `.claude/hooks/post-edit-check.py` unless explicitly asked to change or reset it. -- Do not modify `/etc`, package-manager state, system services, drivers, kernel settings, global Node/TypeScript versions, or the shared `mcpls` configuration as part of repository work unless explicitly approved. - -## Quality of life - -Pyright LSP is available for semantic navigation. Use it for definitions, references, hover types, document and workspace symbols, and call hierarchy; then read the relevant implementation to understand business rules and side effects. diff --git a/old_repo/README.md b/old_repo/README.md deleted file mode 100644 index 567fce0..0000000 --- a/old_repo/README.md +++ /dev/null @@ -1,133 +0,0 @@ -# AI Platform - Lead Scoring Service - -Lead Scoring service and internal Dev Console for the AI Platform. - -## What runs locally - -| Component | Purpose | Default address | -| --- | --- | --- | -| PostgreSQL 16 | Stores scoring runs and migrations | `localhost:5433` | -| Lead Scoring API | REST API, async scoring worker, OpenAPI | `http://localhost:8000` | -| Dev Console | Internal fixture and contract-testing UI | `http://localhost:5173` | -| AIHub | Optional external AI inference dependency | `http://localhost:8080` | - -The service still runs if local AIHub is unavailable: the default config uses `rules_only_review` fallback. A reachable AIHub and a valid tenant VKey are required for live AI scoring. - -## Requirements - -Install before starting: - -- Git -- Python 3.13 or newer -- [uv](https://docs.astral.sh/uv/) -- [Bun](https://bun.sh/) -- Docker with Docker Compose plugin - -## Start from a fresh clone - -```bash -git clone https://github.com/HungTran24/dotb_ai-platform.git -cd dotb_ai-platform -cp .env.example .env -cp apps/dev-console/.env.example apps/dev-console/.env -``` - -The copied `.env` files are local only and ignored by Git. Their example values are intentionally for development only. - -### 1. Start PostgreSQL - -```bash -docker compose up -d postgres -docker compose ps -``` - -The included Compose service exposes PostgreSQL on port `5433` to avoid conflicting with other local projects. If port `5433` is occupied, choose another port and update both values before migration: - -```bash -POSTGRES_PORT=15432 docker compose up -d postgres -# Then set DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:15432/lead_scoring in .env -``` - -### 2. Install backend dependencies and migrate the database - -```bash -uv sync -uv run alembic upgrade head -``` - -### 3. Start the Lead Scoring API - -Run in a separate terminal from the repository root: - -```bash -uv run uvicorn lead_scoring.main:app --reload --host 0.0.0.0 --port 8000 -``` - -Check the API: - -```bash -curl http://localhost:8000/health -``` - -OpenAPI UI: `http://localhost:8000/docs` - -### 4. Start the Dev Console - -Run in a second terminal: - -```bash -cd apps/dev-console -bun install --frozen-lockfile -bun run dev -``` - -Open `http://localhost:5173`. - -## Local configuration - -Root `.env` configures the backend. `apps/dev-console/.env` configures the Vite development server. - -| Variable | Default | Purpose | -| --- | --- | --- | -| `DATABASE_URL` | PostgreSQL at `localhost:5433` | Backend database connection | -| `AIHUB_BASE_URL` | `http://localhost:8080` | Optional AIHub endpoint | -| `DEV_CONSOLE_API_URL` | `http://localhost:8000` | Dev Console API proxy target | -| `DEV_CONSOLE_PORT` | `5173` | Dev Console development port | -| `DEV_CONSOLE_PREVIEW_PORT` | `4173` | Dev Console preview port | -| `DEV_CONSOLE_SERVICE_TOKEN` | `local-dev-token` | Must match the local backend `SERVICE_CALLERS` entry | -| `DEV_CONSOLE_TEST_VKEY` | `local-test-vkey` | Forwarded only to AIHub calls; replace for real AIHub use | - -The local backend template includes an allowlisted `dev-console` caller and baseline scoring config version `1.0.0`, so the Dev Console can open immediately after startup. - -## Stop local services - -```bash -docker compose down -``` - -To remove the local database data too: - -```bash -docker compose down -v -``` - -## Verification - -Backend: - -```bash -uv run pytest -uv run ruff check src tests -uv run ruff format --check src tests -uv run pyright -uv run ty check -``` - -Frontend: - -```bash -cd apps/dev-console -bun run check -bun run test -bun run build -``` diff --git a/old_repo/alembic.ini b/old_repo/alembic.ini deleted file mode 100644 index 39c4734..0000000 --- a/old_repo/alembic.ini +++ /dev/null @@ -1,79 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts -script_location = %(here)s/alembic - -# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s -# file_template = %%(rev)s_%%(slug)s - -# sys.path path, will be prepended to sys.path if present. -prepend_sys_path = %(here)s -path_separator = os - -# timezone to use when rendering the date within the migration file -# as well as the filename. -# string value is passed to dateutil.tz.gettz() -# leave blank for localtime -# timezone = - -# max length of characters to apply to the -# "slug" field -# truncate_slug_length = 40 - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without -# a source .py file to be detected as revisions in the -# versions/ directory -# sourceless = false - -# version location specification; This defaults -# to alembic/versions. When using multiple version -# directories, initial revisions must be specified with --version-path. -# The path separator used here should be the separator specified by "version_path_separator" -# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions - -# version path separator -version_path_separator = os - -# the output encoding used when revision files -# are written from script.py.mako -# output_encoding = utf-8 - -# Logging configuration -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/old_repo/alembic/env.py b/old_repo/alembic/env.py deleted file mode 100644 index 70c0470..0000000 --- a/old_repo/alembic/env.py +++ /dev/null @@ -1,83 +0,0 @@ -import asyncio -from logging.config import fileConfig - -from alembic import context -from sqlalchemy import pool -from sqlalchemy.engine import Connection -from sqlalchemy.ext.asyncio import create_async_engine - -from lead_scoring.config import Settings -from lead_scoring.db import Base - -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config - -# Interpret the config file for Python logging. -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -# add your model's MetaData object here -# for 'autogenerate' support -target_metadata = Base.metadata - - -def get_database_url() -> str: - attr_url = config.attributes.get("sqlalchemy_url") - if attr_url: - return str(attr_url) - opt_url = config.get_main_option("sqlalchemy.url") - if opt_url: - return opt_url - return Settings().database_url - - -def run_migrations_offline() -> None: - """Run migrations in 'offline' mode.""" - url = get_database_url() - context.configure( - url=url, - target_metadata=target_metadata, - literal_binds=True, - dialect_opts={"paramstyle": "named"}, - ) - - with context.begin_transaction(): - context.run_migrations() - - -def do_run_migrations(connection: Connection) -> None: - context.configure(connection=connection, target_metadata=target_metadata) - - with context.begin_transaction(): - context.run_migrations() - - -async def run_async_migrations() -> None: - """Run migrations in async engine scenario.""" - url = get_database_url() - connectable = create_async_engine( - url, - poolclass=pool.NullPool, - ) - - async with connectable.connect() as connection: - await connection.run_sync(do_run_migrations) - - await connectable.dispose() - - -def run_migrations_online() -> None: - """Run migrations in 'online' mode.""" - connectable = config.attributes.get("connection", None) - - if connectable is not None: - do_run_migrations(connectable) - else: - asyncio.run(run_async_migrations()) - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/old_repo/alembic/script.py.mako b/old_repo/alembic/script.py.mako deleted file mode 100644 index edb0604..0000000 --- a/old_repo/alembic/script.py.mako +++ /dev/null @@ -1,26 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from collections.abc import Sequence - -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision: str = ${repr(up_revision)} -down_revision: str | None = ${repr(down_revision)} -branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} -depends_on: str | Sequence[str] | None = ${repr(depends_on)} - - -def upgrade() -> None: - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - ${downgrades if downgrades else "pass"} diff --git a/old_repo/alembic/versions/0001_baseline.py b/old_repo/alembic/versions/0001_baseline.py deleted file mode 100644 index 5a1a1be..0000000 --- a/old_repo/alembic/versions/0001_baseline.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Initial baseline migration. - -Revision ID: 0001_baseline -Revises: -Create Date: 2026-08-27 - -""" - -# revision identifiers, used by Alembic. -revision: str = "0001_baseline" -down_revision: str | None = None -branch_labels: str | None = None -depends_on: str | None = None - - -def upgrade() -> None: - pass - - -def downgrade() -> None: - pass diff --git a/old_repo/alembic/versions/0002_create_scoring_runs.py b/old_repo/alembic/versions/0002_create_scoring_runs.py deleted file mode 100644 index d95920c..0000000 --- a/old_repo/alembic/versions/0002_create_scoring_runs.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Create scoring_runs table. - -Revision ID: 0002_create_scoring_runs -Revises: 0001_baseline -Create Date: 2026-08-27 - -""" - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision: str = "0002_create_scoring_runs" -down_revision: str | None = "0001_baseline" -branch_labels: str | None = None -depends_on: str | None = None - - -def upgrade() -> None: - op.create_table( - "scoring_runs", - sa.Column("id", sa.String(length=36), nullable=False), - sa.Column("tenant_id", sa.String(length=255), nullable=False), - sa.Column("subject_ref", sa.String(length=255), nullable=False), - sa.Column("scoring_config_version", sa.String(length=64), nullable=False), - sa.Column("idempotency_key", sa.String(length=255), nullable=False), - sa.Column("payload_hash", sa.String(length=64), nullable=False), - sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"), - sa.Column("snapshot_data", postgresql.JSONB(astext_type=sa.Text()), nullable=False), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint( - "tenant_id", - "idempotency_key", - name="uq_scoring_runs_tenant_idempotency", - ), - ) - - -def downgrade() -> None: - op.drop_table("scoring_runs") diff --git a/old_repo/alembic/versions/0003_rule_only_execution.py b/old_repo/alembic/versions/0003_rule_only_execution.py deleted file mode 100644 index 230884f..0000000 --- a/old_repo/alembic/versions/0003_rule_only_execution.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Add rule-only execution state and transactional outbox. - -Revision ID: 0003_rule_only_execution -Revises: 0002_create_scoring_runs -Create Date: 2026-08-27 - -""" - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - -revision: str = "0003_rule_only_execution" -down_revision: str | None = "0002_create_scoring_runs" -branch_labels: str | None = None -depends_on: str | None = None - - -def upgrade() -> None: - op.add_column("scoring_runs", sa.Column("effective_config", postgresql.JSONB(), nullable=True)) - op.add_column("scoring_runs", sa.Column("confidence_threshold", sa.Float(), nullable=True)) - op.add_column("scoring_runs", sa.Column("rule_score", sa.Integer(), nullable=True)) - op.add_column("scoring_runs", sa.Column("confidence", sa.Float(), nullable=True)) - op.add_column("scoring_runs", sa.Column("error_code", sa.String(length=64), nullable=True)) - op.add_column("scoring_runs", sa.Column("retryable", sa.Boolean(), nullable=True)) - op.add_column( - "scoring_runs", sa.Column("started_at", sa.DateTime(timezone=True), nullable=True) - ) - op.add_column( - "scoring_runs", sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True) - ) - op.add_column( - "scoring_runs", sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True) - ) - - op.create_table( - "outbox_events", - sa.Column("id", sa.String(length=36), nullable=False), - sa.Column("event_type", sa.String(length=128), nullable=False), - sa.Column("event_version", sa.String(length=32), nullable=False), - sa.Column("run_id", sa.String(length=36), nullable=False), - sa.Column("payload", postgresql.JSONB(), nullable=False), - sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("run_id", "event_type", name="uq_outbox_run_event_type"), - ) - - -def downgrade() -> None: - op.drop_table("outbox_events") - op.drop_column("scoring_runs", "lease_expires_at") - op.drop_column("scoring_runs", "completed_at") - op.drop_column("scoring_runs", "started_at") - op.drop_column("scoring_runs", "retryable") - op.drop_column("scoring_runs", "error_code") - op.drop_column("scoring_runs", "confidence") - op.drop_column("scoring_runs", "rule_score") - op.drop_column("scoring_runs", "confidence_threshold") - op.drop_column("scoring_runs", "effective_config") diff --git a/old_repo/alembic/versions/0004_medium_ai_pipeline.py b/old_repo/alembic/versions/0004_medium_ai_pipeline.py deleted file mode 100644 index d5eba1e..0000000 --- a/old_repo/alembic/versions/0004_medium_ai_pipeline.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Add medium AI pipeline scoring fields and audit metadata. - -Revision ID: 0004_medium_ai_pipeline -Revises: 0003_rule_only_execution -Create Date: 2026-08-27 - -""" - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - -revision: str = "0004_medium_ai_pipeline" -down_revision: str | None = "0003_rule_only_execution" -branch_labels: str | None = None -depends_on: str | None = None - - -def upgrade() -> None: - op.add_column("scoring_runs", sa.Column("ai_score", sa.Integer(), nullable=True)) - op.add_column("scoring_runs", sa.Column("final_score", sa.Integer(), nullable=True)) - op.add_column("scoring_runs", sa.Column("scoring_mode", sa.String(length=32), nullable=True)) - op.add_column("scoring_runs", sa.Column("degraded", sa.Boolean(), nullable=True)) - op.add_column("scoring_runs", sa.Column("ai_metadata", postgresql.JSONB(), nullable=True)) - - -def downgrade() -> None: - op.drop_column("scoring_runs", "ai_metadata") - op.drop_column("scoring_runs", "degraded") - op.drop_column("scoring_runs", "scoring_mode") - op.drop_column("scoring_runs", "final_score") - op.drop_column("scoring_runs", "ai_score") diff --git a/old_repo/alembic/versions/0005_review_api_and_audit.py b/old_repo/alembic/versions/0005_review_api_and_audit.py deleted file mode 100644 index ad6362a..0000000 --- a/old_repo/alembic/versions/0005_review_api_and_audit.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Add review API fields and review audits table. - -Revision ID: 0005_review_api_and_audit -Revises: 0004_medium_ai_pipeline -Create Date: 2026-08-28 - -""" - -import sqlalchemy as sa -from alembic import op - -revision: str = "0005_review_api_and_audit" -down_revision: str | None = "0004_medium_ai_pipeline" -branch_labels: str | None = None -depends_on: str | None = None - - -def upgrade() -> None: - op.add_column( - "scoring_runs", - sa.Column("version", sa.Integer(), nullable=False, server_default="1"), - ) - op.add_column("scoring_runs", sa.Column("effective_score", sa.Integer(), nullable=True)) - op.add_column("scoring_runs", sa.Column("review_decision", sa.String(length=32), nullable=True)) - op.add_column("scoring_runs", sa.Column("reviewed_by", sa.String(length=255), nullable=True)) - op.add_column( - "scoring_runs", sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True) - ) - op.add_column("scoring_runs", sa.Column("review_note", sa.String(length=2000), nullable=True)) - - op.drop_constraint("uq_outbox_run_event_type", "outbox_events", type_="unique") - op.create_index( - "uq_outbox_terminal_run", - "outbox_events", - ["run_id", "event_type"], - unique=True, - postgresql_where=sa.text("event_type = 'lead_scoring.run.terminal'"), - ) - - op.create_table( - "review_audits", - sa.Column("id", sa.String(length=36), nullable=False), - sa.Column("run_id", sa.String(length=36), nullable=False), - sa.Column("tenant_id", sa.String(length=255), nullable=False), - sa.Column("decision", sa.String(length=32), nullable=False), - sa.Column("actor_id", sa.String(length=255), nullable=False), - sa.Column("actor_role", sa.String(length=32), nullable=False), - sa.Column("idempotency_key", sa.String(length=255), nullable=False), - sa.Column("payload_hash", sa.String(length=64), nullable=False), - sa.Column("previous_version", sa.Integer(), nullable=False), - sa.Column("new_version", sa.Integer(), nullable=False), - sa.Column("calculated_score", sa.Integer(), nullable=True), - sa.Column("effective_score", sa.Integer(), nullable=False), - sa.Column("note", sa.String(length=2000), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint( - "tenant_id", - "idempotency_key", - name="uq_review_audits_tenant_idempotency", - ), - ) - - -def downgrade() -> None: - op.drop_table("review_audits") - op.drop_index( - "uq_outbox_terminal_run", - "outbox_events", - postgresql_where=sa.text("event_type = 'lead_scoring.run.terminal'"), - ) - # Clean up non-terminal review outbox events before restoring pre-0005 unique constraint - op.execute(sa.text("DELETE FROM outbox_events WHERE event_type = 'lead_scoring.run.reviewed'")) - op.create_unique_constraint( - "uq_outbox_run_event_type", "outbox_events", ["run_id", "event_type"] - ) - op.drop_column("scoring_runs", "review_note") - op.drop_column("scoring_runs", "reviewed_at") - op.drop_column("scoring_runs", "reviewed_by") - op.drop_column("scoring_runs", "review_decision") - op.drop_column("scoring_runs", "effective_score") - op.drop_column("scoring_runs", "version") diff --git a/old_repo/alembic/versions/0006_reevaluate_child_lineage.py b/old_repo/alembic/versions/0006_reevaluate_child_lineage.py deleted file mode 100644 index bb359af..0000000 --- a/old_repo/alembic/versions/0006_reevaluate_child_lineage.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Add parent_run_id lineage and child_run_id audit for re-evaluate. - -Revision ID: 0006_reevaluate_child_lineage -Revises: 0005_review_api_and_audit -Create Date: 2026-08-28 - -""" - -import sqlalchemy as sa -from alembic import op - -revision: str = "0006_reevaluate_child_lineage" -down_revision: str | None = "0005_review_api_and_audit" -branch_labels: str | None = None -depends_on: str | None = None - - -def upgrade() -> None: - op.add_column( - "scoring_runs", - sa.Column( - "parent_run_id", - sa.String(length=36), - sa.ForeignKey("scoring_runs.id"), - nullable=True, - ), - ) - op.create_index( - "ix_scoring_runs_parent_run_id", - "scoring_runs", - ["parent_run_id"], - ) - op.add_column( - "review_audits", - sa.Column("child_run_id", sa.String(length=36), nullable=True), - ) - op.alter_column( - "review_audits", - "effective_score", - existing_type=sa.Integer(), - nullable=True, - ) - - -def downgrade() -> None: - op.execute( - sa.text("UPDATE review_audits SET effective_score = 0 WHERE effective_score IS NULL") - ) - op.alter_column( - "review_audits", - "effective_score", - existing_type=sa.Integer(), - nullable=False, - ) - op.drop_column("review_audits", "child_run_id") - op.drop_index("ix_scoring_runs_parent_run_id", table_name="scoring_runs") - op.drop_column("scoring_runs", "parent_run_id") diff --git a/old_repo/alembic/versions/0007_outbox_delivery_state.py b/old_repo/alembic/versions/0007_outbox_delivery_state.py deleted file mode 100644 index 82e25f2..0000000 --- a/old_repo/alembic/versions/0007_outbox_delivery_state.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Add outbox delivery state, retry and lease tracking. - -Revision ID: 0007_outbox_delivery_state -Revises: 0006_reevaluate_child_lineage -Create Date: 2026-08-28 - -""" - -import sqlalchemy as sa -from alembic import op - -revision: str = "0007_outbox_delivery_state" -down_revision: str | None = "0006_reevaluate_child_lineage" -branch_labels: str | None = None -depends_on: str | None = None - - -def upgrade() -> None: - op.add_column( - "outbox_events", - sa.Column( - "delivery_status", - sa.String(length=32), - nullable=False, - server_default="pending", - ), - ) - op.add_column( - "outbox_events", - sa.Column( - "attempt_count", - sa.Integer(), - nullable=False, - server_default="0", - ), - ) - op.add_column( - "outbox_events", - sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True), - ) - op.add_column( - "outbox_events", - sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True), - ) - op.add_column( - "outbox_events", - sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True), - ) - op.add_column( - "outbox_events", - sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True), - ) - op.add_column( - "outbox_events", - sa.Column("claim_token", sa.String(length=36), nullable=True), - ) - op.add_column( - "outbox_events", - sa.Column("last_error_code", sa.String(length=64), nullable=True), - ) - op.add_column( - "outbox_events", - sa.Column("last_error_detail", sa.String(length=2000), nullable=True), - ) - op.add_column( - "outbox_events", - sa.Column("last_http_status", sa.Integer(), nullable=True), - ) - op.create_index( - "ix_outbox_events_delivery_claim", - "outbox_events", - ["delivery_status", "next_attempt_at"], - ) - - -def downgrade() -> None: - op.drop_index("ix_outbox_events_delivery_claim", table_name="outbox_events") - op.drop_column("outbox_events", "last_http_status") - op.drop_column("outbox_events", "last_error_detail") - op.drop_column("outbox_events", "last_error_code") - op.drop_column("outbox_events", "lease_expires_at") - op.drop_column("outbox_events", "claim_token") - op.drop_column("outbox_events", "delivered_at") - op.drop_column("outbox_events", "last_attempt_at") - op.drop_column("outbox_events", "next_attempt_at") - op.drop_column("outbox_events", "attempt_count") - op.drop_column("outbox_events", "delivery_status") diff --git a/old_repo/alembic/versions/0008_retention_redaction_state.py b/old_repo/alembic/versions/0008_retention_redaction_state.py deleted file mode 100644 index 6d0fb6c..0000000 --- a/old_repo/alembic/versions/0008_retention_redaction_state.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Add retention and redaction state to scoring_runs and review_audits. - -Revision ID: 0008_retention_redaction_state -Revises: 0007_outbox_delivery_state -Create Date: 2026-08-28 - -""" - -import sqlalchemy as sa -from alembic import op - -revision: str = "0008_retention_redaction_state" -down_revision: str | None = "0007_outbox_delivery_state" -branch_labels: str | None = None -depends_on: str | None = None - - -def upgrade() -> None: - op.add_column( - "scoring_runs", - sa.Column("redacted_at", sa.DateTime(timezone=True), nullable=True), - ) - op.create_index( - "ix_scoring_runs_retention", - "scoring_runs", - ["created_at", "redacted_at"], - ) - - op.add_column( - "review_audits", - sa.Column("redacted_at", sa.DateTime(timezone=True), nullable=True), - ) - op.create_index( - "ix_review_audits_retention", - "review_audits", - ["created_at", "redacted_at"], - ) - - -def downgrade() -> None: - op.drop_index("ix_review_audits_retention", table_name="review_audits") - op.drop_column("review_audits", "redacted_at") - - op.drop_index("ix_scoring_runs_retention", table_name="scoring_runs") - op.drop_column("scoring_runs", "redacted_at") diff --git a/old_repo/alembic/versions/0009_execution_trace.py b/old_repo/alembic/versions/0009_execution_trace.py deleted file mode 100644 index ffee6c8..0000000 --- a/old_repo/alembic/versions/0009_execution_trace.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Add execution_trace to scoring_runs. - -Revision ID: 0009_execution_trace -Revises: 0008_retention_redaction_state -Create Date: 2026-09-04 - -""" - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - -revision: str = "0009_execution_trace" -down_revision: str | None = "0008_retention_redaction_state" -branch_labels: str | None = None -depends_on: str | None = None - - -def upgrade() -> None: - op.add_column( - "scoring_runs", - sa.Column("execution_trace", postgresql.JSONB(astext_type=sa.Text()), nullable=True), - ) - - -def downgrade() -> None: - op.drop_column("scoring_runs", "execution_trace") diff --git a/old_repo/alembic/versions/0010_dotb_headers.py b/old_repo/alembic/versions/0010_dotb_headers.py deleted file mode 100644 index af1acba..0000000 --- a/old_repo/alembic/versions/0010_dotb_headers.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Persist originating DotB headers for AIHub forwarding. - -Revision ID: 0010_dotb_headers -Revises: 0009_execution_trace -Create Date: 2026-09-07 - -""" - -import sqlalchemy as sa -from alembic import op - -revision: str = "0010_dotb_headers" -down_revision: str | None = "0009_execution_trace" -branch_labels: str | None = None -depends_on: str | None = None - - -def upgrade() -> None: - op.add_column("scoring_runs", sa.Column("dotb_app", sa.String(length=64), nullable=True)) - op.add_column("scoring_runs", sa.Column("dotb_feature", sa.String(length=64), nullable=True)) - - -def downgrade() -> None: - op.drop_column("scoring_runs", "dotb_feature") - op.drop_column("scoring_runs", "dotb_app") diff --git a/old_repo/apps/dev-console/.env.example b/old_repo/apps/dev-console/.env.example deleted file mode 100644 index 41541c7..0000000 --- a/old_repo/apps/dev-console/.env.example +++ /dev/null @@ -1,7 +0,0 @@ -DEV_CONSOLE_API_URL=http://localhost:8000 -DEV_CONSOLE_PORT=5173 -DEV_CONSOLE_PREVIEW_PORT=4173 -# Must match SERVICE_CALLERS in the root .env for the local setup. -DEV_CONSOLE_SERVICE_TOKEN=local-dev-token -# Used only to call a local AIHub. Any non-empty value exercises rules-only fallback when AIHub is absent. -DEV_CONSOLE_TEST_VKEY=local-test-vkey diff --git a/old_repo/apps/dev-console/bun.lock b/old_repo/apps/dev-console/bun.lock deleted file mode 100644 index 8972cba..0000000 --- a/old_repo/apps/dev-console/bun.lock +++ /dev/null @@ -1,326 +0,0 @@ -{ - "lockfileVersion": 2, - "configVersion": 1, - "workspaces": { - "": { - "name": "dev-console", - "dependencies": { - "openapi-fetch": "^0.13.4", - }, - "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^4.0.4", - "openapi-typescript": "^7.4.4", - "svelte": "^5.2.7", - "svelte-check": "^4.1.1", - "typescript": "^5.7.2", - "vite": "^5.4.11", - "vitest": "^2.1.8", - }, - }, - }, - "packages": { - "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], - - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - - "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - - "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], - - "@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js-replace": "^1.0.1" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="], - - "@redocly/config": ["@redocly/config@0.22.0", "", {}, "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ=="], - - "@redocly/openapi-core": ["@redocly/openapi-core@1.34.19", "", { "dependencies": { "@redocly/ajv": "8.11.2", "@redocly/config": "0.22.0", "colorette": "1.4.0", "https-proxy-agent": "7.0.6", "js-levenshtein": "1.1.6", "js-yaml": "4.3.1", "minimatch": "5.1.9", "pluralize": "8.0.0", "yaml-ast-parser": "0.0.43" } }, "sha512-o/0VgsBXgwcY1lyeqcVtSGdTQAPnVggo0fbFVPlxl5XVDKUcVH0OLRqt3CbkwByT5FU305E0iE0O7MzThjDblw=="], - - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.0", "", { "os": "android", "cpu": "arm" }, "sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA=="], - - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw=="], - - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw=="], - - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.0", "", { "os": "linux", "cpu": "arm" }, "sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w=="], - - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.0", "", { "os": "linux", "cpu": "arm" }, "sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw=="], - - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q=="], - - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA=="], - - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.0", "", { "os": "linux", "cpu": "none" }, "sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg=="], - - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.0", "", { "os": "linux", "cpu": "none" }, "sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ=="], - - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw=="], - - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA=="], - - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.0", "", { "os": "linux", "cpu": "none" }, "sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA=="], - - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.0", "", { "os": "linux", "cpu": "none" }, "sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ=="], - - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ=="], - - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A=="], - - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.0", "", { "os": "linux", "cpu": "x64" }, "sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw=="], - - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA=="], - - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.0", "", { "os": "none", "cpu": "arm64" }, "sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w=="], - - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ=="], - - "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.13", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ=="], - - "@sveltejs/load-config": ["@sveltejs/load-config@0.2.3", "", {}, "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ=="], - - "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@4.0.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0", "debug": "^4.3.7", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.12", "vitefu": "^1.0.3" }, "peerDependencies": { "svelte": "^5.0.0-next.96 || ^5.0.0", "vite": "^5.0.0" } }, "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA=="], - - "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@3.0.1", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0", "svelte": "^5.0.0-next.96 || ^5.0.0", "vite": "^5.0.0" } }, "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ=="], - - "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], - - "@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="], - - "@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="], - - "@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="], - - "@vitest/runner": ["@vitest/runner@2.1.9", "", { "dependencies": { "@vitest/utils": "2.1.9", "pathe": "^1.1.2" } }, "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g=="], - - "@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="], - - "@vitest/spy": ["@vitest/spy@2.1.9", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ=="], - - "@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="], - - "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], - - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - - "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], - - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - - "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], - - "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - - "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], - - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], - - "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], - - "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], - - "change-case": ["change-case@5.4.4", "", {}, "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w=="], - - "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], - - "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - - "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - - "colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], - - "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], - - "devalue": ["devalue@5.9.2", "", {}, "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w=="], - - "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], - - "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], - - "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], - - "esrap": ["esrap@2.3.6", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-yc0OC12UjPqLoc+fe+v5GNs4TOjAigUw3sTikfC+xeBPGUw7gDRz3DtYaqEhxyMVJojcSWJw7jT0QWR+CbuE/A=="], - - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - - "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], - - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - - "index-to-position": ["index-to-position@1.2.0", "", {}, "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw=="], - - "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], - - "js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="], - - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - - "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], - - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - - "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], - - "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], - - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - - "minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], - - "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], - - "openapi-fetch": ["openapi-fetch@0.13.8", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-yJ4QKRyNxE44baQ9mY5+r/kAzZ8yXMemtNAOFwOzRXJscdjSxxzWSNlyBAr+o5JjkUw9Lc3W7OIoca0cY3PYnQ=="], - - "openapi-typescript": ["openapi-typescript@7.13.0", "", { "dependencies": { "@redocly/openapi-core": "^1.34.6", "ansi-colors": "^4.1.3", "change-case": "^5.4.4", "parse-json": "^8.3.0", "supports-color": "^10.2.2", "yargs-parser": "^21.1.1" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ=="], - - "openapi-typescript-helpers": ["openapi-typescript-helpers@0.0.15", "", {}, "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw=="], - - "parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], - - "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], - - "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], - - "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], - - "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - - "rollup": ["rollup@4.63.0", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.0", "@rollup/rollup-android-arm64": "4.63.0", "@rollup/rollup-darwin-arm64": "4.63.0", "@rollup/rollup-darwin-x64": "4.63.0", "@rollup/rollup-freebsd-arm64": "4.63.0", "@rollup/rollup-freebsd-x64": "4.63.0", "@rollup/rollup-linux-arm-gnueabihf": "4.63.0", "@rollup/rollup-linux-arm-musleabihf": "4.63.0", "@rollup/rollup-linux-arm64-gnu": "4.63.0", "@rollup/rollup-linux-arm64-musl": "4.63.0", "@rollup/rollup-linux-loong64-gnu": "4.63.0", "@rollup/rollup-linux-loong64-musl": "4.63.0", "@rollup/rollup-linux-ppc64-gnu": "4.63.0", "@rollup/rollup-linux-ppc64-musl": "4.63.0", "@rollup/rollup-linux-riscv64-gnu": "4.63.0", "@rollup/rollup-linux-riscv64-musl": "4.63.0", "@rollup/rollup-linux-s390x-gnu": "4.63.0", "@rollup/rollup-linux-x64-gnu": "4.63.0", "@rollup/rollup-linux-x64-musl": "4.63.0", "@rollup/rollup-openbsd-x64": "4.63.0", "@rollup/rollup-openharmony-arm64": "4.63.0", "@rollup/rollup-win32-arm64-msvc": "4.63.0", "@rollup/rollup-win32-ia32-msvc": "4.63.0", "@rollup/rollup-win32-x64-gnu": "4.63.0", "@rollup/rollup-win32-x64-msvc": "4.63.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ=="], - - "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], - - "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - - "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], - - "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], - - "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], - - "svelte": ["svelte@5.56.10", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-Lcxbj8I/KAbpY+VjtY4ENQBV0dDCipfGAhqb51XQZ67CIQqXgsv/8dPkbILaj4Fb6/b6JAEM/PIVbILXgDQy2g=="], - - "svelte-check": ["svelte-check@4.7.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw=="], - - "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - - "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], - - "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], - - "tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="], - - "tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="], - - "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "uri-js-replace": ["uri-js-replace@1.0.1", "", {}, "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g=="], - - "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], - - "vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="], - - "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], - - "vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="], - - "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - - "yaml-ast-parser": ["yaml-ast-parser@0.0.43", "", {}, "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A=="], - - "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - - "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], - } -} diff --git a/old_repo/apps/dev-console/index.html b/old_repo/apps/dev-console/index.html deleted file mode 100644 index c14bc43..0000000 --- a/old_repo/apps/dev-console/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - DotB AI Platform - - - -
- - - diff --git a/old_repo/apps/dev-console/package.json b/old_repo/apps/dev-console/package.json deleted file mode 100644 index db7106e..0000000 --- a/old_repo/apps/dev-console/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "dev-console", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview", - "check": "svelte-check --tsconfig ./tsconfig.json", - "test": "vitest run", - "generate-api": "openapi-typescript ../../openapi.json -o ./src/lib/api/schema.d.ts" - }, - "dependencies": { - "openapi-fetch": "^0.13.4" - }, - "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^4.0.4", - "openapi-typescript": "^7.4.4", - "svelte": "^5.2.7", - "svelte-check": "^4.1.1", - "typescript": "^5.7.2", - "vite": "^5.4.11", - "vitest": "^2.1.8" - } -} diff --git a/old_repo/apps/dev-console/public/brand-assets/logo.svg b/old_repo/apps/dev-console/public/brand-assets/logo.svg deleted file mode 100644 index 506c512..0000000 --- a/old_repo/apps/dev-console/public/brand-assets/logo.svg +++ /dev/null @@ -1,124 +0,0 @@ - -image/svg+xml \ No newline at end of file diff --git a/old_repo/apps/dev-console/public/brand-assets/logo_dark.svg b/old_repo/apps/dev-console/public/brand-assets/logo_dark.svg deleted file mode 100644 index 506c512..0000000 --- a/old_repo/apps/dev-console/public/brand-assets/logo_dark.svg +++ /dev/null @@ -1,124 +0,0 @@ - -image/svg+xml \ No newline at end of file diff --git a/old_repo/apps/dev-console/public/brand-assets/logo_thumbnail.svg b/old_repo/apps/dev-console/public/brand-assets/logo_thumbnail.svg deleted file mode 100644 index 446d9ce..0000000 --- a/old_repo/apps/dev-console/public/brand-assets/logo_thumbnail.svg +++ /dev/null @@ -1,61 +0,0 @@ - -image/svg+xml \ No newline at end of file diff --git a/old_repo/apps/dev-console/public/favicon.ico b/old_repo/apps/dev-console/public/favicon.ico deleted file mode 100644 index a038b2b3320c72ea11a0e23d29b428e444fc7914..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15086 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 -image/svg+xml \ No newline at end of file diff --git a/old_repo/apps/dev-console/public/images/lead-counseling-review.jpg b/old_repo/apps/dev-console/public/images/lead-counseling-review.jpg deleted file mode 100644 index a6c08893d29da95a88626693a470e836fcb43072..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 128093 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 z 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 QthY7KLqQvw^k{$o*|-@m?f?J) diff --git a/old_repo/apps/dev-console/public/lead-scoring-api-docs.html b/old_repo/apps/dev-console/public/lead-scoring-api-docs.html deleted file mode 100644 index 05f1c88..0000000 --- a/old_repo/apps/dev-console/public/lead-scoring-api-docs.html +++ /dev/null @@ -1,41 +0,0 @@ - - - - - Tài liệu API Lead Scoring Service - AI Platform - - - - - - - -

- - - diff --git a/old_repo/apps/dev-console/src/App.svelte b/old_repo/apps/dev-console/src/App.svelte deleted file mode 100644 index 4916e62..0000000 --- a/old_repo/apps/dev-console/src/App.svelte +++ /dev/null @@ -1,436 +0,0 @@ - - -
- - - - -
- -
- -
-
- - Core API: 127.0.0.1 -
- - Tài liệu API (Redoc) ↗ - -
-
- - -
- {#if currentNav === 'lead-scoring'} - - {:else if currentNav === 'churn-prediction'} - - {/if} -
-
-
- - diff --git a/old_repo/apps/dev-console/src/app.css b/old_repo/apps/dev-console/src/app.css deleted file mode 100644 index cc91170..0000000 --- a/old_repo/apps/dev-console/src/app.css +++ /dev/null @@ -1,134 +0,0 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap'); - -:root { - /* Canvas & Atmospheric Light Grounding */ - --bg-canvas: #f8fafc; - --bg-canvas-subtle: #f1f5f9; - --bg-grid-line: rgba(15, 23, 42, 0.03); - - /* Surface System (Clean White & Subtle Tiers) */ - --bg-surface: #ffffff; - --bg-surface-raised: #f1f5f9; - --bg-surface-subtle: #f8fafc; - --bg-surface-hover: #e2e8f0; - --bg-surface-active: #cbd5e1; - --bg-code: #0f172a; - - /* Borders & Dividers */ - --border-subtle: rgba(15, 23, 42, 0.08); - --border-medium: rgba(15, 23, 42, 0.15); - --border-strong: rgba(15, 23, 42, 0.25); - --border-focus: #0284c7; - --border-highlight: rgba(2, 132, 199, 0.35); - - /* High-end Subtle Shadows */ - --shadow-card: 0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.03), 0 0 0 1px rgba(15, 23, 42, 0.07); - --shadow-card-hover: 0 4px 12px -2px rgba(15, 23, 42, 0.08), 0 0 0 1px rgba(15, 23, 42, 0.1); - --shadow-popup: 0 20px 25px -5px rgba(15, 23, 42, 0.1), 0 8px 10px -6px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(15, 23, 42, 0.09); - --shadow-btn: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - - /* Typography Colors (High Contrast) */ - --text-primary: #0f172a; - --text-secondary: #475569; - --text-muted: #64748b; - --text-disabled: #94a3b8; - --text-inverse: #ffffff; - - /* Accent Colors: DotB/Omni light theme. Bright blues are for large accents - and focus decoration only; small text uses the strong/dark variants. */ - --accent-cyan: #0284c7; - --accent-cyan-hover: #0369a1; - --accent-cyan-subtle: rgba(2, 132, 199, 0.08); - --accent-indigo: #4f46e5; - --accent-indigo-subtle: rgba(79, 70, 229, 0.08); - --accent-blue: #1f93ff; - --accent-blue-strong: #075985; - --accent-blue-dark: #075985; - --accent-blue-deep: #0c4a6e; - --accent-blue-subtle: rgba(31, 147, 255, 0.08); - --accent-blue-border: #bae6fd; - --focus-ring: rgba(31, 147, 255, 0.35); - - /* Status Colors: text variants meet WCAG AA on light surfaces. */ - --status-good: #047857; - --status-good-text: #047857; - --status-good-bg: #ecfdf5; - --status-good-border: #a7f3d0; - - --status-warning: #92400e; - --status-warning-text: #92400e; - --status-warning-bg: #fffbeb; - --status-warning-border: #fde68a; - - --status-critical: #be123c; - --status-critical-text: #be123c; - --status-critical-bg: #fff1f2; - --status-critical-border: #fecdd3; - - --status-info: #075985; - --status-info-bg: #f0f9ff; - --status-info-border: #bae6fd; - - /* Font Families: Inter + System UI fallback (Aligned with DotB-Omni) */ - --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; - - /* Radii */ - --radius-xs: 4px; - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 14px; - --radius-xl: 20px; - --radius-full: 9999px; - - /* Transitions */ - --transition-fast: 150ms cubic-bezier(0.16, 1, 0.3, 1); - --transition-smooth: 250ms cubic-bezier(0.16, 1, 0.3, 1); - --transition-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1); -} - -*, *::before, *::after { - box-sizing: border-box; -} - -html, body { - margin: 0; - padding: 0; - min-height: 100vh; - font-family: var(--font-sans); - background-color: var(--bg-canvas); - color: var(--text-primary); - line-height: 1.5; - letter-spacing: -0.01em; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -code, pre, kbd, samp, .font-mono { - font-family: var(--font-mono); - font-feature-settings: "tnum" 1, "zero" 1; -} - -button { - font-family: inherit; - user-select: none; -} - -/* Custom crisp scrollbars */ -::-webkit-scrollbar { - width: 6px; - height: 6px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: rgba(15, 23, 42, 0.15); - border-radius: var(--radius-full); -} - -::-webkit-scrollbar-thumb:hover { - background: rgba(15, 23, 42, 0.3); -} diff --git a/old_repo/apps/dev-console/src/lib/api/client.ts b/old_repo/apps/dev-console/src/lib/api/client.ts deleted file mode 100644 index 5b9630e..0000000 --- a/old_repo/apps/dev-console/src/lib/api/client.ts +++ /dev/null @@ -1,209 +0,0 @@ -import createClient from 'openapi-fetch'; -import type { paths } from './schema.d'; -import type { - CreateScoringRunRequest, - CreateScoringRunResponse, - DevConsoleConfig, - ListScoringRunsResult, - ScoringConfig, - ScoringRunResponse, -} from './types'; - -export const DEFAULT_CONFIG: DevConsoleConfig = { - tenantId: 'omni-account-42', - dotbApp: 'dev-console', - dotbFeature: 'lead-scoring', - pollIntervalMs: 1000, -}; - -export function createApiClient() { - return createClient({ - baseUrl: '/dev-console-api', - }); -} - -export interface RequestHeaders { - tenantId?: string; - idempotencyKey?: string; - dotbApp?: string; - dotbFeature?: string; -} - -export async function admitScoringRun( - client: ReturnType, - payload: CreateScoringRunRequest, - headers: RequestHeaders = {}, - config: DevConsoleConfig = DEFAULT_CONFIG, -): Promise<{ data?: CreateScoringRunResponse; error?: unknown; status: number }> { - const idemKey = headers.idempotencyKey ?? crypto.randomUUID(); - const app = headers.dotbApp ?? config.dotbApp; - const feature = headers.dotbFeature ?? config.dotbFeature; - - const res = await client.POST('/v1/scoring-runs', { - params: { - header: { - 'X-Tenant-VKey': '', - 'Idempotency-Key': idemKey, - 'X-DotB-App': app, - 'X-DotB-Feature': feature, - }, - }, - body: payload, - }); - - return { - data: res.data, - error: res.error, - status: res.response.status, - }; -} - -export async function fetchScoringRun( - client: ReturnType, - runId: string, - tenantId: string = DEFAULT_CONFIG.tenantId, -): Promise<{ data?: ScoringRunResponse; error?: unknown; status: number }> { - const res = await client.GET('/v1/scoring-runs/{run_id}', { - params: { - path: { run_id: runId }, - header: { - 'X-Tenant-Id': tenantId, - }, - }, - }); - - return { - data: res.data, - error: res.error, - status: res.response.status, - }; -} - -export async function listScoringRuns( - client: ReturnType, - tenantId: string = DEFAULT_CONFIG.tenantId, - limit: number = 50, - offset: number = 0, -): Promise { - const res = await client.GET('/v1/scoring-runs', { - params: { - header: { - 'X-Tenant-Id': tenantId, - }, - query: { - limit, - offset, - }, - }, - }); - if (!res.data) throw new Error(`Không tải được danh sách phiên chấm điểm: HTTP ${res.response?.status}`); - const headerTotal = - res.response?.headers?.get?.('x-total-count') ?? - res.response?.headers?.get?.('X-Total-Count'); - const parsedTotal = headerTotal ? parseInt(headerTotal, 10) : NaN; - const total = Number.isNaN(parsedTotal) ? res.data.length : parsedTotal; - return { - items: res.data, - total, - }; -} - -export async function listScoringConfigs( - client: ReturnType, - config: DevConsoleConfig = DEFAULT_CONFIG, -): Promise { - const res = await client.GET('/v1/scoring-runs/configs', { - params: { - header: { - 'X-Tenant-Id': config.tenantId, - 'X-DotB-App': config.dotbApp, - 'X-DotB-Feature': config.dotbFeature, - }, - }, - }); - if (!res.data) throw new Error(`Không tải được cấu hình: HTTP ${res.response.status}`); - return res.data; -} - -export async function saveScoringConfig( - client: ReturnType, - scoringConfig: ScoringConfig, - config: DevConsoleConfig = DEFAULT_CONFIG, -): Promise { - const res = await client.POST('/v1/scoring-runs/configs', { - params: { - header: { - 'X-Tenant-Id': config.tenantId, - 'X-DotB-App': config.dotbApp, - 'X-DotB-Feature': config.dotbFeature, - }, - }, - body: scoringConfig, - }); - if (res.response.status === 409) { - throw new Error( - `Phiên bản "${scoringConfig.version}" đã tồn tại. Mỗi cấu hình là bất biến (immutable), vui lòng đổi tên phiên bản mới (hoặc bấm "Tạo mã phiên bản mới").`, - ); - } - if (!res.data) { - const errorDetail = - (res.error as Record)?.detail ?? - JSON.stringify(res.error) ?? - `HTTP ${res.response.status}`; - throw new Error(`Không lưu được cấu hình: ${String(errorDetail)}`); - } - return res.data; -} - -export async function fetchExecutionTrace( - client: ReturnType, - runId: string, - tenantId: string, -): Promise> { - const res = await client.GET('/v1/scoring-runs/{run_id}/trace', { - params: { path: { run_id: runId }, header: { 'X-Tenant-Id': tenantId } }, - }); - if (!res.data) throw new Error(`Không tải được pipeline trace: HTTP ${res.response.status}`); - return res.data as Record; -} - -export const TERMINAL_STATUSES = new Set([ - 'completed', - 'review_required', - 'failed', - 'superseded', -]); - -export async function pollUntilTerminal( - client: ReturnType, - runId: string, - options: { - tenantId?: string; - intervalMs?: number; - maxAttempts?: number; - onTick?: (run: ScoringRunResponse) => void; - } = {}, -): Promise { - const intervalMs = options.intervalMs ?? DEFAULT_CONFIG.pollIntervalMs; - const maxAttempts = options.maxAttempts ?? 30; - const tenantId = options.tenantId ?? DEFAULT_CONFIG.tenantId; - - for (let i = 0; i < maxAttempts; i++) { - const res = await fetchScoringRun(client, runId, tenantId); - if (!res.data) { - throw new Error(`Failed to fetch run: ${JSON.stringify(res.error)} (HTTP ${res.status})`); - } - - if (options.onTick) { - options.onTick(res.data); - } - - if (TERMINAL_STATUSES.has(res.data.status)) { - return res.data; - } - - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - - throw new Error(`Polling timed out after ${maxAttempts} attempts for run ${runId}`); -} diff --git a/old_repo/apps/dev-console/src/lib/api/schema.d.ts b/old_repo/apps/dev-console/src/lib/api/schema.d.ts deleted file mode 100644 index f7de56b..0000000 --- a/old_repo/apps/dev-console/src/lib/api/schema.d.ts +++ /dev/null @@ -1,1122 +0,0 @@ -/** - * This file was auto-generated by openapi-typescript. - * Do not make direct changes to the file. - */ - -export interface paths { - "/health": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Service Health Check */ - get: operations["health_check_health_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/scoring-runs": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** List recent Scoring Runs for a tenant */ - get: operations["list_scoring_runs_v1_scoring_runs_get"]; - put?: never; - /** Admit a new Lead Scoring Run */ - post: operations["create_scoring_run_v1_scoring_runs_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/scoring-runs/configs": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** List internal scoring configs */ - get: operations["list_scoring_configs_v1_scoring_runs_configs_get"]; - put?: never; - /** Register a process-local immutable internal scoring config */ - post: operations["create_scoring_config_v1_scoring_runs_configs_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/scoring-runs/{run_id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Inspect Scoring Run status */ - get: operations["get_scoring_run_v1_scoring_runs__run_id__get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/scoring-runs/{run_id}/trace": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get internal execution trace for a scoring run (non-production only) */ - get: operations["get_scoring_run_trace_v1_scoring_runs__run_id__trace_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; -} -export type webhooks = Record; -export interface components { - schemas: { - /** AiMetadataFailure */ - AiMetadataFailure: { - /** - * Detail - * @description AI error detail - */ - detail: string; - /** - * Error Code - * @description Machine-readable AI error code - */ - error_code: string; - /** @description Fallback policy triggered */ - fallback_policy: components["schemas"]["FallbackPolicy"]; - }; - /** AiMetadataSuccess */ - AiMetadataSuccess: { - /** @description AI effort profile applied */ - effort_profile: components["schemas"]["EffortProfile"]; - /** - * Model - * @description AI model used - */ - model: string; - /** - * Pass Count - * @description Number of passes executed - */ - pass_count: number; - }; - /** - * BehavioralMetric - * @enum {string} - */ - BehavioralMetric: "message_count" | "conversation_count" | "conversation_turn_count" | "customer_message_count" | "customer_initiated_return"; - /** CanonicalSnapshot */ - CanonicalSnapshot: { - /** - * Captured At - * Format: date-time - * @description Timezone-aware timestamp when snapshot evidence was frozen - */ - captured_at: string; - /** Conversations */ - conversations?: components["schemas"]["ConversationEvidence"][]; - hitl_policy?: components["schemas"]["HitlPolicy"] | null; - identity_context?: components["schemas"]["IdentityContext"] | null; - lead_profile: components["schemas"]["LeadProfile"]; - /** @default linked_contact_history */ - scope: components["schemas"]["SnapshotScope"]; - trigger_metadata?: components["schemas"]["TriggerMetadata"] | null; - truncation?: components["schemas"]["TruncationMetadata"] | null; - /** - * Version - * @default 1.0.0 - */ - version: string; - }; - /** ConversationEvidence */ - ConversationEvidence: { - /** Assigned Agent Id */ - assigned_agent_id?: string | null; - /** Channel */ - channel?: string | null; - /** Conversation Id */ - conversation_id: string; - /** Messages */ - messages?: components["schemas"]["MessageEvidence"][]; - }; - /** CreateScoringRunRequest */ - CreateScoringRunRequest: { - /** - * Rule Overrides - * @description Inline dynamic rule overrides (maximum 30 rules) - */ - rule_overrides?: components["schemas"]["DynamicRule"][]; - /** - * Scoring Config Version - * @description Scoring config version - * @default 1.0.0 - */ - scoring_config_version: string; - /** @description Canonical Lead Snapshot containing evidence */ - snapshot: components["schemas"]["CanonicalSnapshot"]; - /** - * Subject Ref - * @description EMS Lead reference or identifier - */ - subject_ref: string; - /** - * Tenant Id - * @description Tenant identifier - */ - tenant_id: string; - }; - /** CreateScoringRunResponse */ - CreateScoringRunResponse: { - /** - * Created At - * Format: date-time - * @description Creation timestamp - */ - created_at: string; - /** - * Run Id - * @description Server-generated unique scoring run ID - */ - run_id: string; - /** - * Scoring Config Version - * @description Scoring config version applied - */ - scoring_config_version: string; - /** - * @description Initial run status - * @default pending - */ - status: components["schemas"]["ScoringRunStatus"]; - /** - * Status Url - * @description URL to inspect scoring run status - */ - status_url: string; - /** - * Subject Ref - * @description EMS Lead reference - */ - subject_ref: string; - /** - * Tenant Id - * @description Tenant identifier - */ - tenant_id: string; - }; - /** - * DynamicRule - * @description A configurable dynamic scoring rule that evaluates snapshot features. - */ - DynamicRule: { - /** - * Custom Field Key - * @description Key inside lead_profile.custom_fields if target is custom_field - */ - custom_field_key?: string | null; - /** - * Enabled - * @description Whether this rule is active - * @default true - */ - enabled: boolean; - /** - * Id - * @description Unique identifier for the rule - */ - id: string; - /** - * Name - * @description Human-readable rule name - */ - name: string; - /** @description Comparison operator */ - operator: components["schemas"]["DynamicRuleOperator"]; - /** - * Points - * @description Points awarded when matched (-100 to 100) - */ - points: number; - /** - * @description Target feature or custom_field to evaluate - * @default custom_field - */ - target: components["schemas"]["DynamicRuleTarget"]; - /** - * Value - * @description Target comparison value (number, string, or list) - */ - value: unknown; - }; - /** - * DynamicRuleOperator - * @enum {string} - */ - DynamicRuleOperator: "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "contains" | "in"; - /** - * DynamicRuleTarget - * @enum {string} - */ - DynamicRuleTarget: "student_age" | "lead_source" | "course_interest" | "study_area" | "contact_message_recency_days" | "conversation_count" | "message_count" | "custom_field"; - /** - * EffortProfile - * @enum {string} - */ - EffortProfile: "light" | "medium" | "heavy"; - /** ErrorResponse */ - ErrorResponse: { - /** - * Detail - * @description Human-readable error description - */ - detail: string; - /** - * Error Code - * @description Machine-readable error code - */ - error_code: string; - }; - /** - * FallbackPolicy - * @enum {string} - */ - FallbackPolicy: "rules_only_review" | "fail_run"; - /** HTTPValidationError */ - HTTPValidationError: { - /** Detail */ - detail?: components["schemas"]["ValidationError"][]; - }; - /** HealthResponse */ - HealthResponse: { - /** - * Database - * @default connected - * @enum {string} - */ - database: "connected" | "unreachable" | "unconfigured"; - /** - * Status - * @default ok - * @enum {string} - */ - status: "ok" | "unhealthy"; - /** - * Version - * @default 0.1.0 - */ - version: string; - }; - /** HitlPolicy */ - HitlPolicy: { - /** - * Confidence Threshold - * @default 0.8 - */ - confidence_threshold: number; - /** - * Policy Version - * @default 1.0.0 - */ - policy_version: string; - }; - /** IdentityContext */ - IdentityContext: { - /** Contact Id */ - contact_id?: string | null; - /** Details */ - details?: { - [key: string]: unknown; - }; - /** Match Quality */ - match_quality?: ("direct" | "phone_and_email" | "phone_and_name" | "phone_only") | null; - }; - /** LeadProfile */ - LeadProfile: { - /** Custom Fields */ - custom_fields?: { - [key: string]: unknown; - }; - /** Email */ - email?: string | null; - /** Ems Lead Id */ - ems_lead_id: string; - /** Name */ - name?: string | null; - /** Phone */ - phone?: string | null; - /** Status */ - status?: string | null; - }; - /** MessageEvidence */ - MessageEvidence: { - /** Content */ - content: string; - /** Message Id */ - message_id: string; - sender_type: components["schemas"]["MessageSenderType"]; - /** - * Sent At - * Format: date-time - * @description Timezone-aware timestamp of when message was sent - */ - sent_at: string; - }; - /** @enum {string} */ - MessageSenderType: "contact" | "agent" | "bot"; - /** RuleBreakdownItem */ - RuleBreakdownItem: { - /** Detail */ - detail: string; - /** Points Awarded */ - points_awarded: number; - /** Rule Name */ - rule_name: string; - status: components["schemas"]["RuleEvaluationStatus"]; - }; - /** - * RuleEvaluationStatus - * @enum {string} - */ - RuleEvaluationStatus: "matched" | "not_matched" | "skipped_missing_data"; - /** - * RuleMetric - * @enum {string} - */ - RuleMetric: "profile_field_count" | "conversation_count" | "message_count"; - /** ScoringConfig */ - ScoringConfig: { - /** - * Age Primary Max - * @default 15 - */ - age_primary_max: number; - /** - * Age Primary Min - * @default 3 - */ - age_primary_min: number; - /** - * Age Primary Points - * @default 40 - */ - age_primary_points: number; - /** - * Age Secondary Max - * @default 18 - */ - age_secondary_max: number; - /** - * Age Secondary Min - * @default 16 - */ - age_secondary_min: number; - /** - * Age Secondary Points - * @default 20 - */ - age_secondary_points: number; - /** - * Ai Model - * @default openai/gpt-4o-mini - */ - ai_model: string; - /** - * Ai Timeout Seconds - * @default 10 - */ - ai_timeout_seconds: number; - /** - * Ai Weight - * @default 0.6 - */ - ai_weight: number; - /** - * Base Score - * @default 0 - */ - base_score: number; - /** - * Completeness Threshold - * @default 0.5 - */ - completeness_threshold: number; - /** Confidence */ - confidence: number; - /** Confidence Threshold */ - confidence_threshold: number; - /** - * Dynamic Rules - * @description Operator-configured dynamic rules evaluated against lead features - * @default [] - */ - dynamic_rules: components["schemas"]["DynamicRule"][]; - /** - * Education Demo Enabled - * @default false - */ - education_demo_enabled: boolean; - /** @default medium */ - effort_profile: components["schemas"]["EffortProfile"]; - /** @default rules_only_review */ - fallback_policy: components["schemas"]["FallbackPolicy"]; - /** Hitl Policy Version */ - hitl_policy_version: string; - /** - * Medium Extract Prompt - * @description Editable system prompt for Medium AI evidence extraction pass - * @default Bạn là trợ lý AI phân tích hội thoại để đánh giá tiềm năng học viên. Hãy trích xuất bằng chứng có cấu trúc về nhu cầu khóa học, mức độ quan tâm, thời điểm đăng ký và rào cản. Trả lời bằng tiếng Việt và không tự bịa dữ kiện. - */ - medium_extract_prompt: string; - /** - * Medium Score Prompt - * @description Editable system prompt for Medium AI lead scoring pass - * @default Bạn là chuyên gia chấm điểm Lead giáo dục từ 0 đến 100. Dựa trên bằng chứng đã trích xuất, hãy đánh giá khả năng đăng ký, độ tin cậy, lý do và các yếu tố tích cực hoặc tiêu cực. Lưu ý: Không cộng lại điểm các customer rules hoặc metrics đã tính ở phần quy tắc riêng. Trả lời bằng tiếng Việt và không tự bịa dữ kiện. - */ - medium_score_prompt: string; - /** - * Recency Cold Max Days - * @default 30 - */ - recency_cold_max_days: number; - /** - * Recency Cold Points - * @default 5 - */ - recency_cold_points: number; - /** - * Recency Cool Max Days - * @default 14 - */ - recency_cool_max_days: number; - /** - * Recency Cool Points - * @default 15 - */ - recency_cool_points: number; - /** - * Recency Hot Max Days - * @default 3 - */ - recency_hot_max_days: number; - /** - * Recency Hot Points - * @default 40 - */ - recency_hot_points: number; - /** - * Recency Warm Max Days - * @default 7 - */ - recency_warm_max_days: number; - /** - * Recency Warm Points - * @default 25 - */ - recency_warm_points: number; - /** - * Rule Weight - * @default 0.4 - */ - rule_weight: number; - /** - * Rules - * @default [] - */ - rules: components["schemas"]["ScoringRule"][]; - /** - * Selected Behavioral Metrics - * @description System behavioral metrics calculated deterministically and supplied to AI context - * @default [ - * "message_count", - * "conversation_count", - * "conversation_turn_count", - * "customer_message_count", - * "customer_initiated_return" - * ] - */ - selected_behavioral_metrics: components["schemas"]["BehavioralMetric"][]; - /** - * Source Call In Points - * @default 15 - */ - source_call_in_points: number; - /** - * Source Campaign Points - * @default 8 - */ - source_campaign_points: number; - /** - * Source Digital Points - * @default 10 - */ - source_digital_points: number; - /** - * Source Other Points - * @default 5 - */ - source_other_points: number; - /** - * Source Referral Points - * @default 20 - */ - source_referral_points: number; - /** Version */ - version: string; - }; - /** - * ScoringMode - * @enum {string} - */ - ScoringMode: "light_ai" | "medium_ai" | "heavy_ai" | "rules_only_fallback" | "rule_only"; - /** ScoringRule */ - ScoringRule: { - metric: components["schemas"]["RuleMetric"]; - /** Minimum */ - minimum: number; - /** Points */ - points: number; - }; - /** ScoringRunResponse */ - ScoringRunResponse: { - /** Ai Metadata */ - ai_metadata: components["schemas"]["AiMetadataSuccess"] | components["schemas"]["AiMetadataFailure"] | null; - /** Ai Score */ - ai_score: number | null; - /** - * Breakdown - * @description Rule evaluation breakdown - */ - breakdown?: components["schemas"]["RuleBreakdownItem"][] | null; - /** - * Category - * @description Public lead tier / category derived deterministically from score - */ - category?: string | null; - /** Completed At */ - completed_at: string | null; - /** Confidence */ - confidence: number | null; - /** Confidence Threshold */ - confidence_threshold: number | null; - /** - * Created At - * Format: date-time - * @description Creation timestamp - */ - created_at: string; - /** Degraded */ - degraded: boolean | null; - effective_config: components["schemas"]["ScoringConfig"] | null; - /** Effective Score */ - effective_score: number | null; - /** Error Code */ - error_code: string | null; - /** - * Explanation - * @description Concise public explanation using AI reasoning where available or rule summary - */ - explanation?: string | null; - /** Final Score */ - final_score: number | null; - /** - * Parent Run Id - * @description Parent scoring run ID if created via re-evaluation - */ - parent_run_id?: string | null; - /** - * Recommended Action - * @description Deterministic recommended next action based on score - */ - recommended_action?: string | null; - /** Retryable */ - retryable: boolean | null; - /** Rule Score */ - rule_score: number | null; - /** - * Run Id - * @description Unique scoring run ID - */ - run_id: string; - /** - * Scoring Config Version - * @description Scoring config version - */ - scoring_config_version: string; - scoring_mode: components["schemas"]["ScoringMode"] | null; - /** Started At */ - started_at: string | null; - /** @description Current scoring run status */ - status: components["schemas"]["ScoringRunStatus"]; - /** - * Subject Ref - * @description EMS Lead reference - */ - subject_ref: string; - /** - * Tenant Id - * @description Tenant identifier - */ - tenant_id: string; - /** - * Updated At - * Format: date-time - * @description Last updated timestamp - */ - updated_at: string; - /** - * Version - * @description Run version for optimistic concurrency - */ - version: number; - }; - /** - * ScoringRunStatus - * @enum {string} - */ - ScoringRunStatus: "pending" | "running" | "completed" | "review_required" | "failed" | "superseded"; - /** - * SnapshotScope - * @enum {string} - */ - SnapshotScope: "trigger_only" | "linked_contact_history" | "multi_source_history"; - /** TriggerMetadata */ - TriggerMetadata: { - /** Actor Id */ - actor_id?: string | null; - /** Trigger Conversation Id */ - trigger_conversation_id?: string | null; - /** - * Trigger Type - * @default manual_omni - * @enum {string} - */ - trigger_type: "manual_omni" | "captain_tool" | "dev_console"; - }; - /** TruncationMetadata */ - TruncationMetadata: { - /** - * Dropped Conversations Count - * @default 0 - */ - dropped_conversations_count: number; - /** - * Dropped Messages Count - * @default 0 - */ - dropped_messages_count: number; - /** Reason */ - reason?: string | null; - /** - * Truncated - * @default false - */ - truncated: boolean; - }; - /** ValidationError */ - ValidationError: { - /** Context */ - ctx?: Record; - /** Input */ - input?: unknown; - /** Location */ - loc: (string | number)[]; - /** Message */ - msg: string; - /** Error Type */ - type: string; - }; - }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export interface operations { - health_check_health_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Service and database healthy */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HealthResponse"]; - }; - }; - /** @description Database or critical dependency unreachable or unconfigured */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HealthResponse"]; - }; - }; - }; - }; - list_scoring_runs_v1_scoring_runs_get: { - parameters: { - query?: { - /** @description Maximum number of recent runs to return */ - limit?: number; - /** @description Number of recent runs to skip */ - offset?: number; - }; - header: { - /** @description Tenant identifier for scoped tenant isolation query */ - "X-Tenant-Id": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Recent scoring runs scoped to the requested tenant */ - 200: { - headers: { - /** @description Authoritative total count of scoring runs for the tenant */ - "X-Total-Count"?: number; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ScoringRunResponse"][]; - }; - }; - /** @description Invalid or missing Bearer service token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Caller not authorized for tenant */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - create_scoring_run_v1_scoring_runs_post: { - parameters: { - query?: never; - header: { - /** @description Tenant-scoped idempotency key */ - "Idempotency-Key": string; - /** @description Originating DotB application identifier */ - "X-DotB-App": string; - /** @description Originating DotB feature identifier */ - "X-DotB-Feature": string; - /** @description Optional tenant AIHub credential passed in RAM for execution attempt */ - "X-Tenant-VKey"?: string | null; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateScoringRunRequest"]; - }; - }; - responses: { - /** @description Scoring run accepted and persisted in pending state */ - 202: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CreateScoringRunResponse"]; - }; - }; - /** @description Invalid or missing Bearer service token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Caller not authorized for tenant, app, or feature */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Idempotency key conflict with mismatched payload */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Validation error or structural caps exceeded */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Scoring admission is disabled by the operational kill switch */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - }; - }; - list_scoring_configs_v1_scoring_runs_configs_get: { - parameters: { - query?: never; - header: { - "X-Tenant-Id": string; - "X-DotB-App": string; - "X-DotB-Feature": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ScoringConfig"][]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - create_scoring_config_v1_scoring_runs_configs_post: { - parameters: { - query?: never; - header: { - "X-Tenant-Id": string; - "X-DotB-App": string; - "X-DotB-Feature": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["ScoringConfig"]; - }; - }; - responses: { - /** @description Successful Response */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ScoringConfig"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - get_scoring_run_v1_scoring_runs__run_id__get: { - parameters: { - query?: never; - header: { - /** @description Tenant identifier for scoped tenant isolation query */ - "X-Tenant-Id": string; - }; - path: { - /** @description Scoring run ID */ - run_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Scoring run metadata and current status */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ScoringRunResponse"]; - }; - }; - /** @description Invalid or missing Bearer service token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Caller not authorized for tenant */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Scoring run not found for tenant */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Validation error for missing required headers */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - }; - }; - get_scoring_run_trace_v1_scoring_runs__run_id__trace_get: { - parameters: { - query?: never; - header: { - /** @description Tenant identifier for scoped tenant isolation query */ - "X-Tenant-Id": string; - }; - path: { - /** @description Scoring run ID */ - run_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Internal execution trace for scoring run */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - [key: string]: unknown; - }; - }; - }; - /** @description Invalid or missing Bearer service token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Caller unauthorized or trace disabled in production */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Scoring run or trace not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Validation error for missing required headers */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - }; - }; -} diff --git a/old_repo/apps/dev-console/src/lib/api/types.ts b/old_repo/apps/dev-console/src/lib/api/types.ts deleted file mode 100644 index 747cba7..0000000 --- a/old_repo/apps/dev-console/src/lib/api/types.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { components, paths } from './schema.d'; - -export type CreateScoringRunRequest = components['schemas']['CreateScoringRunRequest']; -export type CreateScoringRunResponse = components['schemas']['CreateScoringRunResponse']; -export type ScoringRunResponse = components['schemas']['ScoringRunResponse']; -export type CanonicalSnapshot = components['schemas']['CanonicalSnapshot']; -export type ScoringConfig = components['schemas']['ScoringConfig']; -export type BehavioralMetric = components['schemas']['BehavioralMetric']; -export type DynamicRule = components['schemas']['DynamicRule']; -export type DynamicRuleOperator = components['schemas']['DynamicRuleOperator']; -export type DynamicRuleTarget = components['schemas']['DynamicRuleTarget']; -export type ScoringRunStatus = components['schemas']['ScoringRunStatus']; -export type ErrorResponse = components['schemas']['ErrorResponse']; -export type AiMetadataSuccess = components['schemas']['AiMetadataSuccess']; -export type AiMetadataFailure = components['schemas']['AiMetadataFailure']; - -export interface DevConsoleConfig { - tenantId: string; - dotbApp: string; - dotbFeature: string; - pollIntervalMs: number; -} - -export interface ListScoringRunsResult { - items: ScoringRunResponse[]; - total: number; -} - -export interface GoldenFixture { - id: string; - name: string; - description: string; - expectedStatus: ScoringRunStatus; - expectedMode?: string; - category?: string; - coverImage?: string; - payload: CreateScoringRunRequest; -} diff --git a/old_repo/apps/dev-console/src/lib/components/ChurnPrediction.svelte b/old_repo/apps/dev-console/src/lib/components/ChurnPrediction.svelte deleted file mode 100644 index 38725b9..0000000 --- a/old_repo/apps/dev-console/src/lib/components/ChurnPrediction.svelte +++ /dev/null @@ -1,302 +0,0 @@ - - -
-
-
-
-
- In Development - Dự án giai đoạn tiếp theo · Q4/2026 -
-

Dự đoán học viên rời bỏ (Student Churn Prediction)

-

Mô hình AI dự báo sớm nguy cơ học viên nghỉ học hoặc không tái tục trước 30 ngày để can thiệp kịp thời.

-
-
- Tích hợp: - DOTB EMS · LMS · Omni -
-
- - -
-
- 01 - Thu thập tín hiệu -

Hệ thống tự động đồng bộ sự kiện điểm danh, kết quả bài tập và hội thoại từ EMS & Omni.

-
-
→
-
- 02 - Mô hình AI phân tích -

Mô hình kết hợp ML phân tích chuỗi thời gian + LLM đánh giá cảm xúc phụ huynh.

-
-
→
-
- 03 - Cảnh báo & Can thiệp -

Gắn nhãn rủi ro Churn (High / Medium / Low) và tự động tạo việc cho tư vấn viên chăm sóc.

-
-
- - -
-

Bộ chỉ số tín hiệu dự báo sớm

-
- {#each plannedSignals as signal} -
-
- {signal.title} - {signal.weight} -
-

{signal.desc}

-
- {/each} -
-
- - -
-
- -
-
- Lộ trình triển khai -

Tính năng này sẽ được phát triển tiếp nối sau khi hoàn tất giai đoạn Customer Pilot của phân hệ Lead Scoring. Bộ API và Webhook sẽ kế thừa chuẩn kiến trúc bảo mật dùng chung của AI Platform.

-
-
-
-
- - diff --git a/old_repo/apps/dev-console/src/lib/components/DemoFlow.svelte b/old_repo/apps/dev-console/src/lib/components/DemoFlow.svelte deleted file mode 100644 index 431dfa7..0000000 --- a/old_repo/apps/dev-console/src/lib/components/DemoFlow.svelte +++ /dev/null @@ -1,1858 +0,0 @@ - - -
- - - {#if activeTab === 'demo'} -
- -
-
-

DỮ LIỆU ĐẦU VÀO

-

Thông tin Lead

-
- - -
- Kịch bản nhanh: -
- - - -
-
- - {#if consoleState.currentRunId} -
- - -
- {/if} - -
-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
- - -
-
- - -
- -
- Hội thoại tư vấn - - {#if rawSnapshotMessages.length > 0} -
-
- Hội thoại thực tế đã ghi nhận ({rawSnapshotMessages.length} tin nhắn) -
-
- {#each rawSnapshotMessages as msg} -
-
- {msg.sender_type === 'contact' ? 'Phụ huynh' : 'Tư vấn viên (AI/Agent)'} - {#if msg.sent_at} - - {/if} -
-

{msg.content}

-
- {/each} -
-
- {/if} - -
- - {rawSnapshotMessages.length > 0 ? 'Tùy chỉnh nội dung tin nhắn để chạy lại' : 'Nội dung tin nhắn giả lập'} - - -
-
- - -
-
- - -
-
- - -
-
- - -
- {#if showSecondConversation} -
Hội thoại Facebook bổ sung
-
- - -
-
- - -
- {/if} -
-
-
- - 0 ? { student_age: studentAge } : {}), - ...(courseInterest.trim() ? { course_interest: courseInterest.trim() } : {}), - ...(leadSource.trim() ? { lead_source: leadSource.trim() } : {}), - }, - }} - /> - - - {#if consoleState.errorMessage}{/if} -
-
- - -
-
-
-

PIPELINE TRỰC QUAN

-

Theo dõi quá trình xử lý

-
-

{consoleState.statusMessage}

-
- - {#if !trace} -
- Chưa có kết quả phân tích - Nhấn "Chạy chấm điểm Lead" hoặc chọn một phiên chạy trong danh sách lịch sử để xem chi tiết pipeline. -
- {:else} -
-
-
01

Customer rules + AI

- {#each ruleBreakdown as item} - {@const rule = item as Record} -
-
{displayRuleName(rule.rule_name)}{String(rule.detail)}
- {displayRuleStatus(rule.status)} - {Number(rule.points_awarded) > 0 ? `+${String(rule.points_awarded)}` : '0'} -
- {/each} -
- -
-
02

Độ đầy đủ dữ liệu

-
- {completenessPercent}% - = completenessThreshold} class:needs-review={completenessPercent < completenessThreshold}> - {completenessPercent >= completenessThreshold ? 'Đạt ngưỡng' : 'Cần bổ sung context'} - -
-

Ngưỡng cấu hình: {completenessThreshold}% (gồm 4 nhóm dữ liệu, mỗi nhóm 25%).

-
- Có: {Array.isArray(completeness?.present_fields) ? completeness.present_fields.map(displayCompletenessField).join(', ') || 'chưa có' : 'chưa có'} - Thiếu: {Array.isArray(completeness?.missing_fields) ? completeness.missing_fields.map(displayCompletenessField).join(', ') || 'không thiếu' : 'không rõ'} -
-
- -
-
03

Trích xuất bằng chứng từ hội thoại

-

{String(ai?.summary ?? 'AI không trích xuất được do chuyển sang chế độ dự phòng.')}

- {#if Array.isArray(ai?.key_signals)} -
    - {#each ai.key_signals as signal} -
  • {String(signal)}
  • - {/each} -
- {/if} -
- -
-
04

Đánh giá ngữ cảnh bằng AI

-

{String(ai?.reasoning ?? 'Không có phân tích ngữ cảnh từ AI.')}

- {#if Array.isArray(ai?.positive_factors)} -

Tín hiệu tích cực: {ai.positive_factors.map(String).join(', ')}

- {/if} -
- -
-
05

Kết quả chấm điểm cuối

-
- {consoleState.currentRun?.final_score ?? consoleState.currentRun?.effective_score ?? '-'}/100 - {displayCategory(consoleState.currentRun?.category)} -
-

{consoleState.currentRun?.explanation}

-

Đề xuất tiếp theo: {consoleState.currentRun?.recommended_action}

-

Công thức tính: Bộ quy tắc x {String(calculation?.rule_weight ?? 0)} + AI x {String(calculation?.ai_weight ?? 0)}

- {#if trace.terminal_status_reason} -

{String(trace.terminal_status_reason)}

- {/if} -
-
- - {/if} -
-
- {:else if activeTab === 'runs'} - -
-
-
-
-

LỊCH SỬ THỰC THI

-

Danh sách các phiên chấm điểm đã lưu

-
- -
- - -
- Lọc theo trạng thái: -
- - - - -
-
- - {#if consoleState.isLoadingRuns && consoleState.recentRuns.length === 0} -
- - - - - - - - - - - - - - - {#each [1, 2, 3, 4, 5] as _} - - - - - - - - - - - {/each} - -
Mã phiên (Run ID)Tham chiếu LeadPhiên bản luậtTrạng tháiĐiểm sốPhân loạiThời gian tiếp nhậnThao tác
-
- {:else if filteredRuns.length === 0} -
- Không có phiên chạy nào phù hợp - Hãy gửi yêu cầu chấm điểm từ DotB-Omni hoặc thực hiện thử nghiệm ở tab đầu tiên. -
- {:else} -
- {#if consoleState.isLoadingRuns} -
- {/if} - - - - - - - - - - - - - - - {#each filteredRuns as run} - - - - - - - - - - - {/each} - -
Mã phiên (Run ID)Tham chiếu LeadPhiên bản luậtTrạng tháiĐiểm sốPhân loạiThời gian tiếp nhậnThao tác
{run.run_id.slice(0, 8)}...{run.subject_ref}{run.scoring_config_version}{run.final_score ?? run.effective_score ?? '-'}{displayCategory(run.category)}{new Date(run.created_at).toLocaleString('vi-VN')} - -
-
- {/if} - - {#if consoleState.recentRuns.length > 0 || consoleState.currentRunPage > 1} -
- - Trang {consoleState.currentRunPage} - -
- {/if} -
-
- {:else if activeTab === 'config'} - -
-
-
-

AI CONFIGURATION

-

Cấu hình AI nội bộ

-
- - {#if loadingConfigs} -

Đang tải cấu hình...

- {:else if !draft} - - {:else} -
-
-

AI input context

-

Dữ liệu AI nhận từ request

-
    -
  • Customer rules từ cấu hình đã chọn
  • -
  • Conversation và messages trong snapshot
  • -
  • Computed behavioral metrics
  • -
-
-
-

AI output template

-

Contract kết quả nội bộ

-
    -
  • ai_score và confidence
  • -
  • summary/reasoning
  • -
  • positive_factors và negative_factors
  • -
-
-
-
- -
-
- -
- - -
-
- -
- - -
- -
- Mô hình AI & Hướng dẫn phân tích -
- - -
-
-
- - -
-
- - -
-
- - -
-
-
- - - AI chỉ được trích xuất dữ kiện có thật trong cuộc trò chuyện -
-
- - -
-
-
- - -
-
- Chỉ số hành vi hệ thống đưa vào AI context -

- Lead Scoring tính toán tất định (deterministic) các chỉ số này từ snapshot và chuyển vào AI prompt làm ngữ cảnh. AI không cộng lại điểm các chỉ số này. -

-
- {#each AVAILABLE_BEHAVIORAL_METRICS as metric} - - {/each} -
-
- -
- Khuôn mẫu dữ liệu AI nhận được (Evidence Payload) -

Dữ liệu thực tế truyền vào AI qua prompt evidence (User Content JSON):

-
{JSON.stringify({
-  customer_rules: [
-    { field: "custom_fields.student_age", operator: "gte", value: 12, points: 20 }
-  ],
-  behavioral_metrics: Object.fromEntries(
-    (draft.selected_behavioral_metrics || []).map((m) => [m, ""])
-  ),
-  lead_profile: {
-    ems_lead_id: "lead-xxx",
-    name: "...",
-    custom_fields: { student_age: "12" }
-  },
-  conversations: [
-    { conversation_id: "conv-1", messages: [{ sender_type: "contact", content: "..." }] }
-  ]
-}, null, 2)}
-
- -
- Tỷ trọng kết hợp hai thành phần -
-
- - -
-
- - -
-
-
- - - {#if configError}{/if} -
-
- {/if} -
-
- {/if} - - {#if showPipelineModal} - - {/if} -
- - diff --git a/old_repo/apps/dev-console/src/lib/components/DynamicRuleBuilder.svelte b/old_repo/apps/dev-console/src/lib/components/DynamicRuleBuilder.svelte deleted file mode 100644 index 46c8467..0000000 --- a/old_repo/apps/dev-console/src/lib/components/DynamicRuleBuilder.svelte +++ /dev/null @@ -1,616 +0,0 @@ - - -
-
-
-

Customer rules mô phỏng request Omni

-

- Chỉ dùng cho phiên thử nghiệm hiện tại, mô phỏng rule_overrides do Omni gửi và không lưu vào AI config. -

-
-
- {#if sampleData && Object.keys(sampleData).length > 0} - 0}> - Test trên Lead mẫu: +{totalLiveBonus}đ - - {/if} - -
-
- - -
- Mẫu gợi ý: - {#each presets as preset} - - {/each} -
- - - {#if rules.length === 0} -
- -

Chưa có quy tắc mở rộng nào. Bấm "Thêm quy tắc" hoặc chọn mẫu gợi ý phía trên.

-
- {:else} -
- - - - - - - - - - - - - - - {#each rules as rule, index (rule.id || index)} - {@const testRes = evaluateRuleLive(rule)} - - - - - - - - - - - {/each} - -
BậtTên quy tắcThuộc tính mục tiêuToán tửGiá trị so sánhĐiểmKiểm thử Lead mẫu
- - - - -
- - {#if rule.target === 'custom_field'} - - {/if} -
-
- - - - - - -
- {#if testRes.status === 'matched'} - - - +{rule.points}đ - - {:else if testRes.status === 'not_matched'} - 0đ - {:else if testRes.status === 'missing'} - Chưa có - {:else} - Tắt - {/if} - {testRes.detail} -
-
- -
-
- {/if} -
- - diff --git a/old_repo/apps/dev-console/src/lib/components/FixtureSelector.svelte b/old_repo/apps/dev-console/src/lib/components/FixtureSelector.svelte deleted file mode 100644 index 1bb231f..0000000 --- a/old_repo/apps/dev-console/src/lib/components/FixtureSelector.svelte +++ /dev/null @@ -1,518 +0,0 @@ - - -
- -
-
-
- -
-
-

Kịch bản kiểm thử chuẩn

- Bộ dữ liệu lead mẫu và ảnh chụp hội thoại chuẩn -
-
- - -
- - -
- {#each GOLDEN_FIXTURES as fixture} - {@const isSelected = consoleState.selectedFixture.id === fixture.id} - - {/each} -
- - - {#if isEditingJson} -
-
-
- {#if jsonError} - - - JSON không hợp lệ - - {:else} - - - Cấu trúc hợp lệ - - {/if} -
- -
- - - -
-
- - - - {#if jsonError} - {jsonError} - {/if} -
- {/if} - - - -
- - diff --git a/old_repo/apps/dev-console/src/lib/components/Icon.svelte b/old_repo/apps/dev-console/src/lib/components/Icon.svelte deleted file mode 100644 index 5701065..0000000 --- a/old_repo/apps/dev-console/src/lib/components/Icon.svelte +++ /dev/null @@ -1,159 +0,0 @@ - - - - {#if name === 'sparkles'} - - - {:else if name === 'play'} - - {:else if name === 'refresh'} - - - - - {:else if name === 'check'} - - {:else if name === 'check-circle'} - - - {:else if name === 'alert-triangle'} - - - - {:else if name === 'alert-circle'} - - - - {:else if name === 'sliders'} - - - - - - {:else if name === 'code'} - - - {:else if name === 'copy'} - - - {:else if name === 'settings'} - - - {:else if name === 'chevron-down'} - - {:else if name === 'chevron-right'} - - {:else if name === 'chevron-up'} - - {:else if name === 'activity'} - - {:else if name === 'shield'} - - {:else if name === 'git-branch'} - - - - - {:else if name === 'clock'} - - - {:else if name === 'layers'} - - - - {:else if name === 'cpu'} - - - - - {:else if name === 'zap'} - - {:else if name === 'crosshair'} - - - - - - {:else if name === 'target'} - - - - {:else if name === 'trending-down'} - - - {:else if name === 'book-open'} - - - {:else if name === 'file-text'} - - - - - {:else if name === 'plus'} - - - {:else if name === 'trash'} - - - {:else if name === 'close'} - - - {/if} - - - diff --git a/old_repo/apps/dev-console/src/lib/components/RunInspector.svelte b/old_repo/apps/dev-console/src/lib/components/RunInspector.svelte deleted file mode 100644 index 3e02339..0000000 --- a/old_repo/apps/dev-console/src/lib/components/RunInspector.svelte +++ /dev/null @@ -1,747 +0,0 @@ - - -
- -
-
-
- -
-
-

Chi tiết tiến trình đánh giá

- Trạng thái thực thi, máy trạng thái và dòng thời gian vòng đời -
-
- -
- {#if run} - - - {/if} -
-
- - -
-
- Kịch bản đánh giá lead -
- -
-
- {currentFixture.category ?? 'ĐÁNH GIÁ LEAD'} - {#if run} - {run.status.toUpperCase()} - {/if} -
- -
-

{run ? `Mã Lead: ${run.subject_ref}` : currentFixture.name}

-

- {run - ? `Tenant: ${run.tenant_id} · Phiên bản cấu hình: ${run.scoring_config_version}` - : currentFixture.description} -

-
-
-
-
- - {#if !run} - -
-
- -
- Sẵn sàng chạy kiểm thử - Chọn một kịch bản kiểm thử ở cột bên trái và nhấn Chạy đánh giá điểm lead để kiểm tra. -
-
-
- {:else} - -
-
- MÃ PHIÊN CHẠY: - {run.run_id} -
- -
- - - {#if run.parent_run_id} -
- -
- Nguồn gốc phiên bản cha: - {run.parent_run_id} -
- v{run.version - 1} → v{run.version} -
- {/if} - - -
-
- Mã Tenant - {run.tenant_id} -
-
- Tham chiếu Lead - {run.subject_ref} -
-
- Phiên bản cấu hình - {run.scoring_config_version} -
-
- Số hiệu phiên bản - v{run.version} -
- {#if duration} -
- Tổng thời gian thực thi - {duration} -
- {/if} -
- - -
-
- - Dấu thời gian vòng đời thực thi -
- -
-
- -
- Khởi tạo - {run.created_at} -
-
- - {#if run.started_at} -
-
- -
- Đang xử lý - {run.started_at} -
-
- {/if} - - {#if run.completed_at} -
-
- -
- Hoàn thành - {run.completed_at} -
-
- {/if} -
-
- - -
-
- - - {#if showRawJson} - - {/if} -
- - {#if showRawJson} -
{JSON.stringify(run, null, 2)}
- {/if} -
- {/if} -
- - diff --git a/old_repo/apps/dev-console/src/lib/components/RunStatusBadge.svelte b/old_repo/apps/dev-console/src/lib/components/RunStatusBadge.svelte deleted file mode 100644 index eed3bba..0000000 --- a/old_repo/apps/dev-console/src/lib/components/RunStatusBadge.svelte +++ /dev/null @@ -1,134 +0,0 @@ - - -
- - - {badge.label} - - - {#if degraded} - - DỰ PHÒNG - - {/if} -
- - diff --git a/old_repo/apps/dev-console/src/lib/components/ScoreBreakdown.svelte b/old_repo/apps/dev-console/src/lib/components/ScoreBreakdown.svelte deleted file mode 100644 index 5a4406c..0000000 --- a/old_repo/apps/dev-console/src/lib/components/ScoreBreakdown.svelte +++ /dev/null @@ -1,587 +0,0 @@ - - -
- -
-
-
- -
-
-

Phân tích điểm chi tiết

- Chỉ số đánh giá từ bộ quy tắc xác định và mô hình AIHub -
-
- -
- {#if run.scoring_mode} - - - {run.scoring_mode.toUpperCase()} - - {/if} - {#if run.version > 1} - - PHIÊN BẢN v{run.version} - - {/if} -
-
- - -
- -
- - {#if run.effective_score !== null && run.final_score !== null && run.effective_score !== run.final_score} -
- - Đã hiệu chỉnh từ {run.final_score} -
- {/if} -
- - -
- -
-
-
- - Điểm quy tắc xác định -
- {ruleScore !== null ? `${ruleScore}/100` : '-'} -
-
-
-
- -
- - -
-
-
- - Điểm đánh giá mô hình AI -
- {aiScore !== null ? `${aiScore}/100` : '-'} -
-
-
-
- -
- - -
-
-
- - Độ tin cậy mô hình & Ngưỡng kiểm soát -
-
- {confidencePercent !== null ? `${confidencePercent}%` : '-'} - {#if thresholdPercent !== null} - Yêu cầu: {thresholdPercent}% - {/if} -
-
- -
- -
- - {#if thresholdPercent !== null} -
- {/if} -
- - -
-
-
- - - {#if run.error_code} -
-
- -
- Sự cố thực thi: {run.error_code} -

Tiến trình xử lý bị gián đoạn hoặc gặp lỗi từ máy chủ upstream.

-
-
- {#if run.retryable !== null} - - {run.retryable ? 'TỰ ĐỘNG THỬ LẠI' : 'KHÔNG THỬ LẠI'} - - {/if} -
- {/if} - - - {#if run.ai_metadata} -
-
- - - {#if showMetadata} - - {/if} -
- - {#if showMetadata} -
{JSON.stringify(run.ai_metadata, null, 2)}
- {/if} -
- {/if} -
- - diff --git a/old_repo/apps/dev-console/src/lib/components/ScoreMeter.svelte b/old_repo/apps/dev-console/src/lib/components/ScoreMeter.svelte deleted file mode 100644 index 067b46d..0000000 --- a/old_repo/apps/dev-console/src/lib/components/ScoreMeter.svelte +++ /dev/null @@ -1,195 +0,0 @@ - - -
- - - - - - - - - - - - - - - - {#if score !== null} - - {/if} - - - -
- {label} -
- {score !== null ? validScore : '-'} - /100 -
- - {scoreTier.name} - -
-
- - diff --git a/old_repo/apps/dev-console/src/lib/fixtures/golden.ts b/old_repo/apps/dev-console/src/lib/fixtures/golden.ts deleted file mode 100644 index be133e5..0000000 --- a/old_repo/apps/dev-console/src/lib/fixtures/golden.ts +++ /dev/null @@ -1,238 +0,0 @@ -import type { GoldenFixture } from '../api/types'; - -export const GOLDEN_FIXTURES: GoldenFixture[] = [ - { - id: 'happy-path-medium-ai', - name: '1. Luồng chuẩn (AI 2 bước hoàn chỉnh)', - category: 'Lớp học IELTS cấp tốc', - coverImage: '/images/lead-ielts-student.jpg', - description: 'Hồ sơ lead đầy đủ kèm lịch sử trao đổi chi tiết. Kỳ vọng đạt trạng thái completed với điểm kết hợp Quy tắc và AI.', - expectedStatus: 'completed', - expectedMode: 'medium_ai', - payload: { - tenant_id: 'omni-test-tenant-42', - subject_ref: 'ems-lead-dev-1001', - scoring_config_version: '1.0.0', - snapshot: { - version: '1.0.0', - scope: 'linked_contact_history', - captured_at: '2026-08-28T12:00:00Z', - lead_profile: { - ems_lead_id: 'ems-lead-dev-1001', - name: 'Nguyen Van An', - phone: '+84901234567', - email: 'an.nguyen@example.com', - status: 'new', - }, - conversations: [ - { - conversation_id: 'conv-zalo-101', - channel: 'zalo', - messages: [ - { - message_id: 'msg-01', - sender_type: 'contact', - sent_at: '2026-08-28T11:01:00Z', - content: 'Xin chao, toi muon tim hieu khoa hoc IELTS cap toc.', - }, - { - message_id: 'msg-02', - sender_type: 'agent', - sent_at: '2026-08-28T11:02:00Z', - content: 'Chao anh An, ben em co lop IELTS Intensive target 7.0+ khai giang tuan sau.', - }, - { - message_id: 'msg-03', - sender_type: 'contact', - sent_at: '2026-08-28T11:03:00Z', - content: 'Hoc phi bao nhieu va hoc online hay offline vay ban?', - }, - ], - }, - ], - hitl_policy: { - policy_version: '1.0.0', - confidence_threshold: 0.8, - }, - }, - }, - }, - { - id: 'review-required-low-confidence', - name: '2. Cần duyệt thủ công (Hội thoại ngắn)', - category: 'Tư vấn tuyển sinh', - coverImage: '/images/lead-counseling-review.jpg', - description: 'Lịch sử tin nhắn ngắn qua mạng xã hội. Kỳ vọng độ tin cậy thấp, kích hoạt quy trình người duyệt xếp lớp.', - expectedStatus: 'completed', - expectedMode: 'medium_ai', - payload: { - tenant_id: 'omni-test-tenant-42', - subject_ref: 'ems-lead-dev-1002', - scoring_config_version: '1.0.0', - snapshot: { - version: '1.0.0', - scope: 'linked_contact_history', - captured_at: '2026-08-28T12:05:00Z', - lead_profile: { - ems_lead_id: 'ems-lead-dev-1002', - name: 'Tran Thi B', - phone: '+84912345678', - email: null, - status: 'contacted', - }, - conversations: [ - { - conversation_id: 'conv-fb-201', - channel: 'facebook', - messages: [ - { - message_id: 'msg-04', - sender_type: 'contact', - sent_at: '2026-08-28T12:01:00Z', - content: 'Hi', - }, - ], - }, - ], - hitl_policy: { - policy_version: '1.0.0', - confidence_threshold: 0.8, - }, - }, - }, - }, - { - id: 'degraded-rules-fallback', - name: '3. Chế độ dự phòng (Chỉ tính điểm quy tắc)', - category: 'Đào tạo doanh nghiệp', - coverImage: '/images/lead-enterprise-training.jpg', - description: 'Mô phỏng trường hợp AIHub gặp sự cố, hệ thống tự động bảo toàn điểm quy tắc và kích hoạt cờ degraded.', - expectedStatus: 'completed', - expectedMode: 'rules_only_fallback', - payload: { - tenant_id: 'omni-test-tenant-42', - subject_ref: 'ems-lead-dev-1003', - scoring_config_version: '1.0.0', - snapshot: { - version: '1.0.0', - scope: 'linked_contact_history', - captured_at: '2026-08-28T12:10:00Z', - lead_profile: { - ems_lead_id: 'ems-lead-dev-1003', - name: 'Le Van Cuong', - phone: '+84987654321', - email: 'cuong.le@example.com', - status: 'in_progress', - }, - conversations: [ - { - conversation_id: 'conv-web-301', - channel: 'website', - messages: [ - { - message_id: 'msg-05', - sender_type: 'contact', - sent_at: '2026-08-28T12:09:00Z', - content: 'Toi can tu van goi doanh nghiep.', - }, - ], - }, - ], - hitl_policy: { - policy_version: '1.0.0', - confidence_threshold: 0.8, - }, - }, - }, - }, - { - id: 'aihub-timeout', - name: '4. AIHub timeout', - category: 'Mô phỏng lỗi hạ tầng', - coverImage: '/images/lead-system-fault.jpg', - description: 'Fixture kiểm thử policy khi request AIHub vượt quá timeout đã cấu hình.', - expectedStatus: 'failed', - payload: { - tenant_id: 'omni-test-tenant-42', - subject_ref: 'ems-lead-dev-1004', - scoring_config_version: '1.0.0', - snapshot: { - version: '1.0.0', - scope: 'linked_contact_history', - captured_at: '2026-08-28T12:15:00Z', - lead_profile: { - ems_lead_id: 'ems-lead-dev-1004', - name: 'Pham D', - phone: null, - email: null, - status: 'spam', - }, - conversations: [], - hitl_policy: { - policy_version: '1.0.0', - confidence_threshold: 0.8, - }, - }, - }, - }, - { - id: 'aihub-quota-failure', - name: '5. AIHub quota failure', - category: 'Mô phỏng lỗi hạ tầng', - coverImage: '/images/lead-system-fault.jpg', - description: 'Fixture kiểm thử policy khi AIHub trả lỗi hết quota hoặc rate limit.', - expectedStatus: 'failed', - payload: { - tenant_id: 'omni-test-tenant-42', - subject_ref: 'ems-lead-dev-1005', - scoring_config_version: '1.0.0', - snapshot: { - version: '1.0.0', - scope: 'linked_contact_history', - captured_at: '2026-08-28T12:20:00Z', - lead_profile: { - ems_lead_id: 'ems-lead-dev-1005', - name: 'Quota Failure Fixture', - phone: null, - email: null, - status: 'open', - }, - conversations: [], - hitl_policy: { - policy_version: '1.0.0', - confidence_threshold: 0.8, - }, - }, - }, - }, - { - id: 'aihub-malformed-output', - name: '6. AIHub malformed output', - category: 'Mô phỏng lỗi contract', - coverImage: '/images/lead-system-fault.jpg', - description: 'Fixture kiểm thử khi AIHub trả payload không khớp bounded output contract.', - expectedStatus: 'failed', - payload: { - tenant_id: 'omni-test-tenant-42', - subject_ref: 'ems-lead-dev-1006', - scoring_config_version: '1.0.0', - snapshot: { - version: '1.0.0', - scope: 'linked_contact_history', - captured_at: '2026-08-28T12:25:00Z', - lead_profile: { - ems_lead_id: 'ems-lead-dev-1006', - name: 'Malformed Output Fixture', - phone: null, - email: null, - status: 'open', - }, - conversations: [], - hitl_policy: { - policy_version: '1.0.0', - confidence_threshold: 0.8, - }, - }, - }, - }, -]; diff --git a/old_repo/apps/dev-console/src/lib/state/consoleState.svelte.ts b/old_repo/apps/dev-console/src/lib/state/consoleState.svelte.ts deleted file mode 100644 index 0f34dc3..0000000 --- a/old_repo/apps/dev-console/src/lib/state/consoleState.svelte.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { - DEFAULT_CONFIG, - admitScoringRun, - createApiClient, - fetchExecutionTrace, - fetchScoringRun, - listScoringConfigs, - listScoringRuns, - pollUntilTerminal, - saveScoringConfig, -} from '../api/client'; -import type { - CreateScoringRunRequest, - DevConsoleConfig, - GoldenFixture, - ScoringConfig, - ScoringRunResponse, -} from '../api/types'; -import { GOLDEN_FIXTURES } from '../fixtures/golden'; - -export class DevConsoleState { - config = $state({ ...DEFAULT_CONFIG }); - selectedFixture = $state(GOLDEN_FIXTURES[0]); - customPayloadJson = $state(JSON.stringify(GOLDEN_FIXTURES[0].payload, null, 2)); - scoringConfigs = $state([]); - selectedScoringConfig = $state(null); - executionTrace = $state | null>(null); - - // Current active run state - currentRunId = $state(null); - currentRun = $state(null); - isLoading = $state(false); - isPolling = $state(false); - errorMessage = $state(null); - statusMessage = $state('Sẵn sàng'); - - recentRuns = $state([]); - totalRunCount = $state(0); - isLoadingRuns = $state(false); - readonly runPageSize = 30; - currentRunPage = $state(1); - hasNextRunPage = $state(false); - - async loadRecentRuns(page: number = this.currentRunPage) { - this.isLoadingRuns = true; - try { - const offset = (page - 1) * this.runPageSize; - const result = await listScoringRuns( - createApiClient(), - this.config.tenantId, - this.runPageSize + 1, - offset, - ); - this.currentRunPage = page; - this.totalRunCount = result.total; - this.hasNextRunPage = result.items.length > this.runPageSize; - this.recentRuns = result.items.slice(0, this.runPageSize); - } finally { - this.isLoadingRuns = false; - } - } - - async nextRunPage() { - if (!this.hasNextRunPage || this.isLoadingRuns) return; - await this.loadRecentRuns(this.currentRunPage + 1); - } - - async previousRunPage() { - if (this.currentRunPage <= 1 || this.isLoadingRuns) return; - await this.loadRecentRuns(this.currentRunPage - 1); - } - - async selectRunById(runId: string, tenantId: string = this.config.tenantId) { - this.currentRunId = runId; - this.isLoading = true; - this.errorMessage = null; - try { - const client = createApiClient(); - const res = await fetchScoringRun(client, runId, tenantId); - if (res.data) { - this.currentRun = res.data; - this.statusMessage = `Đã chọn phiên ${runId} (${res.data.status})`; - try { - this.executionTrace = await fetchExecutionTrace(client, runId, tenantId); - } catch { - this.executionTrace = null; - } - } - } catch (err) { - this.errorMessage = err instanceof Error ? err.message : String(err); - } finally { - this.isLoading = false; - } - } - - async loadScoringConfigs() { - const configs = await listScoringConfigs(createApiClient(), this.config); - this.scoringConfigs = configs; - this.selectedScoringConfig ??= configs[0] ?? null; - } - - async saveScoringConfig(scoringConfig: ScoringConfig) { - const saved = await saveScoringConfig(createApiClient(), scoringConfig, this.config); - this.scoringConfigs = [...this.scoringConfigs, saved]; - this.selectedScoringConfig = saved; - return saved; - } - - selectFixture(fixture: GoldenFixture) { - this.selectedFixture = fixture; - this.customPayloadJson = JSON.stringify(fixture.payload, null, 2); - this.errorMessage = null; - } - - async triggerRun() { - this.isLoading = true; - this.errorMessage = null; - this.statusMessage = 'Đang gửi yêu cầu tiếp nhận đánh giá lead...'; - - try { - let parsedPayload: CreateScoringRunRequest; - try { - parsedPayload = JSON.parse(this.customPayloadJson); - } catch (err) { - throw new Error(`Dữ liệu JSON payload không hợp lệ: ${String(err)}`); - } - - const client = createApiClient(); - const res = await admitScoringRun(client, parsedPayload, {}, this.config); - - if (!res.data || res.status !== 202) { - throw new Error( - `Yêu cầu bị từ chối (HTTP ${res.status}): ${JSON.stringify(res.error ?? 'Lỗi không xác định')}`, - ); - } - - const runId = res.data.run_id; - this.currentRunId = runId; - this.statusMessage = `Đã tiếp nhận phiên ${runId} (trạng thái: ${res.data.status}). Đang theo dõi tiến trình...`; - - // Start polling - this.isPolling = true; - const finalRun = await pollUntilTerminal(client, runId, { - tenantId: parsedPayload.tenant_id, - intervalMs: this.config.pollIntervalMs, - onTick: (run) => { - this.currentRun = run; - this.statusMessage = `Trạng thái phiên: ${run.status}...`; - }, - }); - - this.currentRun = finalRun; - this.statusMessage = `Phiên đánh giá kết thúc với trạng thái: ${finalRun.status}`; - this.totalRunCount += 1; - if (this.currentRunPage === 1) { - const pageWasFull = this.recentRuns.length === this.runPageSize; - this.recentRuns = [finalRun, ...this.recentRuns.filter((r) => r.run_id !== finalRun.run_id)].slice( - 0, - this.runPageSize, - ); - this.hasNextRunPage = this.hasNextRunPage || pageWasFull; - } - try { - this.executionTrace = await fetchExecutionTrace(client, runId, parsedPayload.tenant_id); - } catch { - this.executionTrace = null; - } - } catch (err) { - this.errorMessage = err instanceof Error ? err.message : String(err); - this.statusMessage = 'Thực thi thất bại'; - } finally { - this.isLoading = false; - this.isPolling = false; - } - } - - async refreshRun() { - if (!this.currentRunId) return; - this.isLoading = true; - this.errorMessage = null; - - try { - const client = createApiClient(); - const tenantId = this.currentRun?.tenant_id ?? this.config.tenantId; - const res = await fetchScoringRun(client, this.currentRunId, tenantId); - if (res.data) { - this.currentRun = res.data; - this.statusMessage = `Đã làm mới phiên ${this.currentRunId} (phiên bản: v${res.data.version})`; - } else { - throw new Error(`Lỗi tải dữ liệu: ${JSON.stringify(res.error)}`); - } - } catch (err) { - this.errorMessage = err instanceof Error ? err.message : String(err); - } finally { - this.isLoading = false; - } - } - -} - -export const consoleState = new DevConsoleState(); diff --git a/old_repo/apps/dev-console/src/main.ts b/old_repo/apps/dev-console/src/main.ts deleted file mode 100644 index 0624d12..0000000 --- a/old_repo/apps/dev-console/src/main.ts +++ /dev/null @@ -1,14 +0,0 @@ -import './app.css'; -import { mount } from 'svelte'; -import App from './App.svelte'; - -const target = document.getElementById('app'); -if (!target) { - throw new Error('Root #app container not found in DOM'); -} - -const app = mount(App, { - target, -}); - -export default app; diff --git a/old_repo/apps/dev-console/src/tests/client.test.ts b/old_repo/apps/dev-console/src/tests/client.test.ts deleted file mode 100644 index a806466..0000000 --- a/old_repo/apps/dev-console/src/tests/client.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { - DEFAULT_CONFIG, - admitScoringRun, - fetchScoringRun, - listScoringRuns, - pollUntilTerminal, -} from '../lib/api/client'; -import type { CreateScoringRunRequest, ScoringRunResponse } from '../lib/api/types'; -import { GOLDEN_FIXTURES } from '../lib/fixtures/golden'; - -describe('Dev Console API Client', () => { - it('sends only non-secret headers during run admission', async () => { - const mockPost = vi.fn().mockResolvedValue({ - data: { - run_id: 'test-run-123', - tenant_id: 'omni-test-tenant-42', - subject_ref: 'ems-lead-1', - scoring_config_version: '1.0.0', - status: 'pending', - status_url: '/v1/scoring-runs/test-run-123', - created_at: '2026-08-28T12:00:00Z', - }, - error: undefined, - response: { status: 202 }, - }); - - const mockClient = { - POST: mockPost, - GET: vi.fn(), - } as any; - - const payload: CreateScoringRunRequest = { - ...GOLDEN_FIXTURES[0].payload, - rule_overrides: [ - { - id: 'demo-rule-1', - name: 'Demo budget signal', - target: 'custom_field', - custom_field_key: 'budget', - operator: 'gte', - value: 5000000, - points: 15, - enabled: true, - }, - ], - }; - const res = await admitScoringRun( - mockClient, - payload, - { - idempotencyKey: 'idem-123', - dotbApp: 'dev-console', - dotbFeature: 'fixture-test', - }, - DEFAULT_CONFIG, - ); - - expect(res.status).toBe(202); - expect(res.data?.run_id).toBe('test-run-123'); - expect(mockPost).toHaveBeenCalledWith('/v1/scoring-runs', { - params: { - header: { - 'X-Tenant-VKey': '', - 'Idempotency-Key': 'idem-123', - 'X-DotB-App': 'dev-console', - 'X-DotB-Feature': 'fixture-test', - }, - }, - body: payload, - }); - }); - - it('passes pagination parameters and parses total count when listing scoring runs', async () => { - const mockHeaders = new Headers({ - 'x-total-count': '35', - }); - const mockRuns = [{ run_id: 'run-1' }, { run_id: 'run-2' }] as ScoringRunResponse[]; - const mockGet = vi.fn().mockResolvedValue({ - data: mockRuns, - error: undefined, - response: { status: 200, headers: mockHeaders }, - }); - const mockClient = { GET: mockGet } as any; - - const result = await listScoringRuns(mockClient, 'tenant-1', 31, 60); - - expect(mockGet).toHaveBeenCalledWith('/v1/scoring-runs', { - params: { - header: { 'X-Tenant-Id': 'tenant-1' }, - query: { limit: 31, offset: 60 }, - }, - }); - expect(result.items).toEqual(mockRuns); - expect(result.total).toBe(35); - }); - - it('falls back to items length if X-Total-Count header is missing', async () => { - const mockRuns = [{ run_id: 'run-1' }] as ScoringRunResponse[]; - const mockGet = vi.fn().mockResolvedValue({ - data: mockRuns, - error: undefined, - response: { status: 200, headers: new Headers() }, - }); - const mockClient = { GET: mockGet } as any; - - const result = await listScoringRuns(mockClient, 'tenant-1'); - expect(result.items).toEqual(mockRuns); - expect(result.total).toBe(1); - }); - - it('polls until reaching terminal status', async () => { - let callCount = 0; - const mockGet = vi.fn().mockImplementation(() => { - callCount++; - const status = callCount === 1 ? 'pending' : callCount === 2 ? 'running' : 'completed'; - return Promise.resolve({ - data: { - run_id: 'poll-run-1', - tenant_id: 'tenant-1', - subject_ref: 'lead-1', - scoring_config_version: '1.0.0', - parent_run_id: null, - status, - rule_score: 80, - ai_score: 90, - final_score: 88, - effective_score: 88, - scoring_mode: 'medium_ai', - degraded: false, - ai_metadata: null, - confidence: 0.9, - confidence_threshold: 0.8, - effective_config: null, - error_code: null, - retryable: null, - version: 1, - started_at: '2026-08-28T12:00:00Z', - completed_at: '2026-08-28T12:00:05Z', - created_at: '2026-08-28T12:00:00Z', - updated_at: '2026-08-28T12:00:05Z', - } as ScoringRunResponse, - error: undefined, - response: { status: 200 }, - }); - }); - - const mockClient = { - GET: mockGet, - POST: vi.fn(), - } as any; - - const ticks: string[] = []; - const finalRun = await pollUntilTerminal(mockClient, 'poll-run-1', { - intervalMs: 5, - onTick: (run) => ticks.push(run.status), - }); - - expect(finalRun.status).toBe('completed'); - expect(callCount).toBe(3); - expect(ticks).toEqual(['pending', 'running', 'completed']); - }); -}); diff --git a/old_repo/apps/dev-console/src/tests/fixtures.test.ts b/old_repo/apps/dev-console/src/tests/fixtures.test.ts deleted file mode 100644 index 8abb416..0000000 --- a/old_repo/apps/dev-console/src/tests/fixtures.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { GOLDEN_FIXTURES } from '../lib/fixtures/golden'; - -describe('Golden Fixtures Catalog', () => { - it('contains all required pilot test scenarios', () => { - const fixtureIds = GOLDEN_FIXTURES.map((f) => f.id); - expect(fixtureIds).toContain('happy-path-medium-ai'); - expect(fixtureIds).toContain('review-required-low-confidence'); - expect(fixtureIds).toContain('degraded-rules-fallback'); - expect(fixtureIds).toContain('aihub-timeout'); - expect(fixtureIds).toContain('aihub-quota-failure'); - expect(fixtureIds).toContain('aihub-malformed-output'); - }); - - it('ensures each fixture has a valid CanonicalSnapshot structure', () => { - for (const fixture of GOLDEN_FIXTURES) { - expect(fixture.name).toBeTruthy(); - expect(fixture.expectedStatus).toBeTruthy(); - expect(fixture.payload.tenant_id).toBeTruthy(); - expect(fixture.payload.scoring_config_version).toBe('1.0.0'); - expect(fixture.payload.snapshot.version).toBe('1.0.0'); - expect(fixture.payload.snapshot.captured_at).toBeTruthy(); - expect(Array.isArray(fixture.payload.snapshot.conversations)).toBe(true); - expect(fixture.payload.snapshot.hitl_policy?.confidence_threshold).toBe(0.8); - } - }); -}); diff --git a/old_repo/apps/dev-console/src/tests/state.test.ts b/old_repo/apps/dev-console/src/tests/state.test.ts deleted file mode 100644 index d01ee10..0000000 --- a/old_repo/apps/dev-console/src/tests/state.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import * as clientModule from '../lib/api/client'; -import { GOLDEN_FIXTURES } from '../lib/fixtures/golden'; -import { DevConsoleState } from '../lib/state/consoleState.svelte'; - -describe('DevConsoleState Store', () => { - it('initializes with default fixture and configuration', () => { - const state = new DevConsoleState(); - expect(state.config.tenantId).toBe('omni-account-42'); - expect(state.selectedFixture.id).toBe(GOLDEN_FIXTURES[0].id); - expect(state.currentRun).toBeNull(); - }); - - it('updates custom payload JSON when fixture is selected', () => { - const state = new DevConsoleState(); - const secondFixture = GOLDEN_FIXTURES[1]; - state.selectFixture(secondFixture); - - expect(state.selectedFixture.id).toBe(secondFixture.id); - const parsed = JSON.parse(state.customPayloadJson); - expect(parsed.subject_ref).toBe(secondFixture.payload.subject_ref); - }); - - it('triggers run and records the terminal state', async () => { - const state = new DevConsoleState(); - - vi.spyOn(clientModule, 'admitScoringRun').mockResolvedValue({ - data: { - run_id: 'run-999', - tenant_id: 'tenant-1', - subject_ref: 'lead-1', - scoring_config_version: '1.0.0', - status: 'pending', - status_url: '/v1/scoring-runs/run-999', - created_at: '2026-08-28T12:00:00Z', - }, - status: 202, - }); - - vi.spyOn(clientModule, 'pollUntilTerminal').mockResolvedValue({ - run_id: 'run-999', - tenant_id: 'tenant-1', - subject_ref: 'lead-1', - scoring_config_version: '1.0.0', - parent_run_id: null, - status: 'completed', - rule_score: 80, - ai_score: 90, - final_score: 86, - effective_score: 86, - scoring_mode: 'medium_ai', - degraded: false, - ai_metadata: null, - confidence: 0.9, - confidence_threshold: 0.8, - effective_config: null, - error_code: null, - retryable: null, - version: 1, - started_at: '2026-08-28T12:00:00Z', - completed_at: '2026-08-28T12:00:05Z', - created_at: '2026-08-28T12:00:00Z', - updated_at: '2026-08-28T12:00:05Z', - }); - - await state.triggerRun(); - - expect(state.currentRunId).toBe('run-999'); - expect(state.currentRun?.status).toBe('completed'); - expect(state.totalRunCount).toBe(1); - }); - - it('loads paginated run history, updates totalRunCount, and navigates between pages', async () => { - const runs = Array.from({ length: 31 }, (_, index) => ({ - run_id: `run-${index}`, - tenant_id: 'tenant-1', - subject_ref: `lead-${index}`, - scoring_config_version: '1.0.0', - parent_run_id: null, - status: 'completed', - rule_score: 80, - ai_score: 90, - final_score: 86, - effective_score: 86, - scoring_mode: 'medium_ai', - degraded: false, - ai_metadata: null, - confidence: 0.9, - confidence_threshold: 0.8, - effective_config: null, - error_code: null, - retryable: null, - version: 1, - started_at: '2026-08-28T12:00:00Z', - completed_at: '2026-08-28T12:00:05Z', - created_at: '2026-08-28T12:00:00Z', - updated_at: '2026-08-28T12:00:05Z', - })) as any; - const listSpy = vi.spyOn(clientModule, 'listScoringRuns').mockImplementation(async (_client, _tenant, limit, offset) => { - expect(limit).toBe(31); - return { - items: offset === 0 ? runs : runs.slice(30, 31), - total: 35, - }; - }); - const state = new DevConsoleState(); - - await state.loadRecentRuns(); - expect(state.recentRuns).toHaveLength(30); - expect(state.totalRunCount).toBe(35); - expect(state.hasNextRunPage).toBe(true); - await state.nextRunPage(); - expect(state.currentRunPage).toBe(2); - expect(state.recentRuns).toHaveLength(1); - expect(state.totalRunCount).toBe(35); - expect(state.hasNextRunPage).toBe(false); - await state.previousRunPage(); - expect(state.currentRunPage).toBe(1); - expect(state.totalRunCount).toBe(35); - expect(listSpy).toHaveBeenCalledTimes(3); - }); - -}); diff --git a/old_repo/apps/dev-console/svelte.config.js b/old_repo/apps/dev-console/svelte.config.js deleted file mode 100644 index 4c6b24b..0000000 --- a/old_repo/apps/dev-console/svelte.config.js +++ /dev/null @@ -1,5 +0,0 @@ -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; - -export default { - preprocess: vitePreprocess(), -}; diff --git a/old_repo/apps/dev-console/tsconfig.json b/old_repo/apps/dev-console/tsconfig.json deleted file mode 100644 index 7b4c699..0000000 --- a/old_repo/apps/dev-console/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "useDefineForClassFields": true, - "module": "ESNext", - "resolveJsonModule": true, - "allowJs": true, - "checkJs": true, - "isolatedModules": true, - "moduleResolution": "bundler", - "strict": true, - "noEmit": true, - "skipLibCheck": true, - "paths": { - "$lib": ["./src/lib"], - "$lib/*": ["./src/lib/*"] - } - }, - "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] -} diff --git a/old_repo/apps/dev-console/vite.config.ts b/old_repo/apps/dev-console/vite.config.ts deleted file mode 100644 index 0522fd1..0000000 --- a/old_repo/apps/dev-console/vite.config.ts +++ /dev/null @@ -1,56 +0,0 @@ -import path from 'node:path'; -import { svelte } from '@sveltejs/vite-plugin-svelte'; -import { defineConfig, loadEnv, type ProxyOptions } from 'vite'; - -export default defineConfig(({ command, mode }) => { - const env = loadEnv(mode, process.cwd(), 'DEV_CONSOLE_'); - const target = env.DEV_CONSOLE_API_URL || 'http://localhost:8000'; - const serviceToken = env.DEV_CONSOLE_SERVICE_TOKEN; - const testVKey = env.DEV_CONSOLE_TEST_VKEY; - const port = Number(env.DEV_CONSOLE_PORT) || 5173; - const previewPort = Number(env.DEV_CONSOLE_PREVIEW_PORT) || 4173; - - const missingCredentials = command === 'serve' && (!serviceToken || !testVKey); - const proxy: Record = { - '/dev-console-api': { - target, - changeOrigin: true, - rewrite: (requestPath) => requestPath.replace(/^\/dev-console-api/, ''), - configure: (proxyServer) => { - proxyServer.on('proxyReq', (proxyRequest, request) => { - if (missingCredentials || !serviceToken || !testVKey) { - proxyRequest.destroy( - new Error('Dev Console proxy credentials are not configured on the server.'), - ); - return; - } - proxyRequest.setHeader('Authorization', `Bearer ${serviceToken}`); - const requestPath = request.url?.split('?', 1)[0] ?? ''; - if ( - request.method === 'POST' && - /\/v1\/scoring-runs(?:\/|$)/.test(requestPath) - ) { - proxyRequest.setHeader('X-Tenant-VKey', testVKey); - } - }); - }, - }, - }; - - return { - plugins: [svelte()], - resolve: { - alias: { - $lib: path.resolve(__dirname, './src/lib'), - }, - }, - server: { - port, - proxy, - }, - preview: { - port: previewPort, - proxy, - }, - }; -}); diff --git a/old_repo/compose.yaml b/old_repo/compose.yaml deleted file mode 100644 index f3f0153..0000000 --- a/old_repo/compose.yaml +++ /dev/null @@ -1,14 +0,0 @@ -services: - postgres: - image: postgres:16-alpine - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: lead_scoring - ports: - - "${POSTGRES_PORT:-5433}:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d lead_scoring"] - interval: 5s - timeout: 5s - retries: 10 diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/SHA256SUMS b/old_repo/contracts/lead-scoring-contract-pack/v1/SHA256SUMS deleted file mode 100644 index b995fa8..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/SHA256SUMS +++ /dev/null @@ -1,72 +0,0 @@ -793c0f914c810bacd6c56cc0c94894d6f80230833080bbe6b376d5ce23bf8ed5 compatibility.json -63a84bbc9afd6850787c16d8e9231f7810a805b677e9ee928a4ed353183a03fe contract.json -6c7fe40f9c43209d1e4d54e0bc294bf29b1ceb5bf1fee26ee36cf383564a6e8e error-taxonomy.json -5e4dee334c736f404ab3e8cf9cbadf4dfaa06111a3cd9b194463efdd0d5ff8cd fixture-index.json -e83645e1d875320a2845d823d71f43542689561de2abf3d5f8cc50df62a53300 fixtures/admission-request.json -09e599fc89b99b6538a1f505cbdd0c71c89bc0a4cf71b8f80f9069a1d28f7134 fixtures/admission-response.json -84de8353436a8135c1ad963e00fe666b457dc9bba1861e5043e1a61cff7ea910 fixtures/error-validation.json -48963cc3ee55960fc60c6ec71da773af4afc49af9b9b46f8cb3fa947e2ef710f fixtures/lineage-child.json -54bdb599cd84f30965f85fe4089ac4839b120d86d1755c3d9052cfd3b0884cf2 fixtures/lineage-root.json -f41fec32608f130e5780e9c6594df3d12e617eca82677d20c6d410055866675c fixtures/marker/deleted-block-followed-by-append.json -8340b28688c6037f673f20a072555c67468aa2e070922982c11ae49c91c26c9e fixtures/marker/empty-description-append.json -1914b2fdb1e0cff6e27724c94351bfbc9975020307b2009ab56242970bbaf026 fixtures/marker/failed-run-no-render.json -10158b934279d4e415913d06cc3418b802007f0db970f6043edb53a6b0558b3a fixtures/marker/multiple-complete-blocks.json -a5774525903f73c49d88f23ca86d8eb298de6e2fbf67db30ad523c7b066212f8 fixtures/marker/nested-block.json -452eb1f3806bb8d98a3e7c3a246ecca5e0b4f8cf7566e7afc7742e6431af0b04 fixtures/marker/orphan-closing.json -a7ad66c859fe2076a9418b894273130aa7e0221f34de17387f2d640a18f7e951 fixtures/marker/orphan-opening.json -5cab508bc921a4fac2208b9575798108730e39ae5f4adbca94111ffd8a08c18c fixtures/marker/overlapping-block.json -e5b9d9ca88103a08a888aa22941c2c27f1fad31300a0a4dbd00ab4003c1446a7 fixtures/marker/plain-description-append.json -63a1665bdd49eb1e8fe3513480d98a73bd990c2df56a59c2ca21a6d3719d16ab fixtures/marker/single-valid-block-replace.json -dddf775f42e1689a40d3d4b94adc00ed22153f0bb4819c5ca7d63b988ecbfe6f fixtures/marker/unicode-multiline-preservation.json -a2deb1c0ccd2cd81f940a30d70945a9d37be721606324025ed2c01ac5062afb5 fixtures/negative/admission-subject-mismatch.json -073565a826bc5d6d43159bcd0a4d553d7efe2ccc2203a5b09a053e143f09e9cb fixtures/negative/event-missing-version.json -c3b97e49b3a4e26aaef82f5c6eddb4927b92cd85f253ed89cff20aa209e6024d fixtures/negative/review-override-out-of-range.json -c6093699d1997dc9d349ba3afded1b9ba9fa68d0fe1bb7c32f9e7dcd49940c2b fixtures/negative/snapshot-future-message.json -480aa257f0813debafdbf37a800385a2cc8e74c2e553ac01ab5f8abd70fd227e fixtures/polling/completed.json -98c4c933f593920a822cb22c8b50023a247deb34402adbce14a27f4c6c3be1cc fixtures/polling/failed.json -6dd6788d7e95d0ec797dd6f890c31a9c3e293e5c71b302a4076a7fbc85e26a3d fixtures/polling/pending.json -5f8238d989c886eead741ac07341f763f2cb7cac6fe3000c960fbc776020f78f fixtures/polling/running.json -419b8b71c5424419fbb39bdbdaf5f8d3bef3405e841dfcff19f9670405867f89 fixtures/review-approve-request.json -1389e2affd2b927b4437491b24c2fb0bc0e48214b0a9c722e2eee830e802fca8 fixtures/review-override-request.json -2a6934fc8f56694f70e440cfb7ca450c428353fde6ecc0bf919d15487ca86f30 fixtures/review-re_evaluate-request.json -2019d35cbd2a1a583a78be5c9e00ddcf21db65ad9bab06835c24c4870b4de952 fixtures/review-response.json -604335dd696d3f9c802c55bfa2a195857a839d8169f5060a2541f166b2245fcb fixtures/scoring-config-selection.json -b45fc82c1bbf1eea453228ba62f58a2de1509d7493ba902a162a8ca10f6f3d25 fixtures/scoring-config.json -d25239c09bd12d1156febc43700aa57791910240b89f822154cfcb9876c460a6 fixtures/snapshot.json -c3e3c743733cd937e4ae49a45a111d25d11982b1ad68c7af63b1ee857feac6b7 fixtures/tenant-unavailable.json -bb51676f91a9bde91d391e72154753e6abe55827a25f7d22db15a535f4ec3196 fixtures/webhook/completed.json -990cea2d63e7fcfa3fbe6dd6ba2412c6dd8de5f038b441e985a1e0191d0d4752 fixtures/webhook/review_required.json -b24c4046a0cd00f2f0211e44bee4fdeab10309d8126bcf117d5569d07f27cef4 fixtures/webhook/reviewed_approve.json -1a1bab449d6edb7a857cb032fed676e19a08db62431cedecec5bd25b2180a663 fixtures/webhook/reviewed_override.json -260f6668bf924b75c63d114ca3de0851af2fc7f85e42a1fa1d0d3fd35892a267 fixtures/webhook/reviewed_reevaluate.json -c3e3c743733cd937e4ae49a45a111d25d11982b1ad68c7af63b1ee857feac6b7 fixtures/webhook/tenant-unavailable.json -e81e98039aa66cdb612e7f804c3e4f4c6f3de2d96a903fe6cde8924e02c1a694 fixtures/webhook/worker_interrupted.json -8600e01feecf7f013813b02b2b5d8e39de76cb87b8c6ee135d929733aa683a4a openapi.json -88736cd21430f2c351d8500602ed382e43378d515deba78899fc0e0149ca3233 outcome-taxonomy.json -8558287dedb710f4f34f17a91c1bc71246a3ec29a4bac41359b3ffe862e1799e profiles/polling/contract.json -25e29cb91646ec1aa1216175d47569e6a857d79a5465d843702c0ce084b28288 profiles/polling/error-response.schema.json -adb529959e243d8de718ea74d17836b593a52990317321bddf9c4b7c195cac1b profiles/polling/scoring-run-response.schema.json -8774cac80f857e5bc3d89510f0bda43a5f2e9ac1a23b1318ff9372e52e9bd7da profiles/webhook/contract.json -a15d7e56ffca5b1f1e85a646da2bfee24b601b99bc0d065fa0ce578dd7d242a3 profiles/webhook/lead-scoring-reviewed-event.schema.json -5f64d8ef39c562a989dc54249c8617807c58c827e39a66f801f6eae08afd808b profiles/webhook/lead-scoring-terminal-event.schema.json -2ee23597c2108a7127587bfb1fe9d8969b9ad12e47f190c8ab3dcdb5dc228b0c profiles/webhook/signature-vectors.json -1c481f7e9143f4b76032684aaf1110897d5c1b34d86a8ec5a093f52c4eaea845 profiles/webhook/webhook-ingest-error-response.schema.json -7e23ffb6849f8e5a09770076772cfdbc2bc6fed3be7c4970a21d94f2a9985429 profiles/webhook/webhook-ingest-response.schema.json -385f9a5f3b91f4f173461f3ec7d0f5ee582fcfec0a03bf8aa2b878f5ce41850f schemas/admission-request.schema.json -e61fed87719d6167dfaeb958d6fa823f2c1acd6171e874587b352aef006f554c schemas/admission-response.schema.json -65f15af5c0d504ba98038a6b66dc1fe5726fbf482d81ea33e539b33490871f1e schemas/canonical-snapshot.schema.json -25e29cb91646ec1aa1216175d47569e6a857d79a5465d843702c0ce084b28288 schemas/error-response.schema.json -eb181d8cc5686fb7acfce0e73eb6a8d79b7ac79f67c5423d964a4e47becc7798 schemas/error-taxonomy.schema.json -d2eb05b9daaf72bd3a5f40ee2d3ee2f5d874151362b1e7c9b761d5daaf190ff8 schemas/lineage.schema.json -817d2dad9164444ee11ca1b625637eac2d2420de54bda2645b6788b2393cc675 schemas/outcome-taxonomy.schema.json -9d494587710850e820afa88b311fd79afdfe6e5416a983fa2b4fe9e8afa72563 schemas/polling-response.schema.json -86afdeab368f368c37969f97dfcd8abdb6b7d10652d1f209dd206e9f97dd66af schemas/review-request.schema.json -5b82fde8c08b675a9df649311b6dbe525683a15c95261a3dd3c71350fa0085d4 schemas/review-response.schema.json -a15d7e56ffca5b1f1e85a646da2bfee24b601b99bc0d065fa0ce578dd7d242a3 schemas/reviewed-event.schema.json -dccbdc230b64a24f5ed08c10c6ef9ff6fe85fcc1315bae3f0b0bc9541192cd25 schemas/scoring-config-selection.schema.json -2b45ded736a66ffc18ff6bf68c6d5ca77629613c9746ff6f38bb7687c4d7b23c schemas/scoring-config.schema.json -5f64d8ef39c562a989dc54249c8617807c58c827e39a66f801f6eae08afd808b schemas/terminal-event.schema.json -dcd5f6a13eed8717aad229e9a90b63c41488feee3e33f36e6bb0ec8483b82d6c schemas/webhook-event-envelope.schema.json -1c481f7e9143f4b76032684aaf1110897d5c1b34d86a8ec5a093f52c4eaea845 schemas/webhook-ingest-error-response.schema.json -7e23ffb6849f8e5a09770076772cfdbc2bc6fed3be7c4970a21d94f2a9985429 schemas/webhook-ingest-response.schema.json -44ed40efd0057f703855ef1b3e3b4103abf64b4123153877f886ab3599c42c6a security-profile.json diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/compatibility.json b/old_repo/contracts/lead-scoring-contract-pack/v1/compatibility.json deleted file mode 100644 index 04e850f..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/compatibility.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "api_major_version": "v1", - "consumer": "dotb-omni", - "contract_pack_version": "0.1.0", - "pinning": { - "consumer_check": "Run Omni consumer tests against the pinned fixtures/ directory", - "fixture_corpus": "fixtures/", - "provider_check": "uv run generate-contract-pack --check", - "required_before_implementation": true, - "unit": "contract_pack_version_and_ai_platform_git_commit" - }, - "provider": "ai-platform", - "upgrade": { - "breaking": { - "rule": "Publish a new API major directory and contract pack version; never mutate a pinned pack in place.", - "verification": "Run provider and consumer fixture validation before changing the Omni pin." - }, - "compatible_additive": { - "rule": "Keep the same major version and preserve existing required fields and enum values.", - "verification": "Validate current fixtures with the previous consumer schema and add a fixture for the new optional field." - }, - "sequence": [ - "Generate and review artifacts from Pydantic source of truth.", - "Run provider --check and fixture validation.", - "Publish the immutable pack and record its AI Platform Git commit.", - "Update Omni's pin and run consumer validation against the same corpus.", - "Promote the pin only after sandbox compatibility verification." - ] - } -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/contract.json b/old_repo/contracts/lead-scoring-contract-pack/v1/contract.json deleted file mode 100644 index 58645d7..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/contract.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "api_major_version": "v1", - "artifacts": { - "checksums": "SHA256SUMS", - "compatibility": "compatibility.json", - "error_taxonomy": "error-taxonomy.json", - "fixture_index": "fixture-index.json", - "fixtures_directory": "fixtures/", - "manifest": "contract.json", - "openapi": "openapi.json", - "outcome_taxonomy": "outcome-taxonomy.json", - "schemas_directory": "schemas/", - "security_profile": "security-profile.json" - }, - "contract_pack_version": "0.1.0", - "generator": "uv run generate-contract-pack", - "profiles": { - "admission": { - "request_schema_file": "schemas/admission-request.schema.json", - "response_schema_file": "schemas/admission-response.schema.json", - "version": "1.0.0" - }, - "canonical_snapshot": { - "schema_file": "schemas/canonical-snapshot.schema.json", - "version": "1.0.0" - }, - "errors": { - "outcome_schema_file": "schemas/outcome-taxonomy.schema.json", - "schema_file": "schemas/error-taxonomy.schema.json", - "version": "1.0.0" - }, - "lineage": { - "schema_file": "schemas/lineage.schema.json", - "version": "1.0.0" - }, - "polling": { - "endpoint": "GET /v1/scoring-runs/{run_id}", - "schema_file": "schemas/polling-response.schema.json", - "version": "1.0.0" - }, - "review": { - "endpoint": "POST /v1/scoring-runs/{run_id}/reviews", - "request_schema_file": "schemas/review-request.schema.json", - "response_schema_file": "schemas/review-response.schema.json", - "version": "1.0.0" - }, - "scoring_config": { - "immutable": true, - "schema_file": "schemas/scoring-config.schema.json", - "selection": "Callers select an existing version; Lead Scoring remains authoritative.", - "selection_schema_file": "schemas/scoring-config-selection.schema.json", - "version": "1.0.0" - }, - "webhook": { - "endpoint": "POST /api/v1/internal/lead-scoring/events", - "schema_file": "schemas/webhook-event-envelope.schema.json", - "version": "1.0.0" - } - }, - "release_status": "pre-release", - "source_of_truth": "Pydantic models under src/lead_scoring/schemas" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/error-taxonomy.json b/old_repo/contracts/lead-scoring-contract-pack/v1/error-taxonomy.json deleted file mode 100644 index 7e555d7..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/error-taxonomy.json +++ /dev/null @@ -1,252 +0,0 @@ -[ - { - "description": "Missing or invalid service authentication token.", - "error_code": "invalid_token", - "fallback_allowed": false, - "http_status": 401, - "lifecycle": "admission", - "owner": "lead_scoring", - "retryable": false, - "transport": "http" - }, - { - "description": "Authenticated caller is not authorized for the requested tenant or operation.", - "error_code": "forbidden", - "fallback_allowed": false, - "http_status": 403, - "lifecycle": "admission", - "owner": "lead_scoring", - "retryable": false, - "transport": "http" - }, - { - "description": "Requested scoring run is not available in the tenant scope.", - "error_code": "not_found", - "fallback_allowed": false, - "http_status": 404, - "lifecycle": "polling", - "owner": "lead_scoring", - "retryable": false, - "transport": "http" - }, - { - "description": "An idempotency key was reused with a different normalized request.", - "error_code": "idempotency_payload_mismatch", - "fallback_allowed": false, - "http_status": 409, - "lifecycle": "admission", - "owner": "lead_scoring", - "retryable": false, - "transport": "http" - }, - { - "description": "Review expected version does not match the current run version.", - "error_code": "stale_run_version", - "fallback_allowed": false, - "http_status": 409, - "lifecycle": "review", - "owner": "lead_scoring", - "retryable": false, - "transport": "http" - }, - { - "description": "The current run state does not accept a review command.", - "error_code": "run_not_reviewable", - "fallback_allowed": false, - "http_status": 409, - "lifecycle": "review", - "owner": "lead_scoring", - "retryable": false, - "transport": "http" - }, - { - "description": "Request headers, path parameters, or body failed contract validation.", - "error_code": "validation_error", - "fallback_allowed": false, - "http_status": 422, - "lifecycle": "admission", - "owner": "lead_scoring", - "retryable": false, - "transport": "http" - }, - { - "description": "A raw tenant credential was found in a persistent request field.", - "error_code": "credential_in_payload", - "fallback_allowed": false, - "http_status": 422, - "lifecycle": "admission", - "owner": "lead_scoring", - "retryable": false, - "transport": "http" - }, - { - "description": "The requested immutable scoring configuration version is unavailable.", - "error_code": "scoring_config_not_found", - "fallback_allowed": false, - "http_status": 422, - "lifecycle": "admission", - "owner": "lead_scoring", - "retryable": false, - "transport": "http" - }, - { - "description": "Snapshot HITL policy does not match the selected scoring configuration.", - "error_code": "hitl_policy_mismatch", - "fallback_allowed": false, - "http_status": 422, - "lifecycle": "admission", - "owner": "lead_scoring", - "retryable": false, - "transport": "http" - }, - { - "description": "Configured worker lease cannot cover the selected AI profile timeout.", - "error_code": "worker_lease_too_short", - "fallback_allowed": false, - "http_status": 422, - "lifecycle": "admission", - "owner": "lead_scoring", - "retryable": false, - "transport": "http" - }, - { - "description": "Re-evaluate requires a tenant V-Key for the new execution attempt.", - "error_code": "missing_tenant_vkey", - "fallback_allowed": false, - "http_status": 422, - "lifecycle": "review", - "owner": "lead_scoring", - "retryable": false, - "transport": "http" - }, - { - "description": "The re-evaluation snapshot belongs to a different subject.", - "error_code": "subject_ref_mismatch", - "fallback_allowed": false, - "http_status": 422, - "lifecycle": "review", - "owner": "lead_scoring", - "retryable": false, - "transport": "http" - }, - { - "description": "The operational kill switch currently blocks new scoring initiation.", - "error_code": "scoring_admission_disabled", - "fallback_allowed": false, - "http_status": 503, - "lifecycle": "admission", - "owner": "lead_scoring", - "retryable": true, - "transport": "http" - }, - { - "description": "The execution worker lost the in-memory attempt before terminal completion.", - "error_code": "worker_interrupted", - "fallback_allowed": false, - "http_status": null, - "lifecycle": "execution", - "owner": "lead_scoring", - "retryable": true, - "transport": "async_run" - }, - { - "description": "AIHub rejected tenant credential authentication.", - "error_code": "aihub_auth_failed", - "fallback_allowed": true, - "http_status": 401, - "lifecycle": "execution", - "owner": "aihub", - "retryable": false, - "transport": "async_run" - }, - { - "description": "AIHub quota or rate limit was exceeded.", - "error_code": "aihub_quota_exceeded", - "fallback_allowed": true, - "http_status": 429, - "lifecycle": "execution", - "owner": "aihub", - "retryable": false, - "transport": "async_run" - }, - { - "description": "The bounded AIHub request timed out.", - "error_code": "aihub_timeout", - "fallback_allowed": true, - "http_status": null, - "lifecycle": "execution", - "owner": "aihub", - "retryable": false, - "transport": "async_run" - }, - { - "description": "AIHub returned a server-side failure.", - "error_code": "aihub_server_error", - "fallback_allowed": true, - "http_status": null, - "lifecycle": "execution", - "owner": "aihub", - "retryable": false, - "transport": "async_run" - }, - { - "description": "AIHub output did not conform to the required bounded result schema.", - "error_code": "aihub_invalid_output", - "fallback_allowed": true, - "http_status": null, - "lifecycle": "execution", - "owner": "aihub", - "retryable": false, - "transport": "async_run" - }, - { - "description": "The bounded AIHub request could not reach the service.", - "error_code": "aihub_network_error", - "fallback_allowed": true, - "http_status": null, - "lifecycle": "execution", - "owner": "aihub", - "retryable": false, - "transport": "async_run" - }, - { - "description": "AIHub returned a non-retryable client response.", - "error_code": "aihub_client_error", - "fallback_allowed": true, - "http_status": null, - "lifecycle": "execution", - "owner": "aihub", - "retryable": false, - "transport": "async_run" - }, - { - "description": "AIHub returned an unexpected normalized failure.", - "error_code": "aihub_unknown_error", - "fallback_allowed": true, - "http_status": null, - "lifecycle": "execution", - "owner": "aihub", - "retryable": false, - "transport": "async_run" - }, - { - "description": "Webhook destination is not available for the resolved tenant.", - "error_code": "subscriber_tenant_unavailable", - "fallback_allowed": false, - "http_status": 404, - "lifecycle": "webhook_ingest", - "owner": "dotb_omni", - "retryable": false, - "transport": "webhook" - }, - { - "description": "EMS description contains an orphan or multiple Marker Blocks.", - "error_code": "marker_block_malformed", - "fallback_allowed": false, - "http_status": null, - "lifecycle": "writeback", - "owner": "dotb_omni", - "retryable": false, - "transport": "writeback" - } -] diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixture-index.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixture-index.json deleted file mode 100644 index 576fa09..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixture-index.json +++ /dev/null @@ -1,191 +0,0 @@ -{ - "consumer_validation": "Consume the same paths without copying or rewriting payloads.", - "contract_pack_version": "0.1.0", - "marker": [ - { - "path": "fixtures/marker/plain-description-append.json", - "role": "provider_consumer" - }, - { - "path": "fixtures/marker/empty-description-append.json", - "role": "provider_consumer" - }, - { - "path": "fixtures/marker/single-valid-block-replace.json", - "role": "provider_consumer" - }, - { - "path": "fixtures/marker/orphan-opening.json", - "role": "provider_consumer" - }, - { - "path": "fixtures/marker/orphan-closing.json", - "role": "provider_consumer" - }, - { - "path": "fixtures/marker/nested-block.json", - "role": "provider_consumer" - }, - { - "path": "fixtures/marker/overlapping-block.json", - "role": "provider_consumer" - }, - { - "path": "fixtures/marker/multiple-complete-blocks.json", - "role": "provider_consumer" - }, - { - "path": "fixtures/marker/unicode-multiline-preservation.json", - "role": "provider_consumer" - }, - { - "path": "fixtures/marker/deleted-block-followed-by-append.json", - "role": "provider_consumer" - }, - { - "path": "fixtures/marker/failed-run-no-render.json", - "role": "provider_consumer" - } - ], - "negative": [ - { - "path": "fixtures/negative/snapshot-future-message.json", - "schema": "schemas/canonical-snapshot.schema.json" - }, - { - "path": "fixtures/negative/admission-subject-mismatch.json", - "schema": "schemas/admission-request.schema.json" - }, - { - "path": "fixtures/negative/review-override-out-of-range.json", - "schema": "schemas/review-request.schema.json" - }, - { - "path": "fixtures/negative/event-missing-version.json", - "schema": "schemas/terminal-event.schema.json" - } - ], - "positive": [ - { - "path": "fixtures/snapshot.json", - "role": "provider_consumer", - "schema": "schemas/canonical-snapshot.schema.json" - }, - { - "path": "fixtures/scoring-config.json", - "role": "provider_consumer", - "schema": "schemas/scoring-config.schema.json" - }, - { - "path": "fixtures/scoring-config-selection.json", - "role": "provider_consumer", - "schema": "schemas/scoring-config-selection.schema.json" - }, - { - "path": "fixtures/admission-request.json", - "role": "provider_consumer", - "schema": "schemas/admission-request.schema.json" - }, - { - "path": "fixtures/admission-response.json", - "role": "provider_consumer", - "schema": "schemas/admission-response.schema.json" - }, - { - "path": "fixtures/review-approve-request.json", - "role": "provider_consumer", - "schema": "schemas/review-request.schema.json" - }, - { - "path": "fixtures/review-override-request.json", - "role": "provider_consumer", - "schema": "schemas/review-request.schema.json" - }, - { - "path": "fixtures/review-re_evaluate-request.json", - "role": "provider_consumer", - "schema": "schemas/review-request.schema.json" - }, - { - "path": "fixtures/review-response.json", - "role": "provider_consumer", - "schema": "schemas/review-response.schema.json" - }, - { - "path": "fixtures/lineage-root.json", - "role": "provider_consumer", - "schema": "schemas/lineage.schema.json" - }, - { - "path": "fixtures/lineage-child.json", - "role": "provider_consumer", - "schema": "schemas/lineage.schema.json" - }, - { - "path": "fixtures/tenant-unavailable.json", - "role": "provider_consumer", - "schema": "schemas/webhook-ingest-error-response.schema.json" - }, - { - "path": "fixtures/polling/pending.json", - "role": "provider_consumer", - "schema": "schemas/polling-response.schema.json" - }, - { - "path": "fixtures/polling/running.json", - "role": "provider_consumer", - "schema": "schemas/polling-response.schema.json" - }, - { - "path": "fixtures/polling/completed.json", - "role": "provider_consumer", - "schema": "schemas/polling-response.schema.json" - }, - { - "path": "fixtures/polling/review_required.json", - "role": "provider_consumer", - "schema": "schemas/polling-response.schema.json" - }, - { - "path": "fixtures/polling/failed.json", - "role": "provider_consumer", - "schema": "schemas/polling-response.schema.json" - }, - { - "path": "fixtures/polling/superseded.json", - "role": "provider_consumer", - "schema": "schemas/polling-response.schema.json" - }, - { - "path": "fixtures/webhook/completed.json", - "role": "provider_consumer", - "schema": "schemas/terminal-event.schema.json" - }, - { - "path": "fixtures/webhook/review_required.json", - "role": "provider_consumer", - "schema": "schemas/terminal-event.schema.json" - }, - { - "path": "fixtures/webhook/worker_interrupted.json", - "role": "provider_consumer", - "schema": "schemas/terminal-event.schema.json" - }, - { - "path": "fixtures/webhook/reviewed_approve.json", - "role": "provider_consumer", - "schema": "schemas/reviewed-event.schema.json" - }, - { - "path": "fixtures/webhook/reviewed_override.json", - "role": "provider_consumer", - "schema": "schemas/reviewed-event.schema.json" - }, - { - "path": "fixtures/webhook/reviewed_reevaluate.json", - "role": "provider_consumer", - "schema": "schemas/reviewed-event.schema.json" - } - ], - "provider_validation": "validate_fixture_corpus" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/admission-request.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/admission-request.json deleted file mode 100644 index 6985624..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/admission-request.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "rule_overrides": [], - "scoring_config_version": "1.0.0", - "snapshot": { - "captured_at": "2026-08-27T12:00:00Z", - "conversations": [ - { - "assigned_agent_id": "agent-10", - "channel": "chat", - "conversation_id": "conversation-1001", - "messages": [ - { - "content": "Interested in a product demonstration.", - "message_id": "message-1001", - "sender_type": "contact", - "sent_at": "2026-08-27T11:50:00Z" - }, - { - "content": "I can arrange that for you.", - "message_id": "message-1002", - "sender_type": "agent", - "sent_at": "2026-08-27T11:55:00Z" - } - ] - } - ], - "hitl_policy": { - "confidence_threshold": 0.8, - "policy_version": "1.0.0" - }, - "identity_context": { - "contact_id": "contact-1001", - "details": { - "source": "ems" - }, - "match_quality": "direct" - }, - "lead_profile": { - "custom_fields": { - "industry": "software" - }, - "email": "lead@example.test", - "ems_lead_id": "ems-lead-1001", - "name": "Example Lead", - "phone": "+84000000000", - "status": "open" - }, - "scope": "linked_contact_history", - "trigger_metadata": { - "actor_id": "agent-10", - "trigger_conversation_id": "conversation-1001", - "trigger_type": "manual_omni" - }, - "truncation": { - "dropped_conversations_count": 0, - "dropped_messages_count": 0, - "reason": null, - "truncated": false - }, - "version": "1.0.0" - }, - "subject_ref": "ems-lead-1001", - "tenant_id": "omni-account-42" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/admission-response.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/admission-response.json deleted file mode 100644 index 721ca35..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/admission-response.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "created_at": "2026-08-27T12:00:00Z", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "scoring_config_version": "1.0.0", - "status": "pending", - "status_url": "/v1/scoring-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "subject_ref": "ems-lead-1001", - "tenant_id": "omni-account-42" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/error-validation.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/error-validation.json deleted file mode 100644 index c30a476..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/error-validation.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "detail": "Request validation failed", - "error_code": "validation_error" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/lineage-child.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/lineage-child.json deleted file mode 100644 index 79a7fd3..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/lineage-child.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "parent_run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "relationship": "re_evaluation_child", - "run_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/lineage-root.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/lineage-root.json deleted file mode 100644 index 2a6f1ef..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/lineage-root.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "parent_run_id": null, - "relationship": "root", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/deleted-block-followed-by-append.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/deleted-block-followed-by-append.json deleted file mode 100644 index dce7a32..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/deleted-block-followed-by-append.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "case": "deleted-block-followed-by-append", - "current_description": "Lead owner: Nguyễn Ánh 😊\n\nThe previous score block was removed.", - "expected_description": "Lead owner: Nguyễn Ánh 😊\n\nThe previous score block was removed.\n\n\nLead Scoring\nScore: 61\nConfidence: 0.85\nMode: medium_ai\nDegraded: no\nConfig: 1.0.0\nRun: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\nRun created: 2026-08-27T12:00:00Z\n", - "expected_error": null, - "marker_input": { - "confidence": 0.85, - "degraded": false, - "final_score": 61, - "run_created_at": "2026-08-27T12:00:00Z", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "scoring_config_version": "1.0.0", - "scoring_mode": "medium_ai" - }, - "run_status": "completed", - "writeback": true -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/empty-description-append.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/empty-description-append.json deleted file mode 100644 index 32e660d..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/empty-description-append.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "case": "empty-description-append", - "current_description": "", - "expected_description": "\nLead Scoring\nScore: 61\nConfidence: 0.85\nMode: medium_ai\nDegraded: no\nConfig: 1.0.0\nRun: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\nRun created: 2026-08-27T12:00:00Z\n", - "expected_error": null, - "marker_input": { - "confidence": 0.85, - "degraded": false, - "final_score": 61, - "run_created_at": "2026-08-27T12:00:00Z", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "scoring_config_version": "1.0.0", - "scoring_mode": "medium_ai" - }, - "run_status": "completed", - "writeback": true -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/failed-run-no-render.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/failed-run-no-render.json deleted file mode 100644 index 17dffe0..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/failed-run-no-render.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "case": "failed-run-no-render", - "current_description": "Customer note: do not write a failure marker.", - "expected_description": "Customer note: do not write a failure marker.", - "expected_error": null, - "marker_input": null, - "run_status": "failed", - "writeback": false -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/multiple-complete-blocks.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/multiple-complete-blocks.json deleted file mode 100644 index 1a2515d..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/multiple-complete-blocks.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "case": "multiple-complete-blocks", - "current_description": "\nLead Scoring\nScore: 42\nConfidence: 0.71\nMode: light_ai\nDegraded: no\nConfig: 1.0.0\nRun: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\nRun created: 2026-08-27T12:00:00Z\n\n\nUser text\n\n\nLead Scoring\nScore: 42\nConfidence: 0.71\nMode: light_ai\nDegraded: no\nConfig: 1.0.0\nRun: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\nRun created: 2026-08-27T12:00:00Z\n", - "expected_description": null, - "expected_error": "marker_block_malformed", - "marker_input": { - "confidence": 0.85, - "degraded": false, - "final_score": 61, - "run_created_at": "2026-08-27T12:00:00Z", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "scoring_config_version": "1.0.0", - "scoring_mode": "medium_ai" - }, - "run_status": "completed", - "writeback": false -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/nested-block.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/nested-block.json deleted file mode 100644 index 41d2322..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/nested-block.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "case": "nested-block", - "current_description": "\nouter\n\ninner\n\n", - "expected_description": null, - "expected_error": "marker_block_malformed", - "marker_input": { - "confidence": 0.85, - "degraded": false, - "final_score": 61, - "run_created_at": "2026-08-27T12:00:00Z", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "scoring_config_version": "1.0.0", - "scoring_mode": "medium_ai" - }, - "run_status": "completed", - "writeback": false -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/orphan-closing.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/orphan-closing.json deleted file mode 100644 index 8427f3f..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/orphan-closing.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "case": "orphan-closing", - "current_description": "Notes before\n\npartial marker\n", - "expected_description": null, - "expected_error": "marker_block_malformed", - "marker_input": { - "confidence": 0.85, - "degraded": false, - "final_score": 61, - "run_created_at": "2026-08-27T12:00:00Z", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "scoring_config_version": "1.0.0", - "scoring_mode": "medium_ai" - }, - "run_status": "completed", - "writeback": false -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/orphan-opening.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/orphan-opening.json deleted file mode 100644 index ad3b353..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/orphan-opening.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "case": "orphan-opening", - "current_description": "Notes before\n\n\npartial marker", - "expected_description": null, - "expected_error": "marker_block_malformed", - "marker_input": { - "confidence": 0.85, - "degraded": false, - "final_score": 61, - "run_created_at": "2026-08-27T12:00:00Z", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "scoring_config_version": "1.0.0", - "scoring_mode": "medium_ai" - }, - "run_status": "completed", - "writeback": false -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/overlapping-block.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/overlapping-block.json deleted file mode 100644 index b721ec7..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/overlapping-block.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "case": "overlapping-block", - "current_description": "\nfirst\n", - "expected_description": null, - "expected_error": "marker_block_malformed", - "marker_input": { - "confidence": 0.85, - "degraded": false, - "final_score": 61, - "run_created_at": "2026-08-27T12:00:00Z", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "scoring_config_version": "1.0.0", - "scoring_mode": "medium_ai" - }, - "run_status": "completed", - "writeback": false -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/plain-description-append.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/plain-description-append.json deleted file mode 100644 index e4e2d9c..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/plain-description-append.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "case": "plain-description-append", - "current_description": "Existing customer note.", - "expected_description": "Existing customer note.\n\n\nLead Scoring\nScore: 61\nConfidence: 0.85\nMode: medium_ai\nDegraded: no\nConfig: 1.0.0\nRun: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\nRun created: 2026-08-27T12:00:00Z\n", - "expected_error": null, - "marker_input": { - "confidence": 0.85, - "degraded": false, - "final_score": 61, - "run_created_at": "2026-08-27T12:00:00Z", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "scoring_config_version": "1.0.0", - "scoring_mode": "medium_ai" - }, - "run_status": "completed", - "writeback": true -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/single-valid-block-replace.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/single-valid-block-replace.json deleted file mode 100644 index 570f044..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/single-valid-block-replace.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "case": "single-valid-block-replace", - "current_description": "Customer note:\nKeep this line.\n\n\nLead Scoring\nScore: 42\nConfidence: 0.71\nMode: light_ai\nDegraded: no\nConfig: 1.0.0\nRun: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\nRun created: 2026-08-27T12:00:00Z\n\n\nFollow up tomorrow.", - "expected_description": "Customer note:\nKeep this line.\n\n\nLead Scoring\nScore: 61\nConfidence: 0.85\nMode: medium_ai\nDegraded: no\nConfig: 1.0.0\nRun: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\nRun created: 2026-08-27T12:00:00Z\n\n\nFollow up tomorrow.", - "expected_error": null, - "marker_input": { - "confidence": 0.85, - "degraded": false, - "final_score": 61, - "run_created_at": "2026-08-27T12:00:00Z", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "scoring_config_version": "1.0.0", - "scoring_mode": "medium_ai" - }, - "run_status": "completed", - "writeback": true -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/unicode-multiline-preservation.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/unicode-multiline-preservation.json deleted file mode 100644 index dd5ef0a..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/marker/unicode-multiline-preservation.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "case": "unicode-multiline-preservation", - "current_description": "Khách hàng: Nguyễn Ánh 😊\nDòng thứ hai.\n\n\nLead Scoring\nScore: 42\nConfidence: 0.71\nMode: light_ai\nDegraded: no\nConfig: 1.0.0\nRun: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\nRun created: 2026-08-27T12:00:00Z\n\n\nGhi chú cuối: giữ nguyên nội dung người dùng.", - "expected_description": "Khách hàng: Nguyễn Ánh 😊\nDòng thứ hai.\n\n\nLead Scoring\nScore: 50\nConfidence: 0.62\nMode: rules_only_fallback\nDegraded: yes\nConfig: 1.0.0\nRun: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\nRun created: 2026-08-27T12:00:00Z\n\n\nGhi chú cuối: giữ nguyên nội dung người dùng.", - "expected_error": null, - "marker_input": { - "confidence": 0.62, - "degraded": true, - "final_score": 50, - "run_created_at": "2026-08-27T12:00:00Z", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "scoring_config_version": "1.0.0", - "scoring_mode": "rules_only_fallback" - }, - "run_status": "review_required", - "writeback": true -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/negative/admission-subject-mismatch.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/negative/admission-subject-mismatch.json deleted file mode 100644 index cc48559..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/negative/admission-subject-mismatch.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "rule_overrides": [], - "scoring_config_version": "1.0.0", - "snapshot": { - "captured_at": "2026-08-27T12:00:00Z", - "conversations": [ - { - "assigned_agent_id": "agent-10", - "channel": "chat", - "conversation_id": "conversation-1001", - "messages": [ - { - "content": "Interested in a product demonstration.", - "message_id": "message-1001", - "sender_type": "contact", - "sent_at": "2026-08-27T11:50:00Z" - }, - { - "content": "I can arrange that for you.", - "message_id": "message-1002", - "sender_type": "agent", - "sent_at": "2026-08-27T11:55:00Z" - } - ] - } - ], - "hitl_policy": { - "confidence_threshold": 0.8, - "policy_version": "1.0.0" - }, - "identity_context": { - "contact_id": "contact-1001", - "details": { - "source": "ems" - }, - "match_quality": "direct" - }, - "lead_profile": { - "custom_fields": { - "industry": "software" - }, - "email": "lead@example.test", - "ems_lead_id": "ems-lead-1001", - "name": "Example Lead", - "phone": "+84000000000", - "status": "open" - }, - "scope": "linked_contact_history", - "trigger_metadata": { - "actor_id": "agent-10", - "trigger_conversation_id": "conversation-1001", - "trigger_type": "manual_omni" - }, - "truncation": { - "dropped_conversations_count": 0, - "dropped_messages_count": 0, - "reason": null, - "truncated": false - }, - "version": "1.0.0" - }, - "subject_ref": "ems-lead-other", - "tenant_id": "omni-account-42" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/negative/event-missing-version.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/negative/event-missing-version.json deleted file mode 100644 index 7da8e98..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/negative/event-missing-version.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "data": { - "confidence": 0.91, - "rule_score": 82, - "status": "completed" - }, - "event_id": "11111111-1111-4111-8111-111111111111", - "event_type": "lead_scoring.run.terminal", - "occurred_at": "2026-08-27T12:00:03Z", - "run_created_at": "2026-08-27T12:00:00Z", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "scoring_config_version": "1.0.0", - "subject_ref": "ems-lead-1001", - "tenant_id": "omni-account-42" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/negative/review-override-out-of-range.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/negative/review-override-out-of-range.json deleted file mode 100644 index 5a57ce5..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/negative/review-override-out-of-range.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "decision": "override", - "expected_run_version": 1, - "note": "Reviewed against the latest customer context.", - "override_score": 101, - "reviewer": { - "actor_id": "agent-10", - "current_assignee_id": "agent-10", - "role": "agent" - } -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/negative/snapshot-future-message.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/negative/snapshot-future-message.json deleted file mode 100644 index 37df024..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/negative/snapshot-future-message.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "captured_at": "2026-08-27T12:00:00Z", - "conversations": [ - { - "conversation_id": "conversation-1001", - "messages": [ - { - "content": "This message is after capture.", - "message_id": "message-future", - "sender_type": "contact", - "sent_at": "2026-08-27T12:01:00Z" - } - ] - } - ], - "hitl_policy": { - "confidence_threshold": 0.8, - "policy_version": "1.0.0" - }, - "identity_context": { - "contact_id": "contact-1001", - "details": { - "source": "ems" - }, - "match_quality": "direct" - }, - "lead_profile": { - "custom_fields": { - "industry": "software" - }, - "email": "lead@example.test", - "ems_lead_id": "ems-lead-1001", - "name": "Example Lead", - "phone": "+84000000000", - "status": "open" - }, - "scope": "linked_contact_history", - "trigger_metadata": { - "actor_id": "agent-10", - "trigger_conversation_id": "conversation-1001", - "trigger_type": "manual_omni" - }, - "truncation": { - "dropped_conversations_count": 0, - "dropped_messages_count": 0, - "reason": null, - "truncated": false - }, - "version": "1.0.0" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/polling/completed.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/polling/completed.json deleted file mode 100644 index d957446..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/polling/completed.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "ai_metadata": { - "effort_profile": "medium", - "model": "openai/gpt-4o-mini", - "pass_count": 2 - }, - "ai_score": 78, - "breakdown": null, - "category": "hot", - "completed_at": "2026-08-27T12:00:03Z", - "confidence": 0.91, - "confidence_threshold": 0.8, - "created_at": "2026-08-27T12:00:00Z", - "degraded": false, - "effective_config": null, - "effective_score": 80, - "error_code": null, - "explanation": "Lead \u0111\u1ea1t 80/100 sau khi k\u1ebft h\u1ee3p b\u1ed9 quy t\u1eafc v\u00e0 ph\u00e2n t\u00edch h\u1ed9i tho\u1ea1i b\u1eb1ng AI.", - "final_score": 80, - "parent_run_id": null, - "recommended_action": "Li\u00ean h\u1ec7 v\u1edbi Lead trong 24 gi\u1edd \u0111\u1ec3 x\u00e1c nh\u1eadn b\u01b0\u1edbc \u0111\u0103ng k\u00fd ti\u1ebfp theo.", - "retryable": null, - "rule_score": 82, - "run_id": "33333333-3333-4333-8333-333333333333", - "scoring_config_version": "1.0.0", - "scoring_mode": "medium_ai", - "started_at": "2026-08-27T12:00:01Z", - "status": "completed", - "subject_ref": "ems-lead-1003", - "tenant_id": "omni-account-42", - "updated_at": "2026-08-27T12:00:03Z", - "version": 1 -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/polling/failed.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/polling/failed.json deleted file mode 100644 index 5c8c058..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/polling/failed.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "ai_metadata": null, - "ai_score": null, - "breakdown": null, - "category": "cold", - "completed_at": "2026-08-27T12:07:01Z", - "confidence": null, - "confidence_threshold": 0.8, - "created_at": "2026-08-27T12:02:00Z", - "degraded": null, - "effective_config": null, - "effective_score": null, - "error_code": "worker_interrupted", - "explanation": "Kh\u00f4ng th\u1ec3 ch\u1ea5m \u0111i\u1ec3m.", - "final_score": null, - "parent_run_id": null, - "recommended_action": "Ki\u1ec3m tra l\u1ed7i v\u00e0 th\u1eed ch\u1ea5m \u0111i\u1ec3m l\u1ea1i.", - "retryable": true, - "rule_score": null, - "run_id": "55555555-5555-4555-8555-555555555555", - "scoring_config_version": "1.0.0", - "scoring_mode": null, - "started_at": "2026-08-27T12:02:01Z", - "status": "failed", - "subject_ref": "ems-lead-1005", - "tenant_id": "omni-account-42", - "updated_at": "2026-08-27T12:07:01Z", - "version": 1 -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/polling/pending.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/polling/pending.json deleted file mode 100644 index eafc205..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/polling/pending.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "ai_metadata": null, - "ai_score": null, - "breakdown": null, - "category": null, - "completed_at": null, - "confidence": null, - "confidence_threshold": 0.8, - "created_at": "2026-08-27T12:00:00Z", - "degraded": null, - "effective_config": null, - "effective_score": null, - "error_code": null, - "explanation": null, - "final_score": null, - "parent_run_id": null, - "recommended_action": null, - "retryable": null, - "rule_score": null, - "run_id": "11111111-1111-4111-8111-111111111111", - "scoring_config_version": "1.0.0", - "scoring_mode": null, - "started_at": null, - "status": "pending", - "subject_ref": "ems-lead-1001", - "tenant_id": "omni-account-42", - "updated_at": "2026-08-27T12:00:00Z", - "version": 1 -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/polling/running.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/polling/running.json deleted file mode 100644 index 76aee88..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/polling/running.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "ai_metadata": null, - "ai_score": null, - "breakdown": null, - "category": null, - "completed_at": null, - "confidence": null, - "confidence_threshold": 0.8, - "created_at": "2026-08-27T12:00:00Z", - "degraded": null, - "effective_config": null, - "effective_score": null, - "error_code": null, - "explanation": null, - "final_score": null, - "parent_run_id": null, - "recommended_action": null, - "retryable": null, - "rule_score": null, - "run_id": "22222222-2222-4222-8222-222222222222", - "scoring_config_version": "1.0.0", - "scoring_mode": null, - "started_at": "2026-08-27T12:00:01Z", - "status": "running", - "subject_ref": "ems-lead-1002", - "tenant_id": "omni-account-42", - "updated_at": "2026-08-27T12:00:01Z", - "version": 1 -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/review-approve-request.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/review-approve-request.json deleted file mode 100644 index 7916420..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/review-approve-request.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "decision": "approve", - "expected_run_version": 1, - "note": "Reviewed against the latest customer context.", - "reviewer": { - "actor_id": "agent-10", - "current_assignee_id": "agent-10", - "role": "agent" - } -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/review-override-request.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/review-override-request.json deleted file mode 100644 index b100208..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/review-override-request.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "decision": "override", - "expected_run_version": 1, - "note": "Reviewed against the latest customer context.", - "override_score": 90, - "reviewer": { - "actor_id": "agent-10", - "current_assignee_id": "agent-10", - "role": "agent" - } -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/review-re_evaluate-request.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/review-re_evaluate-request.json deleted file mode 100644 index 6b4a179..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/review-re_evaluate-request.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "decision": "re_evaluate", - "expected_run_version": 1, - "note": "Reviewed against the latest customer context.", - "reviewer": { - "actor_id": "agent-10", - "current_assignee_id": "agent-10", - "role": "agent" - }, - "snapshot": { - "captured_at": "2026-08-27T12:05:00Z", - "conversations": [ - { - "assigned_agent_id": "agent-10", - "channel": "chat", - "conversation_id": "conversation-1001", - "messages": [ - { - "content": "Interested in a product demonstration.", - "message_id": "message-1001", - "sender_type": "contact", - "sent_at": "2026-08-27T11:55:00Z" - }, - { - "content": "I can arrange that for you.", - "message_id": "message-1002", - "sender_type": "agent", - "sent_at": "2026-08-27T12:00:00Z" - } - ] - } - ], - "hitl_policy": { - "confidence_threshold": 0.8, - "policy_version": "1.0.0" - }, - "identity_context": { - "contact_id": "contact-1001", - "details": { - "source": "ems" - }, - "match_quality": "direct" - }, - "lead_profile": { - "custom_fields": { - "industry": "software" - }, - "email": "lead@example.test", - "ems_lead_id": "ems-lead-1001", - "name": "Example Lead", - "phone": "+84000000000", - "status": "open" - }, - "scope": "linked_contact_history", - "trigger_metadata": { - "actor_id": "agent-10", - "trigger_conversation_id": "conversation-1001", - "trigger_type": "manual_omni" - }, - "truncation": { - "dropped_conversations_count": 0, - "dropped_messages_count": 0, - "reason": null, - "truncated": false - }, - "version": "1.0.0" - } -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/review-response.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/review-response.json deleted file mode 100644 index 8c55cf9..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/review-response.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "calculated_score": 61, - "child_run_id": null, - "decision": "approve", - "effective_score": 61, - "note": null, - "reviewed_at": "2026-08-27T12:05:00Z", - "reviewed_by": "agent-10", - "run_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - "status": "completed", - "subject_ref": "ems-lead-1002", - "tenant_id": "omni-account-42", - "version": 2 -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/scoring-config-selection.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/scoring-config-selection.json deleted file mode 100644 index 1587a66..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/scoring-config-selection.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "version": "1.0.0" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/scoring-config.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/scoring-config.json deleted file mode 100644 index cb67946..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/scoring-config.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "age_primary_max": 15, - "age_primary_min": 3, - "age_primary_points": 40, - "age_secondary_max": 18, - "age_secondary_min": 16, - "age_secondary_points": 20, - "ai_model": "openai/gpt-4o-mini", - "ai_timeout_seconds": 10.0, - "ai_weight": 0.6, - "base_score": 50, - "completeness_threshold": 0.5, - "confidence": 0.85, - "confidence_threshold": 0.8, - "dynamic_rules": [], - "education_demo_enabled": false, - "effort_profile": "medium", - "fallback_policy": "rules_only_review", - "hitl_policy_version": "1.0.0", - "medium_extract_prompt": "Bạn là trợ lý AI phân tích hội thoại để đánh giá tiềm năng học viên. Hãy trích xuất bằng chứng có cấu trúc về nhu cầu khóa học, mức độ quan tâm, thời điểm đăng ký và rào cản. Trả lời bằng tiếng Việt và không tự bịa dữ kiện.", - "medium_score_prompt": "Bạn là chuyên gia chấm điểm Lead giáo dục từ 0 đến 100. Dựa trên bằng chứng đã trích xuất, hãy đánh giá khả năng đăng ký, độ tin cậy, lý do và các yếu tố tích cực hoặc tiêu cực. Lưu ý: Không cộng lại điểm các customer rules hoặc metrics đã tính ở phần quy tắc riêng. Trả lời bằng tiếng Việt và không tự bịa dữ kiện.", - "recency_cold_max_days": 30.0, - "recency_cold_points": 5, - "recency_cool_max_days": 14.0, - "recency_cool_points": 15, - "recency_hot_max_days": 3.0, - "recency_hot_points": 40, - "recency_warm_max_days": 7.0, - "recency_warm_points": 25, - "rule_weight": 0.4, - "rules": [ - { - "metric": "profile_field_count", - "minimum": 2, - "points": 20 - }, - { - "metric": "conversation_count", - "minimum": 1, - "points": 10 - } - ], - "selected_behavioral_metrics": [ - "message_count", - "conversation_count", - "conversation_turn_count", - "customer_message_count", - "customer_initiated_return" - ], - "source_call_in_points": 15, - "source_campaign_points": 8, - "source_digital_points": 10, - "source_other_points": 5, - "source_referral_points": 20, - "version": "1.0.0" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/snapshot.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/snapshot.json deleted file mode 100644 index 26bdea9..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/snapshot.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "captured_at": "2026-08-27T12:00:00Z", - "conversations": [ - { - "assigned_agent_id": "agent-10", - "channel": "chat", - "conversation_id": "conversation-1001", - "messages": [ - { - "content": "Interested in a product demonstration.", - "message_id": "message-1001", - "sender_type": "contact", - "sent_at": "2026-08-27T11:50:00Z" - }, - { - "content": "I can arrange that for you.", - "message_id": "message-1002", - "sender_type": "agent", - "sent_at": "2026-08-27T11:55:00Z" - } - ] - } - ], - "hitl_policy": { - "confidence_threshold": 0.8, - "policy_version": "1.0.0" - }, - "identity_context": { - "contact_id": "contact-1001", - "details": { - "source": "ems" - }, - "match_quality": "direct" - }, - "lead_profile": { - "custom_fields": { - "industry": "software" - }, - "email": "lead@example.test", - "ems_lead_id": "ems-lead-1001", - "name": "Example Lead", - "phone": "+84000000000", - "status": "open" - }, - "scope": "linked_contact_history", - "trigger_metadata": { - "actor_id": "agent-10", - "trigger_conversation_id": "conversation-1001", - "trigger_type": "manual_omni" - }, - "truncation": { - "dropped_conversations_count": 0, - "dropped_messages_count": 0, - "reason": null, - "truncated": false - }, - "version": "1.0.0" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/tenant-unavailable.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/tenant-unavailable.json deleted file mode 100644 index 43ae139..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/tenant-unavailable.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "detail": "Webhook destination is not available", - "error_code": "subscriber_tenant_unavailable" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/completed.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/completed.json deleted file mode 100644 index 5ca7e6a..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/completed.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "data": { - "ai_score": null, - "category": null, - "confidence": 0.91, - "degraded": null, - "effective_score": null, - "error_code": null, - "explanation": null, - "final_score": null, - "recommended_action": null, - "retryable": null, - "rule_score": 82, - "scoring_mode": null, - "status": "completed" - }, - "event_id": "11111111-1111-4111-8111-111111111111", - "event_type": "lead_scoring.run.terminal", - "event_version": "1.0.0", - "occurred_at": "2026-08-27T12:00:03Z", - "run_created_at": "2026-08-27T12:00:00Z", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "scoring_config_version": "1.0.0", - "subject_ref": "ems-lead-1001", - "tenant_id": "omni-account-42" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/review_required.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/review_required.json deleted file mode 100644 index 3988956..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/review_required.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "data": { - "ai_score": null, - "category": null, - "confidence": 0.64, - "degraded": null, - "effective_score": null, - "error_code": null, - "explanation": null, - "final_score": null, - "recommended_action": null, - "retryable": null, - "rule_score": 61, - "scoring_mode": null, - "status": "completed" - }, - "event_id": "22222222-2222-4222-8222-222222222222", - "event_type": "lead_scoring.run.terminal", - "event_version": "1.0.0", - "occurred_at": "2026-08-27T12:01:04Z", - "run_created_at": "2026-08-27T12:01:00Z", - "run_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - "scoring_config_version": "1.0.0", - "subject_ref": "ems-lead-1002", - "tenant_id": "omni-account-42" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/reviewed_approve.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/reviewed_approve.json deleted file mode 100644 index e5d62fc..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/reviewed_approve.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "data": { - "calculated_score": 61, - "decision": "approve", - "effective_score": 61, - "reviewed_by": "agent_10", - "status": "completed", - "version": 2 - }, - "event_id": "44444444-4444-4444-8444-444444444444", - "event_type": "lead_scoring.run.reviewed", - "event_version": "1.0.0", - "occurred_at": "2026-08-27T12:05:00Z", - "run_created_at": "2026-08-27T12:01:00Z", - "run_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - "scoring_config_version": "1.0.0", - "subject_ref": "ems-lead-1002", - "tenant_id": "omni-account-42" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/reviewed_override.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/reviewed_override.json deleted file mode 100644 index 864db94..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/reviewed_override.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "data": { - "calculated_score": 61, - "decision": "override", - "effective_score": 90, - "reviewed_by": "agent_10", - "status": "completed", - "version": 2 - }, - "event_id": "55555555-5555-4555-8555-555555555555", - "event_type": "lead_scoring.run.reviewed", - "event_version": "1.0.0", - "occurred_at": "2026-08-27T12:06:00Z", - "run_created_at": "2026-08-27T12:01:00Z", - "run_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - "scoring_config_version": "1.0.0", - "subject_ref": "ems-lead-1002", - "tenant_id": "omni-account-42" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/reviewed_reevaluate.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/reviewed_reevaluate.json deleted file mode 100644 index 51239c8..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/reviewed_reevaluate.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "data": { - "calculated_score": 82, - "decision": "re_evaluate", - "effective_score": 82, - "reviewed_by": "agent_10", - "status": "superseded", - "version": 2 - }, - "event_id": "66666666-6666-4666-8666-666666666666", - "event_type": "lead_scoring.run.reviewed", - "event_version": "1.0.0", - "occurred_at": "2026-08-27T12:07:00Z", - "run_created_at": "2026-08-27T12:00:00Z", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "scoring_config_version": "1.0.0", - "subject_ref": "ems-lead-1001", - "tenant_id": "omni-account-42" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/tenant-unavailable.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/tenant-unavailable.json deleted file mode 100644 index 43ae139..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/tenant-unavailable.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "detail": "Webhook destination is not available", - "error_code": "subscriber_tenant_unavailable" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/worker_interrupted.json b/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/worker_interrupted.json deleted file mode 100644 index a877309..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/fixtures/webhook/worker_interrupted.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "data": { - "ai_score": null, - "category": null, - "confidence": null, - "degraded": null, - "effective_score": null, - "error_code": "worker_interrupted", - "explanation": null, - "final_score": null, - "recommended_action": null, - "retryable": true, - "rule_score": null, - "scoring_mode": null, - "status": "failed" - }, - "event_id": "33333333-3333-4333-8333-333333333333", - "event_type": "lead_scoring.run.terminal", - "event_version": "1.0.0", - "occurred_at": "2026-08-27T12:07:01Z", - "run_created_at": "2026-08-27T12:02:00Z", - "run_id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", - "scoring_config_version": "1.0.0", - "subject_ref": "ems-lead-1003", - "tenant_id": "omni-account-42" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/openapi.json b/old_repo/contracts/lead-scoring-contract-pack/v1/openapi.json deleted file mode 100644 index 60b3e7b..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/openapi.json +++ /dev/null @@ -1,2084 +0,0 @@ -{ - "components": { - "schemas": { - "AiMetadataFailure": { - "additionalProperties": false, - "properties": { - "detail": { - "description": "AI error detail", - "maxLength": 255, - "minLength": 1, - "title": "Detail", - "type": "string" - }, - "error_code": { - "description": "Machine-readable AI error code", - "maxLength": 64, - "minLength": 1, - "title": "Error Code", - "type": "string" - }, - "fallback_policy": { - "$ref": "#/components/schemas/FallbackPolicy", - "description": "Fallback policy triggered" - } - }, - "required": [ - "error_code", - "detail", - "fallback_policy" - ], - "title": "AiMetadataFailure", - "type": "object" - }, - "AiMetadataSuccess": { - "additionalProperties": false, - "properties": { - "effort_profile": { - "$ref": "#/components/schemas/EffortProfile", - "description": "AI effort profile applied" - }, - "model": { - "description": "AI model used", - "maxLength": 64, - "minLength": 1, - "title": "Model", - "type": "string" - }, - "pass_count": { - "description": "Number of passes executed", - "minimum": 1.0, - "title": "Pass Count", - "type": "integer" - } - }, - "required": [ - "model", - "effort_profile", - "pass_count" - ], - "title": "AiMetadataSuccess", - "type": "object" - }, - "BehavioralMetric": { - "enum": [ - "message_count", - "conversation_count", - "conversation_turn_count", - "customer_message_count", - "customer_initiated_return" - ], - "title": "BehavioralMetric", - "type": "string" - }, - "CanonicalSnapshot": { - "properties": { - "captured_at": { - "description": "Timezone-aware timestamp when snapshot evidence was frozen", - "format": "date-time", - "title": "Captured At", - "type": "string" - }, - "conversations": { - "items": { - "$ref": "#/components/schemas/ConversationEvidence" - }, - "title": "Conversations", - "type": "array" - }, - "hitl_policy": { - "anyOf": [ - { - "$ref": "#/components/schemas/HitlPolicy" - }, - { - "type": "null" - } - ] - }, - "identity_context": { - "anyOf": [ - { - "$ref": "#/components/schemas/IdentityContext" - }, - { - "type": "null" - } - ] - }, - "lead_profile": { - "$ref": "#/components/schemas/LeadProfile" - }, - "scope": { - "$ref": "#/components/schemas/SnapshotScope", - "default": "linked_contact_history" - }, - "trigger_metadata": { - "anyOf": [ - { - "$ref": "#/components/schemas/TriggerMetadata" - }, - { - "type": "null" - } - ] - }, - "truncation": { - "anyOf": [ - { - "$ref": "#/components/schemas/TruncationMetadata" - }, - { - "type": "null" - } - ] - }, - "version": { - "default": "1.0.0", - "maxLength": 64, - "title": "Version", - "type": "string" - } - }, - "required": [ - "captured_at", - "lead_profile" - ], - "title": "CanonicalSnapshot", - "type": "object" - }, - "ConversationEvidence": { - "properties": { - "assigned_agent_id": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Assigned Agent Id" - }, - "channel": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Channel" - }, - "conversation_id": { - "maxLength": 255, - "minLength": 1, - "title": "Conversation Id", - "type": "string" - }, - "messages": { - "items": { - "$ref": "#/components/schemas/MessageEvidence" - }, - "title": "Messages", - "type": "array" - } - }, - "required": [ - "conversation_id" - ], - "title": "ConversationEvidence", - "type": "object" - }, - "CreateScoringRunRequest": { - "properties": { - "rule_overrides": { - "description": "Inline dynamic rule overrides (maximum 30 rules)", - "items": { - "$ref": "#/components/schemas/DynamicRule" - }, - "maxItems": 30, - "title": "Rule Overrides", - "type": "array" - }, - "scoring_config_version": { - "default": "1.0.0", - "description": "Scoring config version", - "maxLength": 64, - "minLength": 1, - "title": "Scoring Config Version", - "type": "string" - }, - "snapshot": { - "$ref": "#/components/schemas/CanonicalSnapshot", - "description": "Canonical Lead Snapshot containing evidence" - }, - "subject_ref": { - "description": "EMS Lead reference or identifier", - "maxLength": 255, - "minLength": 1, - "title": "Subject Ref", - "type": "string" - }, - "tenant_id": { - "description": "Tenant identifier", - "maxLength": 255, - "minLength": 1, - "title": "Tenant Id", - "type": "string" - } - }, - "required": [ - "tenant_id", - "subject_ref", - "snapshot" - ], - "title": "CreateScoringRunRequest", - "type": "object" - }, - "CreateScoringRunResponse": { - "properties": { - "created_at": { - "description": "Creation timestamp", - "format": "date-time", - "title": "Created At", - "type": "string" - }, - "run_id": { - "description": "Server-generated unique scoring run ID", - "maxLength": 36, - "title": "Run Id", - "type": "string" - }, - "scoring_config_version": { - "description": "Scoring config version applied", - "maxLength": 64, - "title": "Scoring Config Version", - "type": "string" - }, - "status": { - "$ref": "#/components/schemas/ScoringRunStatus", - "default": "pending", - "description": "Initial run status" - }, - "status_url": { - "description": "URL to inspect scoring run status", - "title": "Status Url", - "type": "string" - }, - "subject_ref": { - "description": "EMS Lead reference", - "maxLength": 255, - "title": "Subject Ref", - "type": "string" - }, - "tenant_id": { - "description": "Tenant identifier", - "maxLength": 255, - "title": "Tenant Id", - "type": "string" - } - }, - "required": [ - "run_id", - "status_url", - "tenant_id", - "subject_ref", - "scoring_config_version", - "created_at" - ], - "title": "CreateScoringRunResponse", - "type": "object" - }, - "DynamicRule": { - "description": "A configurable dynamic scoring rule that evaluates snapshot features.", - "properties": { - "custom_field_key": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Key inside lead_profile.custom_fields if target is custom_field", - "title": "Custom Field Key" - }, - "enabled": { - "default": true, - "description": "Whether this rule is active", - "title": "Enabled", - "type": "boolean" - }, - "id": { - "description": "Unique identifier for the rule", - "maxLength": 64, - "minLength": 1, - "title": "Id", - "type": "string" - }, - "name": { - "description": "Human-readable rule name", - "maxLength": 128, - "minLength": 1, - "title": "Name", - "type": "string" - }, - "operator": { - "$ref": "#/components/schemas/DynamicRuleOperator", - "description": "Comparison operator" - }, - "points": { - "description": "Points awarded when matched (-100 to 100)", - "maximum": 100.0, - "minimum": -100.0, - "title": "Points", - "type": "integer" - }, - "target": { - "$ref": "#/components/schemas/DynamicRuleTarget", - "default": "custom_field", - "description": "Target feature or custom_field to evaluate" - }, - "value": { - "description": "Target comparison value (number, string, or list)", - "title": "Value" - } - }, - "required": [ - "id", - "name", - "operator", - "value", - "points" - ], - "title": "DynamicRule", - "type": "object" - }, - "DynamicRuleOperator": { - "enum": [ - "eq", - "neq", - "gt", - "gte", - "lt", - "lte", - "contains", - "in" - ], - "title": "DynamicRuleOperator", - "type": "string" - }, - "DynamicRuleTarget": { - "enum": [ - "student_age", - "lead_source", - "course_interest", - "study_area", - "contact_message_recency_days", - "conversation_count", - "message_count", - "custom_field" - ], - "title": "DynamicRuleTarget", - "type": "string" - }, - "EffortProfile": { - "enum": [ - "light", - "medium", - "heavy" - ], - "title": "EffortProfile", - "type": "string" - }, - "ErrorResponse": { - "properties": { - "detail": { - "description": "Human-readable error description", - "title": "Detail", - "type": "string" - }, - "error_code": { - "description": "Machine-readable error code", - "title": "Error Code", - "type": "string" - } - }, - "required": [ - "error_code", - "detail" - ], - "title": "ErrorResponse", - "type": "object" - }, - "FallbackPolicy": { - "enum": [ - "rules_only_review", - "fail_run" - ], - "title": "FallbackPolicy", - "type": "string" - }, - "HTTPValidationError": { - "properties": { - "detail": { - "items": { - "$ref": "#/components/schemas/ValidationError" - }, - "title": "Detail", - "type": "array" - } - }, - "title": "HTTPValidationError", - "type": "object" - }, - "HealthResponse": { - "additionalProperties": false, - "properties": { - "database": { - "default": "connected", - "enum": [ - "connected", - "unreachable", - "unconfigured" - ], - "title": "Database", - "type": "string" - }, - "status": { - "default": "ok", - "enum": [ - "ok", - "unhealthy" - ], - "title": "Status", - "type": "string" - }, - "version": { - "default": "0.1.0", - "title": "Version", - "type": "string" - } - }, - "title": "HealthResponse", - "type": "object" - }, - "HitlPolicy": { - "properties": { - "confidence_threshold": { - "default": 0.8, - "maximum": 1.0, - "minimum": 0.0, - "title": "Confidence Threshold", - "type": "number" - }, - "policy_version": { - "default": "1.0.0", - "maxLength": 64, - "title": "Policy Version", - "type": "string" - } - }, - "title": "HitlPolicy", - "type": "object" - }, - "IdentityContext": { - "properties": { - "contact_id": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Contact Id" - }, - "details": { - "additionalProperties": true, - "title": "Details", - "type": "object" - }, - "match_quality": { - "anyOf": [ - { - "enum": [ - "direct", - "phone_and_email", - "phone_and_name", - "phone_only" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Match Quality" - } - }, - "title": "IdentityContext", - "type": "object" - }, - "LeadProfile": { - "properties": { - "custom_fields": { - "additionalProperties": true, - "title": "Custom Fields", - "type": "object" - }, - "email": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Email" - }, - "ems_lead_id": { - "maxLength": 255, - "minLength": 1, - "title": "Ems Lead Id", - "type": "string" - }, - "name": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Name" - }, - "phone": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Phone" - }, - "status": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Status" - } - }, - "required": [ - "ems_lead_id" - ], - "title": "LeadProfile", - "type": "object" - }, - "MessageEvidence": { - "properties": { - "content": { - "minLength": 1, - "title": "Content", - "type": "string" - }, - "message_id": { - "maxLength": 255, - "minLength": 1, - "title": "Message Id", - "type": "string" - }, - "sender_type": { - "$ref": "#/components/schemas/MessageSenderType" - }, - "sent_at": { - "description": "Timezone-aware timestamp of when message was sent", - "format": "date-time", - "title": "Sent At", - "type": "string" - } - }, - "required": [ - "message_id", - "sender_type", - "content", - "sent_at" - ], - "title": "MessageEvidence", - "type": "object" - }, - "MessageSenderType": { - "enum": [ - "contact", - "agent", - "bot" - ], - "type": "string" - }, - "RuleBreakdownItem": { - "properties": { - "detail": { - "title": "Detail", - "type": "string" - }, - "points_awarded": { - "title": "Points Awarded", - "type": "integer" - }, - "rule_name": { - "title": "Rule Name", - "type": "string" - }, - "status": { - "$ref": "#/components/schemas/RuleEvaluationStatus" - } - }, - "required": [ - "rule_name", - "status", - "points_awarded", - "detail" - ], - "title": "RuleBreakdownItem", - "type": "object" - }, - "RuleEvaluationStatus": { - "enum": [ - "matched", - "not_matched", - "skipped_missing_data" - ], - "title": "RuleEvaluationStatus", - "type": "string" - }, - "RuleMetric": { - "enum": [ - "profile_field_count", - "conversation_count", - "message_count" - ], - "title": "RuleMetric", - "type": "string" - }, - "ScoringConfig": { - "properties": { - "age_primary_max": { - "default": 15, - "maximum": 100.0, - "minimum": 0.0, - "title": "Age Primary Max", - "type": "integer" - }, - "age_primary_min": { - "default": 3, - "maximum": 100.0, - "minimum": 0.0, - "title": "Age Primary Min", - "type": "integer" - }, - "age_primary_points": { - "default": 40, - "maximum": 100.0, - "minimum": 0.0, - "title": "Age Primary Points", - "type": "integer" - }, - "age_secondary_max": { - "default": 18, - "maximum": 100.0, - "minimum": 0.0, - "title": "Age Secondary Max", - "type": "integer" - }, - "age_secondary_min": { - "default": 16, - "maximum": 100.0, - "minimum": 0.0, - "title": "Age Secondary Min", - "type": "integer" - }, - "age_secondary_points": { - "default": 20, - "maximum": 100.0, - "minimum": 0.0, - "title": "Age Secondary Points", - "type": "integer" - }, - "ai_model": { - "default": "openai/gpt-4o-mini", - "maxLength": 64, - "minLength": 1, - "title": "Ai Model", - "type": "string" - }, - "ai_timeout_seconds": { - "default": 10.0, - "exclusiveMinimum": 0.0, - "title": "Ai Timeout Seconds", - "type": "number" - }, - "ai_weight": { - "default": 0.6, - "maximum": 1.0, - "minimum": 0.0, - "title": "Ai Weight", - "type": "number" - }, - "base_score": { - "default": 0, - "maximum": 100.0, - "minimum": 0.0, - "title": "Base Score", - "type": "integer" - }, - "completeness_threshold": { - "default": 0.5, - "maximum": 1.0, - "minimum": 0.0, - "title": "Completeness Threshold", - "type": "number" - }, - "confidence": { - "maximum": 1.0, - "minimum": 0.0, - "title": "Confidence", - "type": "number" - }, - "confidence_threshold": { - "maximum": 1.0, - "minimum": 0.0, - "title": "Confidence Threshold", - "type": "number" - }, - "dynamic_rules": { - "default": [], - "description": "Operator-configured dynamic rules evaluated against lead features", - "items": { - "$ref": "#/components/schemas/DynamicRule" - }, - "title": "Dynamic Rules", - "type": "array" - }, - "education_demo_enabled": { - "default": false, - "title": "Education Demo Enabled", - "type": "boolean" - }, - "effort_profile": { - "$ref": "#/components/schemas/EffortProfile", - "default": "medium" - }, - "fallback_policy": { - "$ref": "#/components/schemas/FallbackPolicy", - "default": "rules_only_review" - }, - "hitl_policy_version": { - "maxLength": 64, - "minLength": 1, - "title": "Hitl Policy Version", - "type": "string" - }, - "medium_extract_prompt": { - "default": "B\u1ea1n l\u00e0 tr\u1ee3 l\u00fd AI ph\u00e2n t\u00edch h\u1ed9i tho\u1ea1i \u0111\u1ec3 \u0111\u00e1nh gi\u00e1 ti\u1ec1m n\u0103ng h\u1ecdc vi\u00ean. H\u00e3y tr\u00edch xu\u1ea5t b\u1eb1ng ch\u1ee9ng c\u00f3 c\u1ea5u tr\u00fac v\u1ec1 nhu c\u1ea7u kh\u00f3a h\u1ecdc, m\u1ee9c \u0111\u1ed9 quan t\u00e2m, th\u1eddi \u0111i\u1ec3m \u0111\u0103ng k\u00fd v\u00e0 r\u00e0o c\u1ea3n. Tr\u1ea3 l\u1eddi b\u1eb1ng ti\u1ebfng Vi\u1ec7t v\u00e0 kh\u00f4ng t\u1ef1 b\u1ecba d\u1eef ki\u1ec7n.", - "description": "Editable system prompt for Medium AI evidence extraction pass", - "maxLength": 4000, - "minLength": 1, - "title": "Medium Extract Prompt", - "type": "string" - }, - "medium_score_prompt": { - "default": "B\u1ea1n l\u00e0 chuy\u00ean gia ch\u1ea5m \u0111i\u1ec3m Lead gi\u00e1o d\u1ee5c t\u1eeb 0 \u0111\u1ebfn 100. D\u1ef1a tr\u00ean b\u1eb1ng ch\u1ee9ng \u0111\u00e3 tr\u00edch xu\u1ea5t, h\u00e3y \u0111\u00e1nh gi\u00e1 kh\u1ea3 n\u0103ng \u0111\u0103ng k\u00fd, \u0111\u1ed9 tin c\u1eady, l\u00fd do v\u00e0 c\u00e1c y\u1ebfu t\u1ed1 t\u00edch c\u1ef1c ho\u1eb7c ti\u00eau c\u1ef1c. L\u01b0u \u00fd: Kh\u00f4ng c\u1ed9ng l\u1ea1i \u0111i\u1ec3m c\u00e1c customer rules ho\u1eb7c metrics \u0111\u00e3 t\u00ednh \u1edf ph\u1ea7n quy t\u1eafc ri\u00eang. Tr\u1ea3 l\u1eddi b\u1eb1ng ti\u1ebfng Vi\u1ec7t v\u00e0 kh\u00f4ng t\u1ef1 b\u1ecba d\u1eef ki\u1ec7n.", - "description": "Editable system prompt for Medium AI lead scoring pass", - "maxLength": 4000, - "minLength": 1, - "title": "Medium Score Prompt", - "type": "string" - }, - "recency_cold_max_days": { - "default": 30, - "minimum": 0.0, - "title": "Recency Cold Max Days", - "type": "number" - }, - "recency_cold_points": { - "default": 5, - "maximum": 100.0, - "minimum": 0.0, - "title": "Recency Cold Points", - "type": "integer" - }, - "recency_cool_max_days": { - "default": 14, - "minimum": 0.0, - "title": "Recency Cool Max Days", - "type": "number" - }, - "recency_cool_points": { - "default": 15, - "maximum": 100.0, - "minimum": 0.0, - "title": "Recency Cool Points", - "type": "integer" - }, - "recency_hot_max_days": { - "default": 3, - "minimum": 0.0, - "title": "Recency Hot Max Days", - "type": "number" - }, - "recency_hot_points": { - "default": 40, - "maximum": 100.0, - "minimum": 0.0, - "title": "Recency Hot Points", - "type": "integer" - }, - "recency_warm_max_days": { - "default": 7, - "minimum": 0.0, - "title": "Recency Warm Max Days", - "type": "number" - }, - "recency_warm_points": { - "default": 25, - "maximum": 100.0, - "minimum": 0.0, - "title": "Recency Warm Points", - "type": "integer" - }, - "rule_weight": { - "default": 0.4, - "maximum": 1.0, - "minimum": 0.0, - "title": "Rule Weight", - "type": "number" - }, - "rules": { - "default": [], - "items": { - "$ref": "#/components/schemas/ScoringRule" - }, - "title": "Rules", - "type": "array" - }, - "selected_behavioral_metrics": { - "default": [ - "message_count", - "conversation_count", - "conversation_turn_count", - "customer_message_count", - "customer_initiated_return" - ], - "description": "System behavioral metrics calculated deterministically and supplied to AI context", - "items": { - "$ref": "#/components/schemas/BehavioralMetric" - }, - "title": "Selected Behavioral Metrics", - "type": "array" - }, - "source_call_in_points": { - "default": 15, - "maximum": 100.0, - "minimum": 0.0, - "title": "Source Call In Points", - "type": "integer" - }, - "source_campaign_points": { - "default": 8, - "maximum": 100.0, - "minimum": 0.0, - "title": "Source Campaign Points", - "type": "integer" - }, - "source_digital_points": { - "default": 10, - "maximum": 100.0, - "minimum": 0.0, - "title": "Source Digital Points", - "type": "integer" - }, - "source_other_points": { - "default": 5, - "maximum": 100.0, - "minimum": 0.0, - "title": "Source Other Points", - "type": "integer" - }, - "source_referral_points": { - "default": 20, - "maximum": 100.0, - "minimum": 0.0, - "title": "Source Referral Points", - "type": "integer" - }, - "version": { - "maxLength": 64, - "minLength": 1, - "title": "Version", - "type": "string" - } - }, - "required": [ - "version", - "confidence", - "confidence_threshold", - "hitl_policy_version" - ], - "title": "ScoringConfig", - "type": "object" - }, - "ScoringMode": { - "enum": [ - "light_ai", - "medium_ai", - "heavy_ai", - "rules_only_fallback", - "rule_only" - ], - "title": "ScoringMode", - "type": "string" - }, - "ScoringRule": { - "properties": { - "metric": { - "$ref": "#/components/schemas/RuleMetric" - }, - "minimum": { - "minimum": 0.0, - "title": "Minimum", - "type": "integer" - }, - "points": { - "maximum": 100.0, - "minimum": -100.0, - "title": "Points", - "type": "integer" - } - }, - "required": [ - "metric", - "minimum", - "points" - ], - "title": "ScoringRule", - "type": "object" - }, - "ScoringRunResponse": { - "additionalProperties": false, - "properties": { - "ai_metadata": { - "anyOf": [ - { - "$ref": "#/components/schemas/AiMetadataSuccess" - }, - { - "$ref": "#/components/schemas/AiMetadataFailure" - }, - { - "type": "null" - } - ], - "title": "Ai Metadata" - }, - "ai_score": { - "anyOf": [ - { - "maximum": 100.0, - "minimum": 0.0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Ai Score" - }, - "breakdown": { - "anyOf": [ - { - "items": { - "$ref": "#/components/schemas/RuleBreakdownItem" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "description": "Rule evaluation breakdown", - "title": "Breakdown" - }, - "category": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Public lead tier / category derived deterministically from score", - "title": "Category" - }, - "completed_at": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Completed At" - }, - "confidence": { - "anyOf": [ - { - "maximum": 1.0, - "minimum": 0.0, - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Confidence" - }, - "confidence_threshold": { - "anyOf": [ - { - "maximum": 1.0, - "minimum": 0.0, - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Confidence Threshold" - }, - "created_at": { - "description": "Creation timestamp", - "format": "date-time", - "title": "Created At", - "type": "string" - }, - "degraded": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "Degraded" - }, - "effective_config": { - "anyOf": [ - { - "$ref": "#/components/schemas/ScoringConfig" - }, - { - "type": "null" - } - ] - }, - "effective_score": { - "anyOf": [ - { - "maximum": 100.0, - "minimum": 0.0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Effective Score" - }, - "error_code": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Error Code" - }, - "explanation": { - "anyOf": [ - { - "maxLength": 1000, - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Concise public explanation using AI reasoning where available or rule summary", - "title": "Explanation" - }, - "final_score": { - "anyOf": [ - { - "maximum": 100.0, - "minimum": 0.0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Final Score" - }, - "parent_run_id": { - "anyOf": [ - { - "maxLength": 36, - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Parent scoring run ID if created via re-evaluation", - "title": "Parent Run Id" - }, - "recommended_action": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Deterministic recommended next action based on score", - "title": "Recommended Action" - }, - "retryable": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "Retryable" - }, - "rule_score": { - "anyOf": [ - { - "maximum": 100.0, - "minimum": 0.0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Rule Score" - }, - "run_id": { - "description": "Unique scoring run ID", - "maxLength": 36, - "title": "Run Id", - "type": "string" - }, - "scoring_config_version": { - "description": "Scoring config version", - "maxLength": 64, - "title": "Scoring Config Version", - "type": "string" - }, - "scoring_mode": { - "anyOf": [ - { - "$ref": "#/components/schemas/ScoringMode" - }, - { - "type": "null" - } - ] - }, - "started_at": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Started At" - }, - "status": { - "$ref": "#/components/schemas/ScoringRunStatus", - "description": "Current scoring run status" - }, - "subject_ref": { - "description": "EMS Lead reference", - "maxLength": 255, - "title": "Subject Ref", - "type": "string" - }, - "tenant_id": { - "description": "Tenant identifier", - "maxLength": 255, - "title": "Tenant Id", - "type": "string" - }, - "updated_at": { - "description": "Last updated timestamp", - "format": "date-time", - "title": "Updated At", - "type": "string" - }, - "version": { - "description": "Run version for optimistic concurrency", - "minimum": 1.0, - "title": "Version", - "type": "integer" - } - }, - "required": [ - "run_id", - "tenant_id", - "subject_ref", - "scoring_config_version", - "status", - "rule_score", - "ai_score", - "final_score", - "effective_score", - "scoring_mode", - "degraded", - "ai_metadata", - "confidence", - "confidence_threshold", - "effective_config", - "error_code", - "retryable", - "version", - "started_at", - "completed_at", - "created_at", - "updated_at" - ], - "title": "ScoringRunResponse", - "type": "object" - }, - "ScoringRunStatus": { - "enum": [ - "pending", - "running", - "completed", - "review_required", - "failed", - "superseded" - ], - "title": "ScoringRunStatus", - "type": "string" - }, - "SnapshotScope": { - "enum": [ - "trigger_only", - "linked_contact_history", - "multi_source_history" - ], - "title": "SnapshotScope", - "type": "string" - }, - "TriggerMetadata": { - "properties": { - "actor_id": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Actor Id" - }, - "trigger_conversation_id": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Trigger Conversation Id" - }, - "trigger_type": { - "default": "manual_omni", - "enum": [ - "manual_omni", - "captain_tool", - "dev_console" - ], - "title": "Trigger Type", - "type": "string" - } - }, - "title": "TriggerMetadata", - "type": "object" - }, - "TruncationMetadata": { - "properties": { - "dropped_conversations_count": { - "default": 0, - "minimum": 0.0, - "title": "Dropped Conversations Count", - "type": "integer" - }, - "dropped_messages_count": { - "default": 0, - "minimum": 0.0, - "title": "Dropped Messages Count", - "type": "integer" - }, - "reason": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Reason" - }, - "truncated": { - "default": false, - "title": "Truncated", - "type": "boolean" - } - }, - "title": "TruncationMetadata", - "type": "object" - }, - "ValidationError": { - "properties": { - "ctx": { - "title": "Context", - "type": "object" - }, - "input": { - "title": "Input" - }, - "loc": { - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer" - } - ] - }, - "title": "Location", - "type": "array" - }, - "msg": { - "title": "Message", - "type": "string" - }, - "type": { - "title": "Error Type", - "type": "string" - } - }, - "required": [ - "loc", - "msg", - "type" - ], - "title": "ValidationError", - "type": "object" - } - }, - "securitySchemes": { - "HTTPBearer": { - "description": "Bearer service token for machine caller authentication", - "scheme": "bearer", - "type": "http" - } - } - }, - "info": { - "description": "Lead Scoring service for AI Platform", - "title": "Lead Scoring Service", - "version": "0.1.0" - }, - "openapi": "3.1.0", - "paths": { - "/health": { - "get": { - "operationId": "health_check_health_get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HealthResponse" - } - } - }, - "description": "Service and database healthy" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HealthResponse" - } - } - }, - "description": "Database or critical dependency unreachable or unconfigured" - } - }, - "summary": "Service Health Check", - "tags": [ - "System" - ] - } - }, - "/v1/scoring-runs": { - "get": { - "operationId": "list_scoring_runs_v1_scoring_runs_get", - "parameters": [ - { - "description": "Maximum number of recent runs to return", - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 50, - "description": "Maximum number of recent runs to return", - "maximum": 100, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "description": "Number of recent runs to skip", - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "description": "Number of recent runs to skip", - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "description": "Tenant identifier for scoped tenant isolation query", - "in": "header", - "name": "X-Tenant-Id", - "required": true, - "schema": { - "description": "Tenant identifier for scoped tenant isolation query", - "maxLength": 255, - "minLength": 1, - "title": "X-Tenant-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/ScoringRunResponse" - }, - "title": "Response 200 List Scoring Runs V1 Scoring Runs Get", - "type": "array" - } - } - }, - "description": "Recent scoring runs scoped to the requested tenant", - "headers": { - "X-Total-Count": { - "description": "Authoritative total count of scoring runs for the tenant", - "schema": { - "type": "integer" - } - } - } - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Invalid or missing Bearer service token" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Caller not authorized for tenant" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "List recent Scoring Runs for a tenant", - "tags": [ - "Scoring Runs" - ] - }, - "post": { - "operationId": "create_scoring_run_v1_scoring_runs_post", - "parameters": [ - { - "description": "Tenant-scoped idempotency key", - "in": "header", - "name": "Idempotency-Key", - "required": true, - "schema": { - "description": "Tenant-scoped idempotency key", - "maxLength": 255, - "minLength": 1, - "title": "Idempotency-Key", - "type": "string" - } - }, - { - "description": "Originating DotB application identifier", - "in": "header", - "name": "X-DotB-App", - "required": true, - "schema": { - "description": "Originating DotB application identifier", - "maxLength": 64, - "minLength": 1, - "title": "X-Dotb-App", - "type": "string" - } - }, - { - "description": "Originating DotB feature identifier", - "in": "header", - "name": "X-DotB-Feature", - "required": true, - "schema": { - "description": "Originating DotB feature identifier", - "maxLength": 64, - "minLength": 1, - "title": "X-Dotb-Feature", - "type": "string" - } - }, - { - "description": "Optional tenant AIHub credential passed in RAM for execution attempt", - "in": "header", - "name": "X-Tenant-VKey", - "required": false, - "schema": { - "anyOf": [ - { - "maxLength": 1024, - "minLength": 0, - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Optional tenant AIHub credential passed in RAM for execution attempt", - "title": "X-Tenant-Vkey" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateScoringRunRequest" - } - } - }, - "required": true - }, - "responses": { - "202": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateScoringRunResponse" - } - } - }, - "description": "Scoring run accepted and persisted in pending state" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Invalid or missing Bearer service token" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Caller not authorized for tenant, app, or feature" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Idempotency key conflict with mismatched payload" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Validation error or structural caps exceeded" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Scoring admission is disabled by the operational kill switch" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Admit a new Lead Scoring Run", - "tags": [ - "Scoring Runs" - ] - } - }, - "/v1/scoring-runs/configs": { - "get": { - "operationId": "list_scoring_configs_v1_scoring_runs_configs_get", - "parameters": [ - { - "in": "header", - "name": "X-Tenant-Id", - "required": true, - "schema": { - "maxLength": 255, - "minLength": 1, - "title": "X-Tenant-Id", - "type": "string" - } - }, - { - "in": "header", - "name": "X-DotB-App", - "required": true, - "schema": { - "maxLength": 64, - "minLength": 1, - "title": "X-Dotb-App", - "type": "string" - } - }, - { - "in": "header", - "name": "X-DotB-Feature", - "required": true, - "schema": { - "maxLength": 64, - "minLength": 1, - "title": "X-Dotb-Feature", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/ScoringConfig" - }, - "title": "Response List Scoring Configs V1 Scoring Runs Configs Get", - "type": "array" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "List internal scoring configs", - "tags": [ - "Scoring Runs" - ] - }, - "post": { - "operationId": "create_scoring_config_v1_scoring_runs_configs_post", - "parameters": [ - { - "in": "header", - "name": "X-Tenant-Id", - "required": true, - "schema": { - "maxLength": 255, - "minLength": 1, - "title": "X-Tenant-Id", - "type": "string" - } - }, - { - "in": "header", - "name": "X-DotB-App", - "required": true, - "schema": { - "maxLength": 64, - "minLength": 1, - "title": "X-Dotb-App", - "type": "string" - } - }, - { - "in": "header", - "name": "X-DotB-Feature", - "required": true, - "schema": { - "maxLength": 64, - "minLength": 1, - "title": "X-Dotb-Feature", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ScoringConfig" - } - } - }, - "required": true - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ScoringConfig" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Register a process-local immutable internal scoring config", - "tags": [ - "Scoring Runs" - ] - } - }, - "/v1/scoring-runs/{run_id}": { - "get": { - "operationId": "get_scoring_run_v1_scoring_runs__run_id__get", - "parameters": [ - { - "description": "Scoring run ID", - "in": "path", - "name": "run_id", - "required": true, - "schema": { - "description": "Scoring run ID", - "maxLength": 36, - "minLength": 1, - "title": "Run Id", - "type": "string" - } - }, - { - "description": "Tenant identifier for scoped tenant isolation query", - "in": "header", - "name": "X-Tenant-Id", - "required": true, - "schema": { - "description": "Tenant identifier for scoped tenant isolation query", - "maxLength": 255, - "minLength": 1, - "title": "X-Tenant-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ScoringRunResponse" - } - } - }, - "description": "Scoring run metadata and current status" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Invalid or missing Bearer service token" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Caller not authorized for tenant" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Scoring run not found for tenant" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Validation error for missing required headers" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Inspect Scoring Run status", - "tags": [ - "Scoring Runs" - ] - } - }, - "/v1/scoring-runs/{run_id}/trace": { - "get": { - "operationId": "get_scoring_run_trace_v1_scoring_runs__run_id__trace_get", - "parameters": [ - { - "description": "Scoring run ID", - "in": "path", - "name": "run_id", - "required": true, - "schema": { - "description": "Scoring run ID", - "maxLength": 36, - "minLength": 1, - "title": "Run Id", - "type": "string" - } - }, - { - "description": "Tenant identifier for scoped tenant isolation query", - "in": "header", - "name": "X-Tenant-Id", - "required": true, - "schema": { - "description": "Tenant identifier for scoped tenant isolation query", - "maxLength": 255, - "minLength": 1, - "title": "X-Tenant-Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "title": "Response Get Scoring Run Trace V1 Scoring Runs Run Id Trace Get", - "type": "object" - } - } - }, - "description": "Internal execution trace for scoring run" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Invalid or missing Bearer service token" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Caller unauthorized or trace disabled in production" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Scoring run or trace not found" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Validation error for missing required headers" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Get internal execution trace for a scoring run (non-production only)", - "tags": [ - "Scoring Runs" - ] - } - } - } -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/outcome-taxonomy.json b/old_repo/contracts/lead-scoring-contract-pack/v1/outcome-taxonomy.json deleted file mode 100644 index 076396e..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/outcome-taxonomy.json +++ /dev/null @@ -1,80 +0,0 @@ -[ - { - "description": "Run đã được persist và đang chờ execution worker.", - "lifecycle": "run", - "outcome_code": "pending", - "terminal": false - }, - { - "description": "Execution worker đã claim run và đang xử lý.", - "lifecycle": "run", - "outcome_code": "running", - "terminal": false - }, - { - "description": "Run hoàn tất với calculated score hợp lệ.", - "lifecycle": "run", - "outcome_code": "completed", - "terminal": true - }, - { - "description": "Run có kết quả nhưng bắt buộc Human-In-The-Loop review.", - "lifecycle": "run", - "outcome_code": "review_required", - "terminal": true - }, - { - "description": "Run thất bại với machine-readable error outcome.", - "lifecycle": "run", - "outcome_code": "failed", - "terminal": true - }, - { - "description": "Run cũ đã được thay thế bởi child run qua re-evaluation.", - "lifecycle": "run", - "outcome_code": "superseded", - "terminal": true - }, - { - "description": "Reviewer chấp thuận calculated score làm effective score.", - "lifecycle": "review", - "outcome_code": "approve", - "terminal": true - }, - { - "description": "Reviewer đặt effective score numeric từ 0 đến 100.", - "lifecycle": "review", - "outcome_code": "override", - "terminal": true - }, - { - "description": "Reviewer tạo child run và supersede parent run.", - "lifecycle": "review", - "outcome_code": "re_evaluate", - "terminal": true - }, - { - "description": "Omni đã nhận event để xử lý writeback bất đồng bộ.", - "lifecycle": "webhook_ingest", - "outcome_code": "accepted", - "terminal": true - }, - { - "description": "Event hoặc run đã được phản ánh trước đó.", - "lifecycle": "webhook_ingest", - "outcome_code": "duplicate", - "terminal": true - }, - { - "description": "Run mới hơn đã được phản ánh cho ordering key.", - "lifecycle": "webhook_ingest", - "outcome_code": "stale", - "terminal": true - }, - { - "description": "Hai run khác nhau có cùng run_created_at và không được tự ghi đè.", - "lifecycle": "webhook_ingest", - "outcome_code": "ordering_conflict", - "terminal": true - } -] diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/polling/contract.json b/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/polling/contract.json deleted file mode 100644 index 2382723..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/polling/contract.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "api_major_version": "v1", - "contract_pack_version": "0.1.0", - "endpoint_path": "/v1/scoring-runs/{run_id}", - "error_schema_file": "error-response.schema.json", - "headers": { - "authorization": "Bearer ", - "tenant_id": "X-Tenant-Id" - }, - "http_responses": { - "200": { - "description": "Scoring run metadata and current status", - "schema_file": "scoring-run-response.schema.json" - }, - "401": { - "description": "Invalid or missing Bearer service token", - "schema_file": "error-response.schema.json" - }, - "403": { - "description": "Caller not authorized for tenant", - "schema_file": "error-response.schema.json" - }, - "404": { - "description": "Scoring run not found for tenant", - "schema_file": "error-response.schema.json" - }, - "422": { - "description": "Validation error for missing/invalid required headers or path parameters", - "schema_file": "error-response.schema.json" - } - }, - "method": "GET", - "path_params": { - "run_id": { - "description": "Unique scoring run identifier", - "max_length": 36, - "min_length": 1, - "type": "string" - } - }, - "reserved_statuses": [], - "response_schema_file": "scoring-run-response.schema.json", - "supported_statuses": [ - "pending", - "running", - "completed", - "failed" - ] -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/polling/error-response.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/polling/error-response.schema.json deleted file mode 100644 index 1c0985c..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/polling/error-response.schema.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "properties": { - "detail": { - "description": "Human-readable error description", - "title": "Detail", - "type": "string" - }, - "error_code": { - "description": "Machine-readable error code", - "title": "Error Code", - "type": "string" - } - }, - "required": [ - "error_code", - "detail" - ], - "title": "ErrorResponse", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/polling/scoring-run-response.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/polling/scoring-run-response.schema.json deleted file mode 100644 index 84e81e0..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/polling/scoring-run-response.schema.json +++ /dev/null @@ -1,864 +0,0 @@ -{ - "$defs": { - "AiMetadataFailure": { - "additionalProperties": false, - "properties": { - "detail": { - "description": "AI error detail", - "maxLength": 255, - "minLength": 1, - "title": "Detail", - "type": "string" - }, - "error_code": { - "description": "Machine-readable AI error code", - "maxLength": 64, - "minLength": 1, - "title": "Error Code", - "type": "string" - }, - "fallback_policy": { - "$ref": "#/$defs/FallbackPolicy", - "description": "Fallback policy triggered" - } - }, - "required": [ - "error_code", - "detail", - "fallback_policy" - ], - "title": "AiMetadataFailure", - "type": "object" - }, - "AiMetadataSuccess": { - "additionalProperties": false, - "properties": { - "effort_profile": { - "$ref": "#/$defs/EffortProfile", - "description": "AI effort profile applied" - }, - "model": { - "description": "AI model used", - "maxLength": 64, - "minLength": 1, - "title": "Model", - "type": "string" - }, - "pass_count": { - "description": "Number of passes executed", - "minimum": 1, - "title": "Pass Count", - "type": "integer" - } - }, - "required": [ - "model", - "effort_profile", - "pass_count" - ], - "title": "AiMetadataSuccess", - "type": "object" - }, - "BehavioralMetric": { - "enum": [ - "message_count", - "conversation_count", - "conversation_turn_count", - "customer_message_count", - "customer_initiated_return" - ], - "title": "BehavioralMetric", - "type": "string" - }, - "DynamicRule": { - "description": "A configurable dynamic scoring rule that evaluates snapshot features.", - "properties": { - "custom_field_key": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Key inside lead_profile.custom_fields if target is custom_field", - "title": "Custom Field Key" - }, - "enabled": { - "default": true, - "description": "Whether this rule is active", - "title": "Enabled", - "type": "boolean" - }, - "id": { - "description": "Unique identifier for the rule", - "maxLength": 64, - "minLength": 1, - "title": "Id", - "type": "string" - }, - "name": { - "description": "Human-readable rule name", - "maxLength": 128, - "minLength": 1, - "title": "Name", - "type": "string" - }, - "operator": { - "$ref": "#/$defs/DynamicRuleOperator", - "description": "Comparison operator" - }, - "points": { - "description": "Points awarded when matched (-100 to 100)", - "maximum": 100, - "minimum": -100, - "title": "Points", - "type": "integer" - }, - "target": { - "$ref": "#/$defs/DynamicRuleTarget", - "default": "custom_field", - "description": "Target feature or custom_field to evaluate" - }, - "value": { - "description": "Target comparison value (number, string, or list)", - "title": "Value" - } - }, - "required": [ - "id", - "name", - "operator", - "value", - "points" - ], - "title": "DynamicRule", - "type": "object" - }, - "DynamicRuleOperator": { - "enum": [ - "eq", - "neq", - "gt", - "gte", - "lt", - "lte", - "contains", - "in" - ], - "title": "DynamicRuleOperator", - "type": "string" - }, - "DynamicRuleTarget": { - "enum": [ - "student_age", - "lead_source", - "course_interest", - "study_area", - "contact_message_recency_days", - "conversation_count", - "message_count", - "custom_field" - ], - "title": "DynamicRuleTarget", - "type": "string" - }, - "EffortProfile": { - "enum": [ - "light", - "medium", - "heavy" - ], - "title": "EffortProfile", - "type": "string" - }, - "FallbackPolicy": { - "enum": [ - "rules_only_review", - "fail_run" - ], - "title": "FallbackPolicy", - "type": "string" - }, - "RuleBreakdownItem": { - "properties": { - "detail": { - "title": "Detail", - "type": "string" - }, - "points_awarded": { - "title": "Points Awarded", - "type": "integer" - }, - "rule_name": { - "title": "Rule Name", - "type": "string" - }, - "status": { - "$ref": "#/$defs/RuleEvaluationStatus" - } - }, - "required": [ - "rule_name", - "status", - "points_awarded", - "detail" - ], - "title": "RuleBreakdownItem", - "type": "object" - }, - "RuleEvaluationStatus": { - "enum": [ - "matched", - "not_matched", - "skipped_missing_data" - ], - "title": "RuleEvaluationStatus", - "type": "string" - }, - "RuleMetric": { - "enum": [ - "profile_field_count", - "conversation_count", - "message_count" - ], - "title": "RuleMetric", - "type": "string" - }, - "ScoringConfig": { - "properties": { - "age_primary_max": { - "default": 15, - "maximum": 100, - "minimum": 0, - "title": "Age Primary Max", - "type": "integer" - }, - "age_primary_min": { - "default": 3, - "maximum": 100, - "minimum": 0, - "title": "Age Primary Min", - "type": "integer" - }, - "age_primary_points": { - "default": 40, - "maximum": 100, - "minimum": 0, - "title": "Age Primary Points", - "type": "integer" - }, - "age_secondary_max": { - "default": 18, - "maximum": 100, - "minimum": 0, - "title": "Age Secondary Max", - "type": "integer" - }, - "age_secondary_min": { - "default": 16, - "maximum": 100, - "minimum": 0, - "title": "Age Secondary Min", - "type": "integer" - }, - "age_secondary_points": { - "default": 20, - "maximum": 100, - "minimum": 0, - "title": "Age Secondary Points", - "type": "integer" - }, - "ai_model": { - "default": "openai/gpt-4o-mini", - "maxLength": 64, - "minLength": 1, - "title": "Ai Model", - "type": "string" - }, - "ai_timeout_seconds": { - "default": 10.0, - "exclusiveMinimum": 0, - "title": "Ai Timeout Seconds", - "type": "number" - }, - "ai_weight": { - "default": 0.6, - "maximum": 1.0, - "minimum": 0.0, - "title": "Ai Weight", - "type": "number" - }, - "base_score": { - "default": 0, - "maximum": 100, - "minimum": 0, - "title": "Base Score", - "type": "integer" - }, - "completeness_threshold": { - "default": 0.5, - "maximum": 1.0, - "minimum": 0.0, - "title": "Completeness Threshold", - "type": "number" - }, - "confidence": { - "maximum": 1.0, - "minimum": 0.0, - "title": "Confidence", - "type": "number" - }, - "confidence_threshold": { - "maximum": 1.0, - "minimum": 0.0, - "title": "Confidence Threshold", - "type": "number" - }, - "dynamic_rules": { - "default": [], - "description": "Operator-configured dynamic rules evaluated against lead features", - "items": { - "$ref": "#/$defs/DynamicRule" - }, - "title": "Dynamic Rules", - "type": "array" - }, - "education_demo_enabled": { - "default": false, - "title": "Education Demo Enabled", - "type": "boolean" - }, - "effort_profile": { - "$ref": "#/$defs/EffortProfile", - "default": "medium" - }, - "fallback_policy": { - "$ref": "#/$defs/FallbackPolicy", - "default": "rules_only_review" - }, - "hitl_policy_version": { - "maxLength": 64, - "minLength": 1, - "title": "Hitl Policy Version", - "type": "string" - }, - "medium_extract_prompt": { - "default": "B\u1ea1n l\u00e0 tr\u1ee3 l\u00fd AI ph\u00e2n t\u00edch h\u1ed9i tho\u1ea1i \u0111\u1ec3 \u0111\u00e1nh gi\u00e1 ti\u1ec1m n\u0103ng h\u1ecdc vi\u00ean. H\u00e3y tr\u00edch xu\u1ea5t b\u1eb1ng ch\u1ee9ng c\u00f3 c\u1ea5u tr\u00fac v\u1ec1 nhu c\u1ea7u kh\u00f3a h\u1ecdc, m\u1ee9c \u0111\u1ed9 quan t\u00e2m, th\u1eddi \u0111i\u1ec3m \u0111\u0103ng k\u00fd v\u00e0 r\u00e0o c\u1ea3n. Tr\u1ea3 l\u1eddi b\u1eb1ng ti\u1ebfng Vi\u1ec7t v\u00e0 kh\u00f4ng t\u1ef1 b\u1ecba d\u1eef ki\u1ec7n.", - "description": "Editable system prompt for Medium AI evidence extraction pass", - "maxLength": 4000, - "minLength": 1, - "title": "Medium Extract Prompt", - "type": "string" - }, - "medium_score_prompt": { - "default": "B\u1ea1n l\u00e0 chuy\u00ean gia ch\u1ea5m \u0111i\u1ec3m Lead gi\u00e1o d\u1ee5c t\u1eeb 0 \u0111\u1ebfn 100. D\u1ef1a tr\u00ean b\u1eb1ng ch\u1ee9ng \u0111\u00e3 tr\u00edch xu\u1ea5t, h\u00e3y \u0111\u00e1nh gi\u00e1 kh\u1ea3 n\u0103ng \u0111\u0103ng k\u00fd, \u0111\u1ed9 tin c\u1eady, l\u00fd do v\u00e0 c\u00e1c y\u1ebfu t\u1ed1 t\u00edch c\u1ef1c ho\u1eb7c ti\u00eau c\u1ef1c. L\u01b0u \u00fd: Kh\u00f4ng c\u1ed9ng l\u1ea1i \u0111i\u1ec3m c\u00e1c customer rules ho\u1eb7c metrics \u0111\u00e3 t\u00ednh \u1edf ph\u1ea7n quy t\u1eafc ri\u00eang. Tr\u1ea3 l\u1eddi b\u1eb1ng ti\u1ebfng Vi\u1ec7t v\u00e0 kh\u00f4ng t\u1ef1 b\u1ecba d\u1eef ki\u1ec7n.", - "description": "Editable system prompt for Medium AI lead scoring pass", - "maxLength": 4000, - "minLength": 1, - "title": "Medium Score Prompt", - "type": "string" - }, - "recency_cold_max_days": { - "default": 30, - "minimum": 0, - "title": "Recency Cold Max Days", - "type": "number" - }, - "recency_cold_points": { - "default": 5, - "maximum": 100, - "minimum": 0, - "title": "Recency Cold Points", - "type": "integer" - }, - "recency_cool_max_days": { - "default": 14, - "minimum": 0, - "title": "Recency Cool Max Days", - "type": "number" - }, - "recency_cool_points": { - "default": 15, - "maximum": 100, - "minimum": 0, - "title": "Recency Cool Points", - "type": "integer" - }, - "recency_hot_max_days": { - "default": 3, - "minimum": 0, - "title": "Recency Hot Max Days", - "type": "number" - }, - "recency_hot_points": { - "default": 40, - "maximum": 100, - "minimum": 0, - "title": "Recency Hot Points", - "type": "integer" - }, - "recency_warm_max_days": { - "default": 7, - "minimum": 0, - "title": "Recency Warm Max Days", - "type": "number" - }, - "recency_warm_points": { - "default": 25, - "maximum": 100, - "minimum": 0, - "title": "Recency Warm Points", - "type": "integer" - }, - "rule_weight": { - "default": 0.4, - "maximum": 1.0, - "minimum": 0.0, - "title": "Rule Weight", - "type": "number" - }, - "rules": { - "default": [], - "items": { - "$ref": "#/$defs/ScoringRule" - }, - "title": "Rules", - "type": "array" - }, - "selected_behavioral_metrics": { - "default": [ - "message_count", - "conversation_count", - "conversation_turn_count", - "customer_message_count", - "customer_initiated_return" - ], - "description": "System behavioral metrics calculated deterministically and supplied to AI context", - "items": { - "$ref": "#/$defs/BehavioralMetric" - }, - "title": "Selected Behavioral Metrics", - "type": "array" - }, - "source_call_in_points": { - "default": 15, - "maximum": 100, - "minimum": 0, - "title": "Source Call In Points", - "type": "integer" - }, - "source_campaign_points": { - "default": 8, - "maximum": 100, - "minimum": 0, - "title": "Source Campaign Points", - "type": "integer" - }, - "source_digital_points": { - "default": 10, - "maximum": 100, - "minimum": 0, - "title": "Source Digital Points", - "type": "integer" - }, - "source_other_points": { - "default": 5, - "maximum": 100, - "minimum": 0, - "title": "Source Other Points", - "type": "integer" - }, - "source_referral_points": { - "default": 20, - "maximum": 100, - "minimum": 0, - "title": "Source Referral Points", - "type": "integer" - }, - "version": { - "maxLength": 64, - "minLength": 1, - "title": "Version", - "type": "string" - } - }, - "required": [ - "version", - "confidence", - "confidence_threshold", - "hitl_policy_version" - ], - "title": "ScoringConfig", - "type": "object" - }, - "ScoringMode": { - "enum": [ - "light_ai", - "medium_ai", - "heavy_ai", - "rules_only_fallback", - "rule_only" - ], - "title": "ScoringMode", - "type": "string" - }, - "ScoringRule": { - "properties": { - "metric": { - "$ref": "#/$defs/RuleMetric" - }, - "minimum": { - "minimum": 0, - "title": "Minimum", - "type": "integer" - }, - "points": { - "maximum": 100, - "minimum": -100, - "title": "Points", - "type": "integer" - } - }, - "required": [ - "metric", - "minimum", - "points" - ], - "title": "ScoringRule", - "type": "object" - }, - "ScoringRunStatus": { - "enum": [ - "pending", - "running", - "completed", - "review_required", - "failed", - "superseded" - ], - "title": "ScoringRunStatus", - "type": "string" - } - }, - "additionalProperties": false, - "properties": { - "ai_metadata": { - "anyOf": [ - { - "$ref": "#/$defs/AiMetadataSuccess" - }, - { - "$ref": "#/$defs/AiMetadataFailure" - }, - { - "type": "null" - } - ], - "title": "Ai Metadata" - }, - "ai_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Ai Score" - }, - "breakdown": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/RuleBreakdownItem" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Rule evaluation breakdown", - "title": "Breakdown" - }, - "category": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Public lead tier / category derived deterministically from score", - "title": "Category" - }, - "completed_at": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Completed At" - }, - "confidence": { - "anyOf": [ - { - "maximum": 1.0, - "minimum": 0.0, - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Confidence" - }, - "confidence_threshold": { - "anyOf": [ - { - "maximum": 1.0, - "minimum": 0.0, - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Confidence Threshold" - }, - "created_at": { - "description": "Creation timestamp", - "format": "date-time", - "title": "Created At", - "type": "string" - }, - "degraded": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "Degraded" - }, - "effective_config": { - "anyOf": [ - { - "$ref": "#/$defs/ScoringConfig" - }, - { - "type": "null" - } - ] - }, - "effective_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Effective Score" - }, - "error_code": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Error Code" - }, - "explanation": { - "anyOf": [ - { - "maxLength": 1000, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Concise public explanation using AI reasoning where available or rule summary", - "title": "Explanation" - }, - "final_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Final Score" - }, - "parent_run_id": { - "anyOf": [ - { - "maxLength": 36, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent scoring run ID if created via re-evaluation", - "title": "Parent Run Id" - }, - "recommended_action": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Deterministic recommended next action based on score", - "title": "Recommended Action" - }, - "retryable": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "Retryable" - }, - "rule_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Rule Score" - }, - "run_id": { - "description": "Unique scoring run ID", - "maxLength": 36, - "title": "Run Id", - "type": "string" - }, - "scoring_config_version": { - "description": "Scoring config version", - "maxLength": 64, - "title": "Scoring Config Version", - "type": "string" - }, - "scoring_mode": { - "anyOf": [ - { - "$ref": "#/$defs/ScoringMode" - }, - { - "type": "null" - } - ] - }, - "started_at": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Started At" - }, - "status": { - "$ref": "#/$defs/ScoringRunStatus", - "description": "Current scoring run status" - }, - "subject_ref": { - "description": "EMS Lead reference", - "maxLength": 255, - "title": "Subject Ref", - "type": "string" - }, - "tenant_id": { - "description": "Tenant identifier", - "maxLength": 255, - "title": "Tenant Id", - "type": "string" - }, - "updated_at": { - "description": "Last updated timestamp", - "format": "date-time", - "title": "Updated At", - "type": "string" - }, - "version": { - "description": "Run version for optimistic concurrency", - "minimum": 1, - "title": "Version", - "type": "integer" - } - }, - "required": [ - "run_id", - "tenant_id", - "subject_ref", - "scoring_config_version", - "status", - "rule_score", - "ai_score", - "final_score", - "effective_score", - "scoring_mode", - "degraded", - "ai_metadata", - "confidence", - "confidence_threshold", - "effective_config", - "error_code", - "retryable", - "version", - "started_at", - "completed_at", - "created_at", - "updated_at" - ], - "title": "ScoringRunResponse", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/contract.json b/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/contract.json deleted file mode 100644 index a3cdf21..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/contract.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "canonical_input": "..", - "contract_pack_version": "0.1.0", - "endpoint_path": "/api/v1/internal/lead-scoring/events", - "event_type": "lead_scoring.run.terminal", - "event_types": [ - "lead_scoring.run.terminal", - "lead_scoring.run.reviewed" - ], - "event_version": "1.0.0", - "method": "POST", - "replay_window_seconds": 300, - "signature_algorithm": "HMAC-SHA256", - "signature_version": "v1", - "supported_event_types": [ - "lead_scoring.run.terminal", - "lead_scoring.run.reviewed" - ] -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/lead-scoring-reviewed-event.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/lead-scoring-reviewed-event.schema.json deleted file mode 100644 index 3f10f8a..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/lead-scoring-reviewed-event.schema.json +++ /dev/null @@ -1,140 +0,0 @@ -{ - "$defs": { - "ReviewDecision": { - "enum": [ - "approve", - "override", - "re_evaluate" - ], - "title": "ReviewDecision", - "type": "string" - }, - "ReviewRunData": { - "additionalProperties": false, - "properties": { - "calculated_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Calculated Score" - }, - "decision": { - "$ref": "#/$defs/ReviewDecision" - }, - "effective_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Effective Score" - }, - "reviewed_by": { - "maxLength": 255, - "minLength": 1, - "title": "Reviewed By", - "type": "string" - }, - "status": { - "title": "Status", - "type": "string" - }, - "version": { - "minimum": 1, - "title": "Version", - "type": "integer" - } - }, - "required": [ - "status", - "decision", - "version", - "reviewed_by" - ], - "title": "ReviewRunData", - "type": "object" - } - }, - "additionalProperties": false, - "properties": { - "data": { - "$ref": "#/$defs/ReviewRunData" - }, - "event_id": { - "format": "uuid", - "title": "Event Id", - "type": "string" - }, - "event_type": { - "const": "lead_scoring.run.reviewed", - "title": "Event Type", - "type": "string" - }, - "event_version": { - "const": "1.0.0", - "title": "Event Version", - "type": "string" - }, - "occurred_at": { - "format": "date-time", - "title": "Occurred At", - "type": "string" - }, - "run_created_at": { - "format": "date-time", - "title": "Run Created At", - "type": "string" - }, - "run_id": { - "format": "uuid", - "title": "Run Id", - "type": "string" - }, - "scoring_config_version": { - "maxLength": 64, - "minLength": 1, - "title": "Scoring Config Version", - "type": "string" - }, - "subject_ref": { - "maxLength": 255, - "minLength": 1, - "title": "Subject Ref", - "type": "string" - }, - "tenant_id": { - "maxLength": 255, - "minLength": 1, - "title": "Tenant Id", - "type": "string" - } - }, - "required": [ - "event_id", - "event_type", - "event_version", - "tenant_id", - "subject_ref", - "scoring_config_version", - "run_id", - "run_created_at", - "occurred_at", - "data" - ], - "title": "LeadScoringReviewedEvent", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/lead-scoring-terminal-event.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/lead-scoring-terminal-event.schema.json deleted file mode 100644 index 95fc9ee..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/lead-scoring-terminal-event.schema.json +++ /dev/null @@ -1,251 +0,0 @@ -{ - "$defs": { - "TerminalRunData": { - "additionalProperties": false, - "properties": { - "ai_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Ai Score" - }, - "category": { - "anyOf": [ - { - "maxLength": 32, - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Category" - }, - "confidence": { - "anyOf": [ - { - "maximum": 1.0, - "minimum": 0.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Confidence" - }, - "degraded": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Degraded" - }, - "effective_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Effective Score" - }, - "error_code": { - "anyOf": [ - { - "maxLength": 64, - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Error Code" - }, - "explanation": { - "anyOf": [ - { - "maxLength": 2000, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Explanation" - }, - "final_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Final Score" - }, - "recommended_action": { - "anyOf": [ - { - "maxLength": 2000, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Recommended Action" - }, - "retryable": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Retryable" - }, - "rule_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Rule Score" - }, - "scoring_mode": { - "anyOf": [ - { - "maxLength": 32, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Scoring Mode" - }, - "status": { - "enum": [ - "completed", - "failed" - ], - "title": "Status", - "type": "string" - } - }, - "required": [ - "status" - ], - "title": "TerminalRunData", - "type": "object" - } - }, - "additionalProperties": false, - "properties": { - "data": { - "$ref": "#/$defs/TerminalRunData" - }, - "event_id": { - "format": "uuid", - "title": "Event Id", - "type": "string" - }, - "event_type": { - "const": "lead_scoring.run.terminal", - "title": "Event Type", - "type": "string" - }, - "event_version": { - "const": "1.0.0", - "title": "Event Version", - "type": "string" - }, - "occurred_at": { - "format": "date-time", - "title": "Occurred At", - "type": "string" - }, - "run_created_at": { - "format": "date-time", - "title": "Run Created At", - "type": "string" - }, - "run_id": { - "format": "uuid", - "title": "Run Id", - "type": "string" - }, - "scoring_config_version": { - "maxLength": 64, - "minLength": 1, - "title": "Scoring Config Version", - "type": "string" - }, - "subject_ref": { - "maxLength": 255, - "minLength": 1, - "title": "Subject Ref", - "type": "string" - }, - "tenant_id": { - "maxLength": 255, - "minLength": 1, - "title": "Tenant Id", - "type": "string" - } - }, - "required": [ - "event_id", - "event_type", - "event_version", - "tenant_id", - "subject_ref", - "scoring_config_version", - "run_id", - "run_created_at", - "occurred_at", - "data" - ], - "title": "LeadScoringTerminalEvent", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/signature-vectors.json b/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/signature-vectors.json deleted file mode 100644 index 70f1df0..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/signature-vectors.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "algorithm": "HMAC-SHA256", - "canonical_input": "..", - "headers": { - "event_id": "X-DotB-Webhook-Id", - "key_id": "X-DotB-Webhook-Key-Id", - "signature": "X-DotB-Webhook-Signature", - "timestamp": "X-DotB-Webhook-Timestamp" - }, - "replay_window_seconds": 300, - "signature_version": "v1", - "test_secret": "whsec_test_contract_v1_not_for_production", - "vectors": [ - { - "body_file": "fixtures/completed.json", - "event_id": "11111111-1111-4111-8111-111111111111", - "key_id": "pilot-test-2026-01", - "signature": "v1=ac4c01e2c237185d42a178a00ec4175893e3af456495f503369a2b4bfc6e58a0", - "timestamp": "1787832600" - }, - { - "body_file": "fixtures/review_required.json", - "event_id": "22222222-2222-4222-8222-222222222222", - "key_id": "pilot-test-2026-01", - "signature": "v1=db51e5cecd34ef4b8bce843ddfc4d757c81afd3436ca0f932f1123ce215cfe46", - "timestamp": "1787832600" - }, - { - "body_file": "fixtures/worker_interrupted.json", - "event_id": "33333333-3333-4333-8333-333333333333", - "key_id": "pilot-test-2026-01", - "signature": "v1=e6a7bef67ed3c29634a35da49cb1b713c8fc8e53f8f0a6bd5449950ed368d661", - "timestamp": "1787832600" - }, - { - "body_file": "fixtures/reviewed_approve.json", - "event_id": "44444444-4444-4444-8444-444444444444", - "key_id": "pilot-test-2026-01", - "signature": "v1=021d40e273ad75fce6822fdec1a010b28a8b8dc55f850783e3afddd229636875", - "timestamp": "1787832600" - }, - { - "body_file": "fixtures/reviewed_override.json", - "event_id": "55555555-5555-4555-8555-555555555555", - "key_id": "pilot-test-2026-01", - "signature": "v1=ba06a10e0ed2badfd92a670b091b3187a03968d3abd5e24466384774e1606326", - "timestamp": "1787832600" - }, - { - "body_file": "fixtures/reviewed_reevaluate.json", - "event_id": "66666666-6666-4666-8666-666666666666", - "key_id": "pilot-test-2026-01", - "signature": "v1=3276fdafc130962fc8cea8ccd842827949d07efa3d6cdf08d1a8493e4ab62018", - "timestamp": "1787832600" - } - ] -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/webhook-ingest-error-response.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/webhook-ingest-error-response.schema.json deleted file mode 100644 index 15c59cd..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/webhook-ingest-error-response.schema.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "additionalProperties": false, - "properties": { - "detail": { - "const": "Webhook destination is not available", - "title": "Detail", - "type": "string" - }, - "error_code": { - "const": "subscriber_tenant_unavailable", - "title": "Error Code", - "type": "string" - } - }, - "required": [ - "error_code", - "detail" - ], - "title": "WebhookIngestErrorResponse", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/webhook-ingest-response.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/webhook-ingest-response.schema.json deleted file mode 100644 index 9bc2497..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/profiles/webhook/webhook-ingest-response.schema.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "additionalProperties": false, - "properties": { - "event_id": { - "format": "uuid", - "title": "Event Id", - "type": "string" - }, - "status": { - "enum": [ - "accepted", - "duplicate", - "stale", - "ordering_conflict" - ], - "title": "Status", - "type": "string" - } - }, - "required": [ - "status", - "event_id" - ], - "title": "WebhookIngestResponse", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/admission-request.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/admission-request.schema.json deleted file mode 100644 index b310fc3..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/admission-request.schema.json +++ /dev/null @@ -1,532 +0,0 @@ -{ - "$defs": { - "CanonicalSnapshot": { - "properties": { - "captured_at": { - "description": "Timezone-aware timestamp when snapshot evidence was frozen", - "format": "date-time", - "title": "Captured At", - "type": "string" - }, - "conversations": { - "items": { - "$ref": "#/$defs/ConversationEvidence" - }, - "title": "Conversations", - "type": "array" - }, - "hitl_policy": { - "anyOf": [ - { - "$ref": "#/$defs/HitlPolicy" - }, - { - "type": "null" - } - ], - "default": null - }, - "identity_context": { - "anyOf": [ - { - "$ref": "#/$defs/IdentityContext" - }, - { - "type": "null" - } - ], - "default": null - }, - "lead_profile": { - "$ref": "#/$defs/LeadProfile" - }, - "scope": { - "$ref": "#/$defs/SnapshotScope", - "default": "linked_contact_history" - }, - "trigger_metadata": { - "anyOf": [ - { - "$ref": "#/$defs/TriggerMetadata" - }, - { - "type": "null" - } - ], - "default": null - }, - "truncation": { - "anyOf": [ - { - "$ref": "#/$defs/TruncationMetadata" - }, - { - "type": "null" - } - ], - "default": null - }, - "version": { - "default": "1.0.0", - "maxLength": 64, - "title": "Version", - "type": "string" - } - }, - "required": [ - "captured_at", - "lead_profile" - ], - "title": "CanonicalSnapshot", - "type": "object" - }, - "ConversationEvidence": { - "properties": { - "assigned_agent_id": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Assigned Agent Id" - }, - "channel": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Channel" - }, - "conversation_id": { - "maxLength": 255, - "minLength": 1, - "title": "Conversation Id", - "type": "string" - }, - "messages": { - "items": { - "$ref": "#/$defs/MessageEvidence" - }, - "title": "Messages", - "type": "array" - } - }, - "required": [ - "conversation_id" - ], - "title": "ConversationEvidence", - "type": "object" - }, - "DynamicRule": { - "description": "A configurable dynamic scoring rule that evaluates snapshot features.", - "properties": { - "custom_field_key": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Key inside lead_profile.custom_fields if target is custom_field", - "title": "Custom Field Key" - }, - "enabled": { - "default": true, - "description": "Whether this rule is active", - "title": "Enabled", - "type": "boolean" - }, - "id": { - "description": "Unique identifier for the rule", - "maxLength": 64, - "minLength": 1, - "title": "Id", - "type": "string" - }, - "name": { - "description": "Human-readable rule name", - "maxLength": 128, - "minLength": 1, - "title": "Name", - "type": "string" - }, - "operator": { - "$ref": "#/$defs/DynamicRuleOperator", - "description": "Comparison operator" - }, - "points": { - "description": "Points awarded when matched (-100 to 100)", - "maximum": 100, - "minimum": -100, - "title": "Points", - "type": "integer" - }, - "target": { - "$ref": "#/$defs/DynamicRuleTarget", - "default": "custom_field", - "description": "Target feature or custom_field to evaluate" - }, - "value": { - "description": "Target comparison value (number, string, or list)", - "title": "Value" - } - }, - "required": [ - "id", - "name", - "operator", - "value", - "points" - ], - "title": "DynamicRule", - "type": "object" - }, - "DynamicRuleOperator": { - "enum": [ - "eq", - "neq", - "gt", - "gte", - "lt", - "lte", - "contains", - "in" - ], - "title": "DynamicRuleOperator", - "type": "string" - }, - "DynamicRuleTarget": { - "enum": [ - "student_age", - "lead_source", - "course_interest", - "study_area", - "contact_message_recency_days", - "conversation_count", - "message_count", - "custom_field" - ], - "title": "DynamicRuleTarget", - "type": "string" - }, - "HitlPolicy": { - "properties": { - "confidence_threshold": { - "default": 0.8, - "maximum": 1.0, - "minimum": 0.0, - "title": "Confidence Threshold", - "type": "number" - }, - "policy_version": { - "default": "1.0.0", - "maxLength": 64, - "title": "Policy Version", - "type": "string" - } - }, - "title": "HitlPolicy", - "type": "object" - }, - "IdentityContext": { - "properties": { - "contact_id": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Contact Id" - }, - "details": { - "additionalProperties": true, - "title": "Details", - "type": "object" - }, - "match_quality": { - "anyOf": [ - { - "enum": [ - "direct", - "phone_and_email", - "phone_and_name", - "phone_only" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Match Quality" - } - }, - "title": "IdentityContext", - "type": "object" - }, - "LeadProfile": { - "properties": { - "custom_fields": { - "additionalProperties": true, - "title": "Custom Fields", - "type": "object" - }, - "email": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Email" - }, - "ems_lead_id": { - "maxLength": 255, - "minLength": 1, - "title": "Ems Lead Id", - "type": "string" - }, - "name": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Name" - }, - "phone": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Phone" - }, - "status": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Status" - } - }, - "required": [ - "ems_lead_id" - ], - "title": "LeadProfile", - "type": "object" - }, - "MessageEvidence": { - "properties": { - "content": { - "minLength": 1, - "title": "Content", - "type": "string" - }, - "message_id": { - "maxLength": 255, - "minLength": 1, - "title": "Message Id", - "type": "string" - }, - "sender_type": { - "$ref": "#/$defs/MessageSenderType" - }, - "sent_at": { - "description": "Timezone-aware timestamp of when message was sent", - "format": "date-time", - "title": "Sent At", - "type": "string" - } - }, - "required": [ - "message_id", - "sender_type", - "content", - "sent_at" - ], - "title": "MessageEvidence", - "type": "object" - }, - "MessageSenderType": { - "enum": [ - "contact", - "agent", - "bot" - ], - "type": "string" - }, - "SnapshotScope": { - "enum": [ - "trigger_only", - "linked_contact_history", - "multi_source_history" - ], - "title": "SnapshotScope", - "type": "string" - }, - "TriggerMetadata": { - "properties": { - "actor_id": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Actor Id" - }, - "trigger_conversation_id": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Trigger Conversation Id" - }, - "trigger_type": { - "default": "manual_omni", - "enum": [ - "manual_omni", - "captain_tool", - "dev_console" - ], - "title": "Trigger Type", - "type": "string" - } - }, - "title": "TriggerMetadata", - "type": "object" - }, - "TruncationMetadata": { - "properties": { - "dropped_conversations_count": { - "default": 0, - "minimum": 0, - "title": "Dropped Conversations Count", - "type": "integer" - }, - "dropped_messages_count": { - "default": 0, - "minimum": 0, - "title": "Dropped Messages Count", - "type": "integer" - }, - "reason": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Reason" - }, - "truncated": { - "default": false, - "title": "Truncated", - "type": "boolean" - } - }, - "title": "TruncationMetadata", - "type": "object" - } - }, - "properties": { - "rule_overrides": { - "description": "Inline dynamic rule overrides (maximum 30 rules)", - "items": { - "$ref": "#/$defs/DynamicRule" - }, - "maxItems": 30, - "title": "Rule Overrides", - "type": "array" - }, - "scoring_config_version": { - "default": "1.0.0", - "description": "Scoring config version", - "maxLength": 64, - "minLength": 1, - "title": "Scoring Config Version", - "type": "string" - }, - "snapshot": { - "$ref": "#/$defs/CanonicalSnapshot", - "description": "Canonical Lead Snapshot containing evidence" - }, - "subject_ref": { - "description": "EMS Lead reference or identifier", - "maxLength": 255, - "minLength": 1, - "title": "Subject Ref", - "type": "string" - }, - "tenant_id": { - "description": "Tenant identifier", - "maxLength": 255, - "minLength": 1, - "title": "Tenant Id", - "type": "string" - } - }, - "required": [ - "tenant_id", - "subject_ref", - "snapshot" - ], - "title": "CreateScoringRunRequest", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/admission-response.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/admission-response.schema.json deleted file mode 100644 index 6e976a0..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/admission-response.schema.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "$defs": { - "ScoringRunStatus": { - "enum": [ - "pending", - "running", - "completed", - "review_required", - "failed", - "superseded" - ], - "title": "ScoringRunStatus", - "type": "string" - } - }, - "properties": { - "created_at": { - "description": "Creation timestamp", - "format": "date-time", - "title": "Created At", - "type": "string" - }, - "run_id": { - "description": "Server-generated unique scoring run ID", - "maxLength": 36, - "title": "Run Id", - "type": "string" - }, - "scoring_config_version": { - "description": "Scoring config version applied", - "maxLength": 64, - "title": "Scoring Config Version", - "type": "string" - }, - "status": { - "$ref": "#/$defs/ScoringRunStatus", - "default": "pending", - "description": "Initial run status" - }, - "status_url": { - "description": "URL to inspect scoring run status", - "title": "Status Url", - "type": "string" - }, - "subject_ref": { - "description": "EMS Lead reference", - "maxLength": 255, - "title": "Subject Ref", - "type": "string" - }, - "tenant_id": { - "description": "Tenant identifier", - "maxLength": 255, - "title": "Tenant Id", - "type": "string" - } - }, - "required": [ - "run_id", - "status_url", - "tenant_id", - "subject_ref", - "scoring_config_version", - "created_at" - ], - "title": "CreateScoringRunResponse", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/canonical-snapshot.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/canonical-snapshot.schema.json deleted file mode 100644 index 3a63a96..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/canonical-snapshot.schema.json +++ /dev/null @@ -1,390 +0,0 @@ -{ - "$defs": { - "ConversationEvidence": { - "properties": { - "assigned_agent_id": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Assigned Agent Id" - }, - "channel": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Channel" - }, - "conversation_id": { - "maxLength": 255, - "minLength": 1, - "title": "Conversation Id", - "type": "string" - }, - "messages": { - "items": { - "$ref": "#/$defs/MessageEvidence" - }, - "title": "Messages", - "type": "array" - } - }, - "required": [ - "conversation_id" - ], - "title": "ConversationEvidence", - "type": "object" - }, - "HitlPolicy": { - "properties": { - "confidence_threshold": { - "default": 0.8, - "maximum": 1.0, - "minimum": 0.0, - "title": "Confidence Threshold", - "type": "number" - }, - "policy_version": { - "default": "1.0.0", - "maxLength": 64, - "title": "Policy Version", - "type": "string" - } - }, - "title": "HitlPolicy", - "type": "object" - }, - "IdentityContext": { - "properties": { - "contact_id": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Contact Id" - }, - "details": { - "additionalProperties": true, - "title": "Details", - "type": "object" - }, - "match_quality": { - "anyOf": [ - { - "enum": [ - "direct", - "phone_and_email", - "phone_and_name", - "phone_only" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Match Quality" - } - }, - "title": "IdentityContext", - "type": "object" - }, - "LeadProfile": { - "properties": { - "custom_fields": { - "additionalProperties": true, - "title": "Custom Fields", - "type": "object" - }, - "email": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Email" - }, - "ems_lead_id": { - "maxLength": 255, - "minLength": 1, - "title": "Ems Lead Id", - "type": "string" - }, - "name": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Name" - }, - "phone": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Phone" - }, - "status": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Status" - } - }, - "required": [ - "ems_lead_id" - ], - "title": "LeadProfile", - "type": "object" - }, - "MessageEvidence": { - "properties": { - "content": { - "minLength": 1, - "title": "Content", - "type": "string" - }, - "message_id": { - "maxLength": 255, - "minLength": 1, - "title": "Message Id", - "type": "string" - }, - "sender_type": { - "$ref": "#/$defs/MessageSenderType" - }, - "sent_at": { - "description": "Timezone-aware timestamp of when message was sent", - "format": "date-time", - "title": "Sent At", - "type": "string" - } - }, - "required": [ - "message_id", - "sender_type", - "content", - "sent_at" - ], - "title": "MessageEvidence", - "type": "object" - }, - "MessageSenderType": { - "enum": [ - "contact", - "agent", - "bot" - ], - "type": "string" - }, - "SnapshotScope": { - "enum": [ - "trigger_only", - "linked_contact_history", - "multi_source_history" - ], - "title": "SnapshotScope", - "type": "string" - }, - "TriggerMetadata": { - "properties": { - "actor_id": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Actor Id" - }, - "trigger_conversation_id": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Trigger Conversation Id" - }, - "trigger_type": { - "default": "manual_omni", - "enum": [ - "manual_omni", - "captain_tool", - "dev_console" - ], - "title": "Trigger Type", - "type": "string" - } - }, - "title": "TriggerMetadata", - "type": "object" - }, - "TruncationMetadata": { - "properties": { - "dropped_conversations_count": { - "default": 0, - "minimum": 0, - "title": "Dropped Conversations Count", - "type": "integer" - }, - "dropped_messages_count": { - "default": 0, - "minimum": 0, - "title": "Dropped Messages Count", - "type": "integer" - }, - "reason": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Reason" - }, - "truncated": { - "default": false, - "title": "Truncated", - "type": "boolean" - } - }, - "title": "TruncationMetadata", - "type": "object" - } - }, - "properties": { - "captured_at": { - "description": "Timezone-aware timestamp when snapshot evidence was frozen", - "format": "date-time", - "title": "Captured At", - "type": "string" - }, - "conversations": { - "items": { - "$ref": "#/$defs/ConversationEvidence" - }, - "title": "Conversations", - "type": "array" - }, - "hitl_policy": { - "anyOf": [ - { - "$ref": "#/$defs/HitlPolicy" - }, - { - "type": "null" - } - ], - "default": null - }, - "identity_context": { - "anyOf": [ - { - "$ref": "#/$defs/IdentityContext" - }, - { - "type": "null" - } - ], - "default": null - }, - "lead_profile": { - "$ref": "#/$defs/LeadProfile" - }, - "scope": { - "$ref": "#/$defs/SnapshotScope", - "default": "linked_contact_history" - }, - "trigger_metadata": { - "anyOf": [ - { - "$ref": "#/$defs/TriggerMetadata" - }, - { - "type": "null" - } - ], - "default": null - }, - "truncation": { - "anyOf": [ - { - "$ref": "#/$defs/TruncationMetadata" - }, - { - "type": "null" - } - ], - "default": null - }, - "version": { - "default": "1.0.0", - "maxLength": 64, - "title": "Version", - "type": "string" - } - }, - "required": [ - "captured_at", - "lead_profile" - ], - "title": "CanonicalSnapshot", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/error-response.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/error-response.schema.json deleted file mode 100644 index 1c0985c..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/error-response.schema.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "properties": { - "detail": { - "description": "Human-readable error description", - "title": "Detail", - "type": "string" - }, - "error_code": { - "description": "Machine-readable error code", - "title": "Error Code", - "type": "string" - } - }, - "required": [ - "error_code", - "detail" - ], - "title": "ErrorResponse", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/error-taxonomy.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/error-taxonomy.schema.json deleted file mode 100644 index c9ff295..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/error-taxonomy.schema.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "$defs": { - "ErrorTaxonomyEntry": { - "additionalProperties": false, - "description": "Khai báo machine-readable cho lifecycle và retry của public error code.", - "properties": { - "description": { - "maxLength": 255, - "minLength": 1, - "title": "Description", - "type": "string" - }, - "error_code": { - "maxLength": 64, - "minLength": 1, - "title": "Error Code", - "type": "string" - }, - "fallback_allowed": { - "title": "Fallback Allowed", - "type": "boolean" - }, - "http_status": { - "anyOf": [ - { - "maximum": 599, - "minimum": 400, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Http Status" - }, - "lifecycle": { - "enum": [ - "admission", - "polling", - "execution", - "review", - "webhook_ingest", - "writeback" - ], - "title": "Lifecycle", - "type": "string" - }, - "owner": { - "enum": [ - "lead_scoring", - "aihub", - "dotb_omni" - ], - "title": "Owner", - "type": "string" - }, - "retryable": { - "title": "Retryable", - "type": "boolean" - }, - "transport": { - "enum": [ - "http", - "async_run", - "webhook", - "writeback" - ], - "title": "Transport", - "type": "string" - } - }, - "required": [ - "error_code", - "lifecycle", - "owner", - "transport", - "retryable", - "fallback_allowed", - "description" - ], - "title": "ErrorTaxonomyEntry", - "type": "object" - } - }, - "description": "Wrapper schema cho artifact error taxonomy đã phát hành.", - "items": { - "$ref": "#/$defs/ErrorTaxonomyEntry" - }, - "title": "ErrorTaxonomy", - "type": "array" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/lineage.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/lineage.schema.json deleted file mode 100644 index c3c2c55..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/lineage.schema.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "additionalProperties": false, - "description": "Metadata ổn định mô tả quan hệ parent/child của scoring run.", - "properties": { - "parent_run_id": { - "anyOf": [ - { - "maxLength": 36, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Parent Run Id" - }, - "relationship": { - "enum": [ - "root", - "re_evaluation_child" - ], - "title": "Relationship", - "type": "string" - }, - "run_id": { - "maxLength": 36, - "minLength": 1, - "title": "Run Id", - "type": "string" - } - }, - "required": [ - "run_id", - "relationship" - ], - "title": "ScoringRunLineage", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/outcome-taxonomy.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/outcome-taxonomy.schema.json deleted file mode 100644 index 034c4c5..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/outcome-taxonomy.schema.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "$defs": { - "OutcomeTaxonomyEntry": { - "additionalProperties": false, - "description": "Khai báo machine-readable cho các outcome đã phát hành.", - "properties": { - "description": { - "maxLength": 255, - "minLength": 1, - "title": "Description", - "type": "string" - }, - "lifecycle": { - "enum": [ - "run", - "review", - "webhook_ingest" - ], - "title": "Lifecycle", - "type": "string" - }, - "outcome_code": { - "maxLength": 64, - "minLength": 1, - "title": "Outcome Code", - "type": "string" - }, - "terminal": { - "title": "Terminal", - "type": "boolean" - } - }, - "required": [ - "outcome_code", - "lifecycle", - "terminal", - "description" - ], - "title": "OutcomeTaxonomyEntry", - "type": "object" - } - }, - "description": "Wrapper schema cho artifact outcome taxonomy đã phát hành.", - "items": { - "$ref": "#/$defs/OutcomeTaxonomyEntry" - }, - "title": "OutcomeTaxonomy", - "type": "array" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/polling-response.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/polling-response.schema.json deleted file mode 100644 index c1e43c6..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/polling-response.schema.json +++ /dev/null @@ -1,864 +0,0 @@ -{ - "$defs": { - "AiMetadataFailure": { - "additionalProperties": false, - "properties": { - "detail": { - "description": "AI error detail", - "maxLength": 255, - "minLength": 1, - "title": "Detail", - "type": "string" - }, - "error_code": { - "description": "Machine-readable AI error code", - "maxLength": 64, - "minLength": 1, - "title": "Error Code", - "type": "string" - }, - "fallback_policy": { - "$ref": "#/$defs/FallbackPolicy", - "description": "Fallback policy triggered" - } - }, - "required": [ - "error_code", - "detail", - "fallback_policy" - ], - "title": "AiMetadataFailure", - "type": "object" - }, - "AiMetadataSuccess": { - "additionalProperties": false, - "properties": { - "effort_profile": { - "$ref": "#/$defs/EffortProfile", - "description": "AI effort profile applied" - }, - "model": { - "description": "AI model used", - "maxLength": 64, - "minLength": 1, - "title": "Model", - "type": "string" - }, - "pass_count": { - "description": "Number of passes executed", - "minimum": 1, - "title": "Pass Count", - "type": "integer" - } - }, - "required": [ - "model", - "effort_profile", - "pass_count" - ], - "title": "AiMetadataSuccess", - "type": "object" - }, - "BehavioralMetric": { - "enum": [ - "message_count", - "conversation_count", - "conversation_turn_count", - "customer_message_count", - "customer_initiated_return" - ], - "title": "BehavioralMetric", - "type": "string" - }, - "DynamicRule": { - "description": "A configurable dynamic scoring rule that evaluates snapshot features.", - "properties": { - "custom_field_key": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Key inside lead_profile.custom_fields if target is custom_field", - "title": "Custom Field Key" - }, - "enabled": { - "default": true, - "description": "Whether this rule is active", - "title": "Enabled", - "type": "boolean" - }, - "id": { - "description": "Unique identifier for the rule", - "maxLength": 64, - "minLength": 1, - "title": "Id", - "type": "string" - }, - "name": { - "description": "Human-readable rule name", - "maxLength": 128, - "minLength": 1, - "title": "Name", - "type": "string" - }, - "operator": { - "$ref": "#/$defs/DynamicRuleOperator", - "description": "Comparison operator" - }, - "points": { - "description": "Points awarded when matched (-100 to 100)", - "maximum": 100, - "minimum": -100, - "title": "Points", - "type": "integer" - }, - "target": { - "$ref": "#/$defs/DynamicRuleTarget", - "default": "custom_field", - "description": "Target feature or custom_field to evaluate" - }, - "value": { - "description": "Target comparison value (number, string, or list)", - "title": "Value" - } - }, - "required": [ - "id", - "name", - "operator", - "value", - "points" - ], - "title": "DynamicRule", - "type": "object" - }, - "DynamicRuleOperator": { - "enum": [ - "eq", - "neq", - "gt", - "gte", - "lt", - "lte", - "contains", - "in" - ], - "title": "DynamicRuleOperator", - "type": "string" - }, - "DynamicRuleTarget": { - "enum": [ - "student_age", - "lead_source", - "course_interest", - "study_area", - "contact_message_recency_days", - "conversation_count", - "message_count", - "custom_field" - ], - "title": "DynamicRuleTarget", - "type": "string" - }, - "EffortProfile": { - "enum": [ - "light", - "medium", - "heavy" - ], - "title": "EffortProfile", - "type": "string" - }, - "FallbackPolicy": { - "enum": [ - "rules_only_review", - "fail_run" - ], - "title": "FallbackPolicy", - "type": "string" - }, - "RuleBreakdownItem": { - "properties": { - "detail": { - "title": "Detail", - "type": "string" - }, - "points_awarded": { - "title": "Points Awarded", - "type": "integer" - }, - "rule_name": { - "title": "Rule Name", - "type": "string" - }, - "status": { - "$ref": "#/$defs/RuleEvaluationStatus" - } - }, - "required": [ - "rule_name", - "status", - "points_awarded", - "detail" - ], - "title": "RuleBreakdownItem", - "type": "object" - }, - "RuleEvaluationStatus": { - "enum": [ - "matched", - "not_matched", - "skipped_missing_data" - ], - "title": "RuleEvaluationStatus", - "type": "string" - }, - "RuleMetric": { - "enum": [ - "profile_field_count", - "conversation_count", - "message_count" - ], - "title": "RuleMetric", - "type": "string" - }, - "ScoringConfig": { - "properties": { - "age_primary_max": { - "default": 15, - "maximum": 100, - "minimum": 0, - "title": "Age Primary Max", - "type": "integer" - }, - "age_primary_min": { - "default": 3, - "maximum": 100, - "minimum": 0, - "title": "Age Primary Min", - "type": "integer" - }, - "age_primary_points": { - "default": 40, - "maximum": 100, - "minimum": 0, - "title": "Age Primary Points", - "type": "integer" - }, - "age_secondary_max": { - "default": 18, - "maximum": 100, - "minimum": 0, - "title": "Age Secondary Max", - "type": "integer" - }, - "age_secondary_min": { - "default": 16, - "maximum": 100, - "minimum": 0, - "title": "Age Secondary Min", - "type": "integer" - }, - "age_secondary_points": { - "default": 20, - "maximum": 100, - "minimum": 0, - "title": "Age Secondary Points", - "type": "integer" - }, - "ai_model": { - "default": "openai/gpt-4o-mini", - "maxLength": 64, - "minLength": 1, - "title": "Ai Model", - "type": "string" - }, - "ai_timeout_seconds": { - "default": 10.0, - "exclusiveMinimum": 0, - "title": "Ai Timeout Seconds", - "type": "number" - }, - "ai_weight": { - "default": 0.6, - "maximum": 1.0, - "minimum": 0.0, - "title": "Ai Weight", - "type": "number" - }, - "base_score": { - "default": 0, - "maximum": 100, - "minimum": 0, - "title": "Base Score", - "type": "integer" - }, - "completeness_threshold": { - "default": 0.5, - "maximum": 1.0, - "minimum": 0.0, - "title": "Completeness Threshold", - "type": "number" - }, - "confidence": { - "maximum": 1.0, - "minimum": 0.0, - "title": "Confidence", - "type": "number" - }, - "confidence_threshold": { - "maximum": 1.0, - "minimum": 0.0, - "title": "Confidence Threshold", - "type": "number" - }, - "dynamic_rules": { - "default": [], - "description": "Operator-configured dynamic rules evaluated against lead features", - "items": { - "$ref": "#/$defs/DynamicRule" - }, - "title": "Dynamic Rules", - "type": "array" - }, - "education_demo_enabled": { - "default": false, - "title": "Education Demo Enabled", - "type": "boolean" - }, - "effort_profile": { - "$ref": "#/$defs/EffortProfile", - "default": "medium" - }, - "fallback_policy": { - "$ref": "#/$defs/FallbackPolicy", - "default": "rules_only_review" - }, - "hitl_policy_version": { - "maxLength": 64, - "minLength": 1, - "title": "Hitl Policy Version", - "type": "string" - }, - "medium_extract_prompt": { - "default": "Bạn là trợ lý AI phân tích hội thoại để đánh giá tiềm năng học viên. Hãy trích xuất bằng chứng có cấu trúc về nhu cầu khóa học, mức độ quan tâm, thời điểm đăng ký và rào cản. Trả lời bằng tiếng Việt và không tự bịa dữ kiện.", - "description": "Editable system prompt for Medium AI evidence extraction pass", - "maxLength": 4000, - "minLength": 1, - "title": "Medium Extract Prompt", - "type": "string" - }, - "medium_score_prompt": { - "default": "Bạn là chuyên gia chấm điểm Lead giáo dục từ 0 đến 100. Dựa trên bằng chứng đã trích xuất, hãy đánh giá khả năng đăng ký, độ tin cậy, lý do và các yếu tố tích cực hoặc tiêu cực. Lưu ý: Không cộng lại điểm các customer rules hoặc metrics đã tính ở phần quy tắc riêng. Trả lời bằng tiếng Việt và không tự bịa dữ kiện.", - "description": "Editable system prompt for Medium AI lead scoring pass", - "maxLength": 4000, - "minLength": 1, - "title": "Medium Score Prompt", - "type": "string" - }, - "recency_cold_max_days": { - "default": 30, - "minimum": 0, - "title": "Recency Cold Max Days", - "type": "number" - }, - "recency_cold_points": { - "default": 5, - "maximum": 100, - "minimum": 0, - "title": "Recency Cold Points", - "type": "integer" - }, - "recency_cool_max_days": { - "default": 14, - "minimum": 0, - "title": "Recency Cool Max Days", - "type": "number" - }, - "recency_cool_points": { - "default": 15, - "maximum": 100, - "minimum": 0, - "title": "Recency Cool Points", - "type": "integer" - }, - "recency_hot_max_days": { - "default": 3, - "minimum": 0, - "title": "Recency Hot Max Days", - "type": "number" - }, - "recency_hot_points": { - "default": 40, - "maximum": 100, - "minimum": 0, - "title": "Recency Hot Points", - "type": "integer" - }, - "recency_warm_max_days": { - "default": 7, - "minimum": 0, - "title": "Recency Warm Max Days", - "type": "number" - }, - "recency_warm_points": { - "default": 25, - "maximum": 100, - "minimum": 0, - "title": "Recency Warm Points", - "type": "integer" - }, - "rule_weight": { - "default": 0.4, - "maximum": 1.0, - "minimum": 0.0, - "title": "Rule Weight", - "type": "number" - }, - "rules": { - "default": [], - "items": { - "$ref": "#/$defs/ScoringRule" - }, - "title": "Rules", - "type": "array" - }, - "selected_behavioral_metrics": { - "default": [ - "message_count", - "conversation_count", - "conversation_turn_count", - "customer_message_count", - "customer_initiated_return" - ], - "description": "System behavioral metrics calculated deterministically and supplied to AI context", - "items": { - "$ref": "#/$defs/BehavioralMetric" - }, - "title": "Selected Behavioral Metrics", - "type": "array" - }, - "source_call_in_points": { - "default": 15, - "maximum": 100, - "minimum": 0, - "title": "Source Call In Points", - "type": "integer" - }, - "source_campaign_points": { - "default": 8, - "maximum": 100, - "minimum": 0, - "title": "Source Campaign Points", - "type": "integer" - }, - "source_digital_points": { - "default": 10, - "maximum": 100, - "minimum": 0, - "title": "Source Digital Points", - "type": "integer" - }, - "source_other_points": { - "default": 5, - "maximum": 100, - "minimum": 0, - "title": "Source Other Points", - "type": "integer" - }, - "source_referral_points": { - "default": 20, - "maximum": 100, - "minimum": 0, - "title": "Source Referral Points", - "type": "integer" - }, - "version": { - "maxLength": 64, - "minLength": 1, - "title": "Version", - "type": "string" - } - }, - "required": [ - "version", - "confidence", - "confidence_threshold", - "hitl_policy_version" - ], - "title": "ScoringConfig", - "type": "object" - }, - "ScoringMode": { - "enum": [ - "light_ai", - "medium_ai", - "heavy_ai", - "rules_only_fallback", - "rule_only" - ], - "title": "ScoringMode", - "type": "string" - }, - "ScoringRule": { - "properties": { - "metric": { - "$ref": "#/$defs/RuleMetric" - }, - "minimum": { - "minimum": 0, - "title": "Minimum", - "type": "integer" - }, - "points": { - "maximum": 100, - "minimum": -100, - "title": "Points", - "type": "integer" - } - }, - "required": [ - "metric", - "minimum", - "points" - ], - "title": "ScoringRule", - "type": "object" - }, - "ScoringRunStatus": { - "enum": [ - "pending", - "running", - "completed", - "review_required", - "failed", - "superseded" - ], - "title": "ScoringRunStatus", - "type": "string" - } - }, - "additionalProperties": false, - "properties": { - "ai_metadata": { - "anyOf": [ - { - "$ref": "#/$defs/AiMetadataSuccess" - }, - { - "$ref": "#/$defs/AiMetadataFailure" - }, - { - "type": "null" - } - ], - "title": "Ai Metadata" - }, - "ai_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Ai Score" - }, - "breakdown": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/RuleBreakdownItem" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Rule evaluation breakdown", - "title": "Breakdown" - }, - "category": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Public lead tier / category derived deterministically from score", - "title": "Category" - }, - "completed_at": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Completed At" - }, - "confidence": { - "anyOf": [ - { - "maximum": 1.0, - "minimum": 0.0, - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Confidence" - }, - "confidence_threshold": { - "anyOf": [ - { - "maximum": 1.0, - "minimum": 0.0, - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Confidence Threshold" - }, - "created_at": { - "description": "Creation timestamp", - "format": "date-time", - "title": "Created At", - "type": "string" - }, - "degraded": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "Degraded" - }, - "effective_config": { - "anyOf": [ - { - "$ref": "#/$defs/ScoringConfig" - }, - { - "type": "null" - } - ] - }, - "effective_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Effective Score" - }, - "error_code": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Error Code" - }, - "explanation": { - "anyOf": [ - { - "maxLength": 1000, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Concise public explanation using AI reasoning where available or rule summary", - "title": "Explanation" - }, - "final_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Final Score" - }, - "parent_run_id": { - "anyOf": [ - { - "maxLength": 36, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent scoring run ID if created via re-evaluation", - "title": "Parent Run Id" - }, - "recommended_action": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Deterministic recommended next action based on score", - "title": "Recommended Action" - }, - "retryable": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "Retryable" - }, - "rule_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Rule Score" - }, - "run_id": { - "description": "Unique scoring run ID", - "maxLength": 36, - "title": "Run Id", - "type": "string" - }, - "scoring_config_version": { - "description": "Scoring config version", - "maxLength": 64, - "title": "Scoring Config Version", - "type": "string" - }, - "scoring_mode": { - "anyOf": [ - { - "$ref": "#/$defs/ScoringMode" - }, - { - "type": "null" - } - ] - }, - "started_at": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Started At" - }, - "status": { - "$ref": "#/$defs/ScoringRunStatus", - "description": "Current scoring run status" - }, - "subject_ref": { - "description": "EMS Lead reference", - "maxLength": 255, - "title": "Subject Ref", - "type": "string" - }, - "tenant_id": { - "description": "Tenant identifier", - "maxLength": 255, - "title": "Tenant Id", - "type": "string" - }, - "updated_at": { - "description": "Last updated timestamp", - "format": "date-time", - "title": "Updated At", - "type": "string" - }, - "version": { - "description": "Run version for optimistic concurrency", - "minimum": 1, - "title": "Version", - "type": "integer" - } - }, - "required": [ - "run_id", - "tenant_id", - "subject_ref", - "scoring_config_version", - "status", - "rule_score", - "ai_score", - "final_score", - "effective_score", - "scoring_mode", - "degraded", - "ai_metadata", - "confidence", - "confidence_threshold", - "effective_config", - "error_code", - "retryable", - "version", - "started_at", - "completed_at", - "created_at", - "updated_at" - ], - "title": "ScoringRunResponse", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/review-request.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/review-request.schema.json deleted file mode 100644 index 5d721f7..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/review-request.schema.json +++ /dev/null @@ -1,594 +0,0 @@ -{ - "$defs": { - "ApproveReviewRequest": { - "additionalProperties": false, - "properties": { - "decision": { - "const": "approve", - "default": "approve", - "description": "Approve calculated score", - "title": "Decision", - "type": "string" - }, - "expected_run_version": { - "description": "Expected run version for optimistic concurrency", - "minimum": 1, - "title": "Expected Run Version", - "type": "integer" - }, - "note": { - "anyOf": [ - { - "maxLength": 2000, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional reviewer note or reason", - "title": "Note" - }, - "reviewer": { - "$ref": "#/$defs/ReviewerContext", - "description": "Caller-provided reviewer authorization context" - } - }, - "required": [ - "expected_run_version", - "reviewer" - ], - "title": "ApproveReviewRequest", - "type": "object" - }, - "CanonicalSnapshot": { - "properties": { - "captured_at": { - "description": "Timezone-aware timestamp when snapshot evidence was frozen", - "format": "date-time", - "title": "Captured At", - "type": "string" - }, - "conversations": { - "items": { - "$ref": "#/$defs/ConversationEvidence" - }, - "title": "Conversations", - "type": "array" - }, - "hitl_policy": { - "anyOf": [ - { - "$ref": "#/$defs/HitlPolicy" - }, - { - "type": "null" - } - ], - "default": null - }, - "identity_context": { - "anyOf": [ - { - "$ref": "#/$defs/IdentityContext" - }, - { - "type": "null" - } - ], - "default": null - }, - "lead_profile": { - "$ref": "#/$defs/LeadProfile" - }, - "scope": { - "$ref": "#/$defs/SnapshotScope", - "default": "linked_contact_history" - }, - "trigger_metadata": { - "anyOf": [ - { - "$ref": "#/$defs/TriggerMetadata" - }, - { - "type": "null" - } - ], - "default": null - }, - "truncation": { - "anyOf": [ - { - "$ref": "#/$defs/TruncationMetadata" - }, - { - "type": "null" - } - ], - "default": null - }, - "version": { - "default": "1.0.0", - "maxLength": 64, - "title": "Version", - "type": "string" - } - }, - "required": [ - "captured_at", - "lead_profile" - ], - "title": "CanonicalSnapshot", - "type": "object" - }, - "ConversationEvidence": { - "properties": { - "assigned_agent_id": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Assigned Agent Id" - }, - "channel": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Channel" - }, - "conversation_id": { - "maxLength": 255, - "minLength": 1, - "title": "Conversation Id", - "type": "string" - }, - "messages": { - "items": { - "$ref": "#/$defs/MessageEvidence" - }, - "title": "Messages", - "type": "array" - } - }, - "required": [ - "conversation_id" - ], - "title": "ConversationEvidence", - "type": "object" - }, - "HitlPolicy": { - "properties": { - "confidence_threshold": { - "default": 0.8, - "maximum": 1.0, - "minimum": 0.0, - "title": "Confidence Threshold", - "type": "number" - }, - "policy_version": { - "default": "1.0.0", - "maxLength": 64, - "title": "Policy Version", - "type": "string" - } - }, - "title": "HitlPolicy", - "type": "object" - }, - "IdentityContext": { - "properties": { - "contact_id": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Contact Id" - }, - "details": { - "additionalProperties": true, - "title": "Details", - "type": "object" - }, - "match_quality": { - "anyOf": [ - { - "enum": [ - "direct", - "phone_and_email", - "phone_and_name", - "phone_only" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Match Quality" - } - }, - "title": "IdentityContext", - "type": "object" - }, - "LeadProfile": { - "properties": { - "custom_fields": { - "additionalProperties": true, - "title": "Custom Fields", - "type": "object" - }, - "email": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Email" - }, - "ems_lead_id": { - "maxLength": 255, - "minLength": 1, - "title": "Ems Lead Id", - "type": "string" - }, - "name": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Name" - }, - "phone": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Phone" - }, - "status": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Status" - } - }, - "required": [ - "ems_lead_id" - ], - "title": "LeadProfile", - "type": "object" - }, - "MessageEvidence": { - "properties": { - "content": { - "minLength": 1, - "title": "Content", - "type": "string" - }, - "message_id": { - "maxLength": 255, - "minLength": 1, - "title": "Message Id", - "type": "string" - }, - "sender_type": { - "$ref": "#/$defs/MessageSenderType" - }, - "sent_at": { - "description": "Timezone-aware timestamp of when message was sent", - "format": "date-time", - "title": "Sent At", - "type": "string" - } - }, - "required": [ - "message_id", - "sender_type", - "content", - "sent_at" - ], - "title": "MessageEvidence", - "type": "object" - }, - "MessageSenderType": { - "enum": [ - "contact", - "agent", - "bot" - ], - "type": "string" - }, - "OverrideReviewRequest": { - "additionalProperties": false, - "properties": { - "decision": { - "const": "override", - "default": "override", - "description": "Override score", - "title": "Decision", - "type": "string" - }, - "expected_run_version": { - "description": "Expected run version for optimistic concurrency", - "minimum": 1, - "title": "Expected Run Version", - "type": "integer" - }, - "note": { - "anyOf": [ - { - "maxLength": 2000, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional reviewer note or reason", - "title": "Note" - }, - "override_score": { - "description": "Human override score between 0 and 100", - "maximum": 100, - "minimum": 0, - "title": "Override Score", - "type": "integer" - }, - "reviewer": { - "$ref": "#/$defs/ReviewerContext", - "description": "Caller-provided reviewer authorization context" - } - }, - "required": [ - "expected_run_version", - "reviewer", - "override_score" - ], - "title": "OverrideReviewRequest", - "type": "object" - }, - "ReEvaluateReviewRequest": { - "additionalProperties": false, - "properties": { - "decision": { - "const": "re_evaluate", - "default": "re_evaluate", - "description": "Re-evaluate with new snapshot and create child run", - "title": "Decision", - "type": "string" - }, - "expected_run_version": { - "description": "Expected run version for optimistic concurrency", - "minimum": 1, - "title": "Expected Run Version", - "type": "integer" - }, - "note": { - "anyOf": [ - { - "maxLength": 2000, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional reviewer note or reason", - "title": "Note" - }, - "reviewer": { - "$ref": "#/$defs/ReviewerContext", - "description": "Caller-provided reviewer authorization context" - }, - "snapshot": { - "$ref": "#/$defs/CanonicalSnapshot", - "description": "New Canonical Lead Snapshot containing updated evidence" - } - }, - "required": [ - "expected_run_version", - "reviewer", - "snapshot" - ], - "title": "ReEvaluateReviewRequest", - "type": "object" - }, - "ReviewerContext": { - "additionalProperties": false, - "properties": { - "actor_id": { - "description": "Actor identifier of the reviewer", - "maxLength": 255, - "minLength": 1, - "title": "Actor Id", - "type": "string" - }, - "current_assignee_id": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Caller-supplied assignment context retained for compatibility", - "title": "Current Assignee Id" - }, - "role": { - "$ref": "#/$defs/ReviewerRole", - "description": "Reviewer role supplied by the caller for audit metadata" - } - }, - "required": [ - "actor_id", - "role" - ], - "title": "ReviewerContext", - "type": "object" - }, - "ReviewerRole": { - "enum": [ - "administrator", - "agent" - ], - "title": "ReviewerRole", - "type": "string" - }, - "SnapshotScope": { - "enum": [ - "trigger_only", - "linked_contact_history", - "multi_source_history" - ], - "title": "SnapshotScope", - "type": "string" - }, - "TriggerMetadata": { - "properties": { - "actor_id": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Actor Id" - }, - "trigger_conversation_id": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Trigger Conversation Id" - }, - "trigger_type": { - "default": "manual_omni", - "enum": [ - "manual_omni", - "captain_tool", - "dev_console" - ], - "title": "Trigger Type", - "type": "string" - } - }, - "title": "TriggerMetadata", - "type": "object" - }, - "TruncationMetadata": { - "properties": { - "dropped_conversations_count": { - "default": 0, - "minimum": 0, - "title": "Dropped Conversations Count", - "type": "integer" - }, - "dropped_messages_count": { - "default": 0, - "minimum": 0, - "title": "Dropped Messages Count", - "type": "integer" - }, - "reason": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Reason" - }, - "truncated": { - "default": false, - "title": "Truncated", - "type": "boolean" - } - }, - "title": "TruncationMetadata", - "type": "object" - } - }, - "discriminator": { - "mapping": { - "approve": "#/$defs/ApproveReviewRequest", - "override": "#/$defs/OverrideReviewRequest", - "re_evaluate": "#/$defs/ReEvaluateReviewRequest" - }, - "propertyName": "decision" - }, - "oneOf": [ - { - "$ref": "#/$defs/ApproveReviewRequest" - }, - { - "$ref": "#/$defs/OverrideReviewRequest" - }, - { - "$ref": "#/$defs/ReEvaluateReviewRequest" - } - ] -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/review-response.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/review-response.schema.json deleted file mode 100644 index 1ba05f6..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/review-response.schema.json +++ /dev/null @@ -1,142 +0,0 @@ -{ - "$defs": { - "ReviewDecision": { - "enum": [ - "approve", - "override", - "re_evaluate" - ], - "title": "ReviewDecision", - "type": "string" - }, - "ScoringRunStatus": { - "enum": [ - "pending", - "running", - "completed", - "review_required", - "failed", - "superseded" - ], - "title": "ScoringRunStatus", - "type": "string" - } - }, - "additionalProperties": false, - "properties": { - "calculated_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Original calculated score", - "title": "Calculated Score" - }, - "child_run_id": { - "anyOf": [ - { - "maxLength": 36, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Server-generated child scoring run ID when decision is re_evaluate", - "title": "Child Run Id" - }, - "decision": { - "$ref": "#/$defs/ReviewDecision", - "description": "Review decision applied" - }, - "effective_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Effective score after review", - "title": "Effective Score" - }, - "note": { - "anyOf": [ - { - "maxLength": 2000, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Reviewer note", - "title": "Note" - }, - "reviewed_at": { - "description": "Timestamp of review", - "format": "date-time", - "title": "Reviewed At", - "type": "string" - }, - "reviewed_by": { - "description": "Actor ID who reviewed", - "maxLength": 255, - "title": "Reviewed By", - "type": "string" - }, - "run_id": { - "description": "Scoring run ID", - "maxLength": 36, - "title": "Run Id", - "type": "string" - }, - "status": { - "$ref": "#/$defs/ScoringRunStatus", - "description": "Updated run status (completed or superseded)" - }, - "subject_ref": { - "description": "EMS Lead reference", - "maxLength": 255, - "title": "Subject Ref", - "type": "string" - }, - "tenant_id": { - "description": "Tenant identifier", - "maxLength": 255, - "title": "Tenant Id", - "type": "string" - }, - "version": { - "description": "New run version after review", - "minimum": 1, - "title": "Version", - "type": "integer" - } - }, - "required": [ - "run_id", - "tenant_id", - "subject_ref", - "status", - "decision", - "version", - "reviewed_by", - "reviewed_at" - ], - "title": "ReviewResponse", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/reviewed-event.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/reviewed-event.schema.json deleted file mode 100644 index 3f10f8a..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/reviewed-event.schema.json +++ /dev/null @@ -1,140 +0,0 @@ -{ - "$defs": { - "ReviewDecision": { - "enum": [ - "approve", - "override", - "re_evaluate" - ], - "title": "ReviewDecision", - "type": "string" - }, - "ReviewRunData": { - "additionalProperties": false, - "properties": { - "calculated_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Calculated Score" - }, - "decision": { - "$ref": "#/$defs/ReviewDecision" - }, - "effective_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Effective Score" - }, - "reviewed_by": { - "maxLength": 255, - "minLength": 1, - "title": "Reviewed By", - "type": "string" - }, - "status": { - "title": "Status", - "type": "string" - }, - "version": { - "minimum": 1, - "title": "Version", - "type": "integer" - } - }, - "required": [ - "status", - "decision", - "version", - "reviewed_by" - ], - "title": "ReviewRunData", - "type": "object" - } - }, - "additionalProperties": false, - "properties": { - "data": { - "$ref": "#/$defs/ReviewRunData" - }, - "event_id": { - "format": "uuid", - "title": "Event Id", - "type": "string" - }, - "event_type": { - "const": "lead_scoring.run.reviewed", - "title": "Event Type", - "type": "string" - }, - "event_version": { - "const": "1.0.0", - "title": "Event Version", - "type": "string" - }, - "occurred_at": { - "format": "date-time", - "title": "Occurred At", - "type": "string" - }, - "run_created_at": { - "format": "date-time", - "title": "Run Created At", - "type": "string" - }, - "run_id": { - "format": "uuid", - "title": "Run Id", - "type": "string" - }, - "scoring_config_version": { - "maxLength": 64, - "minLength": 1, - "title": "Scoring Config Version", - "type": "string" - }, - "subject_ref": { - "maxLength": 255, - "minLength": 1, - "title": "Subject Ref", - "type": "string" - }, - "tenant_id": { - "maxLength": 255, - "minLength": 1, - "title": "Tenant Id", - "type": "string" - } - }, - "required": [ - "event_id", - "event_type", - "event_version", - "tenant_id", - "subject_ref", - "scoring_config_version", - "run_id", - "run_created_at", - "occurred_at", - "data" - ], - "title": "LeadScoringReviewedEvent", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/scoring-config-selection.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/scoring-config-selection.schema.json deleted file mode 100644 index 28bc4b9..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/scoring-config-selection.schema.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "additionalProperties": false, - "description": "Reference used by callers to select an immutable scoring config version.", - "properties": { - "version": { - "maxLength": 64, - "minLength": 1, - "title": "Version", - "type": "string" - } - }, - "required": [ - "version" - ], - "title": "ScoringConfigVersionSelection", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/scoring-config.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/scoring-config.schema.json deleted file mode 100644 index ba56d18..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/scoring-config.schema.json +++ /dev/null @@ -1,427 +0,0 @@ -{ - "$defs": { - "BehavioralMetric": { - "enum": [ - "message_count", - "conversation_count", - "conversation_turn_count", - "customer_message_count", - "customer_initiated_return" - ], - "title": "BehavioralMetric", - "type": "string" - }, - "DynamicRule": { - "description": "A configurable dynamic scoring rule that evaluates snapshot features.", - "properties": { - "custom_field_key": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Key inside lead_profile.custom_fields if target is custom_field", - "title": "Custom Field Key" - }, - "enabled": { - "default": true, - "description": "Whether this rule is active", - "title": "Enabled", - "type": "boolean" - }, - "id": { - "description": "Unique identifier for the rule", - "maxLength": 64, - "minLength": 1, - "title": "Id", - "type": "string" - }, - "name": { - "description": "Human-readable rule name", - "maxLength": 128, - "minLength": 1, - "title": "Name", - "type": "string" - }, - "operator": { - "$ref": "#/$defs/DynamicRuleOperator", - "description": "Comparison operator" - }, - "points": { - "description": "Points awarded when matched (-100 to 100)", - "maximum": 100, - "minimum": -100, - "title": "Points", - "type": "integer" - }, - "target": { - "$ref": "#/$defs/DynamicRuleTarget", - "default": "custom_field", - "description": "Target feature or custom_field to evaluate" - }, - "value": { - "description": "Target comparison value (number, string, or list)", - "title": "Value" - } - }, - "required": [ - "id", - "name", - "operator", - "value", - "points" - ], - "title": "DynamicRule", - "type": "object" - }, - "DynamicRuleOperator": { - "enum": [ - "eq", - "neq", - "gt", - "gte", - "lt", - "lte", - "contains", - "in" - ], - "title": "DynamicRuleOperator", - "type": "string" - }, - "DynamicRuleTarget": { - "enum": [ - "student_age", - "lead_source", - "course_interest", - "study_area", - "contact_message_recency_days", - "conversation_count", - "message_count", - "custom_field" - ], - "title": "DynamicRuleTarget", - "type": "string" - }, - "EffortProfile": { - "enum": [ - "light", - "medium", - "heavy" - ], - "title": "EffortProfile", - "type": "string" - }, - "FallbackPolicy": { - "enum": [ - "rules_only_review", - "fail_run" - ], - "title": "FallbackPolicy", - "type": "string" - }, - "RuleMetric": { - "enum": [ - "profile_field_count", - "conversation_count", - "message_count" - ], - "title": "RuleMetric", - "type": "string" - }, - "ScoringRule": { - "properties": { - "metric": { - "$ref": "#/$defs/RuleMetric" - }, - "minimum": { - "minimum": 0, - "title": "Minimum", - "type": "integer" - }, - "points": { - "maximum": 100, - "minimum": -100, - "title": "Points", - "type": "integer" - } - }, - "required": [ - "metric", - "minimum", - "points" - ], - "title": "ScoringRule", - "type": "object" - } - }, - "properties": { - "age_primary_max": { - "default": 15, - "maximum": 100, - "minimum": 0, - "title": "Age Primary Max", - "type": "integer" - }, - "age_primary_min": { - "default": 3, - "maximum": 100, - "minimum": 0, - "title": "Age Primary Min", - "type": "integer" - }, - "age_primary_points": { - "default": 40, - "maximum": 100, - "minimum": 0, - "title": "Age Primary Points", - "type": "integer" - }, - "age_secondary_max": { - "default": 18, - "maximum": 100, - "minimum": 0, - "title": "Age Secondary Max", - "type": "integer" - }, - "age_secondary_min": { - "default": 16, - "maximum": 100, - "minimum": 0, - "title": "Age Secondary Min", - "type": "integer" - }, - "age_secondary_points": { - "default": 20, - "maximum": 100, - "minimum": 0, - "title": "Age Secondary Points", - "type": "integer" - }, - "ai_model": { - "default": "openai/gpt-4o-mini", - "maxLength": 64, - "minLength": 1, - "title": "Ai Model", - "type": "string" - }, - "ai_timeout_seconds": { - "default": 10.0, - "exclusiveMinimum": 0, - "title": "Ai Timeout Seconds", - "type": "number" - }, - "ai_weight": { - "default": 0.6, - "maximum": 1.0, - "minimum": 0.0, - "title": "Ai Weight", - "type": "number" - }, - "base_score": { - "default": 0, - "maximum": 100, - "minimum": 0, - "title": "Base Score", - "type": "integer" - }, - "completeness_threshold": { - "default": 0.5, - "maximum": 1.0, - "minimum": 0.0, - "title": "Completeness Threshold", - "type": "number" - }, - "confidence": { - "maximum": 1.0, - "minimum": 0.0, - "title": "Confidence", - "type": "number" - }, - "confidence_threshold": { - "maximum": 1.0, - "minimum": 0.0, - "title": "Confidence Threshold", - "type": "number" - }, - "dynamic_rules": { - "default": [], - "description": "Operator-configured dynamic rules evaluated against lead features", - "items": { - "$ref": "#/$defs/DynamicRule" - }, - "title": "Dynamic Rules", - "type": "array" - }, - "education_demo_enabled": { - "default": false, - "title": "Education Demo Enabled", - "type": "boolean" - }, - "effort_profile": { - "$ref": "#/$defs/EffortProfile", - "default": "medium" - }, - "fallback_policy": { - "$ref": "#/$defs/FallbackPolicy", - "default": "rules_only_review" - }, - "hitl_policy_version": { - "maxLength": 64, - "minLength": 1, - "title": "Hitl Policy Version", - "type": "string" - }, - "medium_extract_prompt": { - "default": "Bạn là trợ lý AI phân tích hội thoại để đánh giá tiềm năng học viên. Hãy trích xuất bằng chứng có cấu trúc về nhu cầu khóa học, mức độ quan tâm, thời điểm đăng ký và rào cản. Trả lời bằng tiếng Việt và không tự bịa dữ kiện.", - "description": "Editable system prompt for Medium AI evidence extraction pass", - "maxLength": 4000, - "minLength": 1, - "title": "Medium Extract Prompt", - "type": "string" - }, - "medium_score_prompt": { - "default": "Bạn là chuyên gia chấm điểm Lead giáo dục từ 0 đến 100. Dựa trên bằng chứng đã trích xuất, hãy đánh giá khả năng đăng ký, độ tin cậy, lý do và các yếu tố tích cực hoặc tiêu cực. Lưu ý: Không cộng lại điểm các customer rules hoặc metrics đã tính ở phần quy tắc riêng. Trả lời bằng tiếng Việt và không tự bịa dữ kiện.", - "description": "Editable system prompt for Medium AI lead scoring pass", - "maxLength": 4000, - "minLength": 1, - "title": "Medium Score Prompt", - "type": "string" - }, - "recency_cold_max_days": { - "default": 30, - "minimum": 0, - "title": "Recency Cold Max Days", - "type": "number" - }, - "recency_cold_points": { - "default": 5, - "maximum": 100, - "minimum": 0, - "title": "Recency Cold Points", - "type": "integer" - }, - "recency_cool_max_days": { - "default": 14, - "minimum": 0, - "title": "Recency Cool Max Days", - "type": "number" - }, - "recency_cool_points": { - "default": 15, - "maximum": 100, - "minimum": 0, - "title": "Recency Cool Points", - "type": "integer" - }, - "recency_hot_max_days": { - "default": 3, - "minimum": 0, - "title": "Recency Hot Max Days", - "type": "number" - }, - "recency_hot_points": { - "default": 40, - "maximum": 100, - "minimum": 0, - "title": "Recency Hot Points", - "type": "integer" - }, - "recency_warm_max_days": { - "default": 7, - "minimum": 0, - "title": "Recency Warm Max Days", - "type": "number" - }, - "recency_warm_points": { - "default": 25, - "maximum": 100, - "minimum": 0, - "title": "Recency Warm Points", - "type": "integer" - }, - "rule_weight": { - "default": 0.4, - "maximum": 1.0, - "minimum": 0.0, - "title": "Rule Weight", - "type": "number" - }, - "rules": { - "default": [], - "items": { - "$ref": "#/$defs/ScoringRule" - }, - "title": "Rules", - "type": "array" - }, - "selected_behavioral_metrics": { - "default": [ - "message_count", - "conversation_count", - "conversation_turn_count", - "customer_message_count", - "customer_initiated_return" - ], - "description": "System behavioral metrics calculated deterministically and supplied to AI context", - "items": { - "$ref": "#/$defs/BehavioralMetric" - }, - "title": "Selected Behavioral Metrics", - "type": "array" - }, - "source_call_in_points": { - "default": 15, - "maximum": 100, - "minimum": 0, - "title": "Source Call In Points", - "type": "integer" - }, - "source_campaign_points": { - "default": 8, - "maximum": 100, - "minimum": 0, - "title": "Source Campaign Points", - "type": "integer" - }, - "source_digital_points": { - "default": 10, - "maximum": 100, - "minimum": 0, - "title": "Source Digital Points", - "type": "integer" - }, - "source_other_points": { - "default": 5, - "maximum": 100, - "minimum": 0, - "title": "Source Other Points", - "type": "integer" - }, - "source_referral_points": { - "default": 20, - "maximum": 100, - "minimum": 0, - "title": "Source Referral Points", - "type": "integer" - }, - "version": { - "maxLength": 64, - "minLength": 1, - "title": "Version", - "type": "string" - } - }, - "required": [ - "version", - "confidence", - "confidence_threshold", - "hitl_policy_version" - ], - "title": "ScoringConfig", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/terminal-event.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/terminal-event.schema.json deleted file mode 100644 index 95fc9ee..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/terminal-event.schema.json +++ /dev/null @@ -1,251 +0,0 @@ -{ - "$defs": { - "TerminalRunData": { - "additionalProperties": false, - "properties": { - "ai_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Ai Score" - }, - "category": { - "anyOf": [ - { - "maxLength": 32, - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Category" - }, - "confidence": { - "anyOf": [ - { - "maximum": 1.0, - "minimum": 0.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Confidence" - }, - "degraded": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Degraded" - }, - "effective_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Effective Score" - }, - "error_code": { - "anyOf": [ - { - "maxLength": 64, - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Error Code" - }, - "explanation": { - "anyOf": [ - { - "maxLength": 2000, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Explanation" - }, - "final_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Final Score" - }, - "recommended_action": { - "anyOf": [ - { - "maxLength": 2000, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Recommended Action" - }, - "retryable": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Retryable" - }, - "rule_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Rule Score" - }, - "scoring_mode": { - "anyOf": [ - { - "maxLength": 32, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Scoring Mode" - }, - "status": { - "enum": [ - "completed", - "failed" - ], - "title": "Status", - "type": "string" - } - }, - "required": [ - "status" - ], - "title": "TerminalRunData", - "type": "object" - } - }, - "additionalProperties": false, - "properties": { - "data": { - "$ref": "#/$defs/TerminalRunData" - }, - "event_id": { - "format": "uuid", - "title": "Event Id", - "type": "string" - }, - "event_type": { - "const": "lead_scoring.run.terminal", - "title": "Event Type", - "type": "string" - }, - "event_version": { - "const": "1.0.0", - "title": "Event Version", - "type": "string" - }, - "occurred_at": { - "format": "date-time", - "title": "Occurred At", - "type": "string" - }, - "run_created_at": { - "format": "date-time", - "title": "Run Created At", - "type": "string" - }, - "run_id": { - "format": "uuid", - "title": "Run Id", - "type": "string" - }, - "scoring_config_version": { - "maxLength": 64, - "minLength": 1, - "title": "Scoring Config Version", - "type": "string" - }, - "subject_ref": { - "maxLength": 255, - "minLength": 1, - "title": "Subject Ref", - "type": "string" - }, - "tenant_id": { - "maxLength": 255, - "minLength": 1, - "title": "Tenant Id", - "type": "string" - } - }, - "required": [ - "event_id", - "event_type", - "event_version", - "tenant_id", - "subject_ref", - "scoring_config_version", - "run_id", - "run_created_at", - "occurred_at", - "data" - ], - "title": "LeadScoringTerminalEvent", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/webhook-event-envelope.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/webhook-event-envelope.schema.json deleted file mode 100644 index 164df2b..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/webhook-event-envelope.schema.json +++ /dev/null @@ -1,399 +0,0 @@ -{ - "$defs": { - "LeadScoringReviewedEvent": { - "additionalProperties": false, - "properties": { - "data": { - "$ref": "#/$defs/ReviewRunData" - }, - "event_id": { - "format": "uuid", - "title": "Event Id", - "type": "string" - }, - "event_type": { - "const": "lead_scoring.run.reviewed", - "title": "Event Type", - "type": "string" - }, - "event_version": { - "const": "1.0.0", - "title": "Event Version", - "type": "string" - }, - "occurred_at": { - "format": "date-time", - "title": "Occurred At", - "type": "string" - }, - "run_created_at": { - "format": "date-time", - "title": "Run Created At", - "type": "string" - }, - "run_id": { - "format": "uuid", - "title": "Run Id", - "type": "string" - }, - "scoring_config_version": { - "maxLength": 64, - "minLength": 1, - "title": "Scoring Config Version", - "type": "string" - }, - "subject_ref": { - "maxLength": 255, - "minLength": 1, - "title": "Subject Ref", - "type": "string" - }, - "tenant_id": { - "maxLength": 255, - "minLength": 1, - "title": "Tenant Id", - "type": "string" - } - }, - "required": [ - "event_id", - "event_type", - "event_version", - "tenant_id", - "subject_ref", - "scoring_config_version", - "run_id", - "run_created_at", - "occurred_at", - "data" - ], - "title": "LeadScoringReviewedEvent", - "type": "object" - }, - "LeadScoringTerminalEvent": { - "additionalProperties": false, - "properties": { - "data": { - "$ref": "#/$defs/TerminalRunData" - }, - "event_id": { - "format": "uuid", - "title": "Event Id", - "type": "string" - }, - "event_type": { - "const": "lead_scoring.run.terminal", - "title": "Event Type", - "type": "string" - }, - "event_version": { - "const": "1.0.0", - "title": "Event Version", - "type": "string" - }, - "occurred_at": { - "format": "date-time", - "title": "Occurred At", - "type": "string" - }, - "run_created_at": { - "format": "date-time", - "title": "Run Created At", - "type": "string" - }, - "run_id": { - "format": "uuid", - "title": "Run Id", - "type": "string" - }, - "scoring_config_version": { - "maxLength": 64, - "minLength": 1, - "title": "Scoring Config Version", - "type": "string" - }, - "subject_ref": { - "maxLength": 255, - "minLength": 1, - "title": "Subject Ref", - "type": "string" - }, - "tenant_id": { - "maxLength": 255, - "minLength": 1, - "title": "Tenant Id", - "type": "string" - } - }, - "required": [ - "event_id", - "event_type", - "event_version", - "tenant_id", - "subject_ref", - "scoring_config_version", - "run_id", - "run_created_at", - "occurred_at", - "data" - ], - "title": "LeadScoringTerminalEvent", - "type": "object" - }, - "ReviewDecision": { - "enum": [ - "approve", - "override", - "re_evaluate" - ], - "title": "ReviewDecision", - "type": "string" - }, - "ReviewRunData": { - "additionalProperties": false, - "properties": { - "calculated_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Calculated Score" - }, - "decision": { - "$ref": "#/$defs/ReviewDecision" - }, - "effective_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Effective Score" - }, - "reviewed_by": { - "maxLength": 255, - "minLength": 1, - "title": "Reviewed By", - "type": "string" - }, - "status": { - "title": "Status", - "type": "string" - }, - "version": { - "minimum": 1, - "title": "Version", - "type": "integer" - } - }, - "required": [ - "status", - "decision", - "version", - "reviewed_by" - ], - "title": "ReviewRunData", - "type": "object" - }, - "TerminalRunData": { - "additionalProperties": false, - "properties": { - "ai_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Ai Score" - }, - "category": { - "anyOf": [ - { - "maxLength": 32, - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Category" - }, - "confidence": { - "anyOf": [ - { - "maximum": 1.0, - "minimum": 0.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Confidence" - }, - "degraded": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Degraded" - }, - "effective_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Effective Score" - }, - "error_code": { - "anyOf": [ - { - "maxLength": 64, - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Error Code" - }, - "explanation": { - "anyOf": [ - { - "maxLength": 2000, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Explanation" - }, - "final_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Final Score" - }, - "recommended_action": { - "anyOf": [ - { - "maxLength": 2000, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Recommended Action" - }, - "retryable": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Retryable" - }, - "rule_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Rule Score" - }, - "scoring_mode": { - "anyOf": [ - { - "maxLength": 32, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Scoring Mode" - }, - "status": { - "enum": [ - "completed", - "failed" - ], - "title": "Status", - "type": "string" - } - }, - "required": [ - "status" - ], - "title": "TerminalRunData", - "type": "object" - } - }, - "anyOf": [ - { - "$ref": "#/$defs/LeadScoringTerminalEvent" - }, - { - "$ref": "#/$defs/LeadScoringReviewedEvent" - } - ] -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/webhook-ingest-error-response.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/webhook-ingest-error-response.schema.json deleted file mode 100644 index 15c59cd..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/webhook-ingest-error-response.schema.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "additionalProperties": false, - "properties": { - "detail": { - "const": "Webhook destination is not available", - "title": "Detail", - "type": "string" - }, - "error_code": { - "const": "subscriber_tenant_unavailable", - "title": "Error Code", - "type": "string" - } - }, - "required": [ - "error_code", - "detail" - ], - "title": "WebhookIngestErrorResponse", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/webhook-ingest-response.schema.json b/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/webhook-ingest-response.schema.json deleted file mode 100644 index 9bc2497..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/schemas/webhook-ingest-response.schema.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "additionalProperties": false, - "properties": { - "event_id": { - "format": "uuid", - "title": "Event Id", - "type": "string" - }, - "status": { - "enum": [ - "accepted", - "duplicate", - "stale", - "ordering_conflict" - ], - "title": "Status", - "type": "string" - } - }, - "required": [ - "status", - "event_id" - ], - "title": "WebhookIngestResponse", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-contract-pack/v1/security-profile.json b/old_repo/contracts/lead-scoring-contract-pack/v1/security-profile.json deleted file mode 100644 index e7037f6..0000000 --- a/old_repo/contracts/lead-scoring-contract-pack/v1/security-profile.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "contract_pack_version": "0.1.0", - "service_authentication": { - "caller_binding": "registered caller, operation scope, and tenant allowlist", - "header": "Authorization", - "identity_source": "deployment-managed opaque service token", - "replay": "Service-token replay protection is owned by the deployment boundary.", - "rotation": "Provision a replacement token, verify it, then revoke the old token.", - "scheme": "Bearer" - }, - "tenant_credential": { - "forbidden_surfaces": [ - "database", - "queue", - "cache", - "checkpoint", - "logs", - "traces", - "events", - "browser" - ], - "header": "X-Tenant-VKey", - "owner": "caller and AIHub", - "persistence": "in-memory execution attempt only" - }, - "webhook_authentication": { - "algorithm": "HMAC-SHA256", - "headers": [ - "X-DotB-Webhook-Id", - "X-DotB-Webhook-Key-Id", - "X-DotB-Webhook-Timestamp", - "X-DotB-Webhook-Signature" - ], - "rotation": "Receiver may accept active and immediately previous key during a bounded window." - } -} diff --git a/old_repo/contracts/lead-scoring-polling/v1/contract.json b/old_repo/contracts/lead-scoring-polling/v1/contract.json deleted file mode 100644 index 2382723..0000000 --- a/old_repo/contracts/lead-scoring-polling/v1/contract.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "api_major_version": "v1", - "contract_pack_version": "0.1.0", - "endpoint_path": "/v1/scoring-runs/{run_id}", - "error_schema_file": "error-response.schema.json", - "headers": { - "authorization": "Bearer ", - "tenant_id": "X-Tenant-Id" - }, - "http_responses": { - "200": { - "description": "Scoring run metadata and current status", - "schema_file": "scoring-run-response.schema.json" - }, - "401": { - "description": "Invalid or missing Bearer service token", - "schema_file": "error-response.schema.json" - }, - "403": { - "description": "Caller not authorized for tenant", - "schema_file": "error-response.schema.json" - }, - "404": { - "description": "Scoring run not found for tenant", - "schema_file": "error-response.schema.json" - }, - "422": { - "description": "Validation error for missing/invalid required headers or path parameters", - "schema_file": "error-response.schema.json" - } - }, - "method": "GET", - "path_params": { - "run_id": { - "description": "Unique scoring run identifier", - "max_length": 36, - "min_length": 1, - "type": "string" - } - }, - "reserved_statuses": [], - "response_schema_file": "scoring-run-response.schema.json", - "supported_statuses": [ - "pending", - "running", - "completed", - "failed" - ] -} diff --git a/old_repo/contracts/lead-scoring-polling/v1/error-response.schema.json b/old_repo/contracts/lead-scoring-polling/v1/error-response.schema.json deleted file mode 100644 index 1c0985c..0000000 --- a/old_repo/contracts/lead-scoring-polling/v1/error-response.schema.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "properties": { - "detail": { - "description": "Human-readable error description", - "title": "Detail", - "type": "string" - }, - "error_code": { - "description": "Machine-readable error code", - "title": "Error Code", - "type": "string" - } - }, - "required": [ - "error_code", - "detail" - ], - "title": "ErrorResponse", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-polling/v1/fixtures/completed.json b/old_repo/contracts/lead-scoring-polling/v1/fixtures/completed.json deleted file mode 100644 index d957446..0000000 --- a/old_repo/contracts/lead-scoring-polling/v1/fixtures/completed.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "ai_metadata": { - "effort_profile": "medium", - "model": "openai/gpt-4o-mini", - "pass_count": 2 - }, - "ai_score": 78, - "breakdown": null, - "category": "hot", - "completed_at": "2026-08-27T12:00:03Z", - "confidence": 0.91, - "confidence_threshold": 0.8, - "created_at": "2026-08-27T12:00:00Z", - "degraded": false, - "effective_config": null, - "effective_score": 80, - "error_code": null, - "explanation": "Lead \u0111\u1ea1t 80/100 sau khi k\u1ebft h\u1ee3p b\u1ed9 quy t\u1eafc v\u00e0 ph\u00e2n t\u00edch h\u1ed9i tho\u1ea1i b\u1eb1ng AI.", - "final_score": 80, - "parent_run_id": null, - "recommended_action": "Li\u00ean h\u1ec7 v\u1edbi Lead trong 24 gi\u1edd \u0111\u1ec3 x\u00e1c nh\u1eadn b\u01b0\u1edbc \u0111\u0103ng k\u00fd ti\u1ebfp theo.", - "retryable": null, - "rule_score": 82, - "run_id": "33333333-3333-4333-8333-333333333333", - "scoring_config_version": "1.0.0", - "scoring_mode": "medium_ai", - "started_at": "2026-08-27T12:00:01Z", - "status": "completed", - "subject_ref": "ems-lead-1003", - "tenant_id": "omni-account-42", - "updated_at": "2026-08-27T12:00:03Z", - "version": 1 -} diff --git a/old_repo/contracts/lead-scoring-polling/v1/fixtures/failed.json b/old_repo/contracts/lead-scoring-polling/v1/fixtures/failed.json deleted file mode 100644 index 5c8c058..0000000 --- a/old_repo/contracts/lead-scoring-polling/v1/fixtures/failed.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "ai_metadata": null, - "ai_score": null, - "breakdown": null, - "category": "cold", - "completed_at": "2026-08-27T12:07:01Z", - "confidence": null, - "confidence_threshold": 0.8, - "created_at": "2026-08-27T12:02:00Z", - "degraded": null, - "effective_config": null, - "effective_score": null, - "error_code": "worker_interrupted", - "explanation": "Kh\u00f4ng th\u1ec3 ch\u1ea5m \u0111i\u1ec3m.", - "final_score": null, - "parent_run_id": null, - "recommended_action": "Ki\u1ec3m tra l\u1ed7i v\u00e0 th\u1eed ch\u1ea5m \u0111i\u1ec3m l\u1ea1i.", - "retryable": true, - "rule_score": null, - "run_id": "55555555-5555-4555-8555-555555555555", - "scoring_config_version": "1.0.0", - "scoring_mode": null, - "started_at": "2026-08-27T12:02:01Z", - "status": "failed", - "subject_ref": "ems-lead-1005", - "tenant_id": "omni-account-42", - "updated_at": "2026-08-27T12:07:01Z", - "version": 1 -} diff --git a/old_repo/contracts/lead-scoring-polling/v1/fixtures/pending.json b/old_repo/contracts/lead-scoring-polling/v1/fixtures/pending.json deleted file mode 100644 index eafc205..0000000 --- a/old_repo/contracts/lead-scoring-polling/v1/fixtures/pending.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "ai_metadata": null, - "ai_score": null, - "breakdown": null, - "category": null, - "completed_at": null, - "confidence": null, - "confidence_threshold": 0.8, - "created_at": "2026-08-27T12:00:00Z", - "degraded": null, - "effective_config": null, - "effective_score": null, - "error_code": null, - "explanation": null, - "final_score": null, - "parent_run_id": null, - "recommended_action": null, - "retryable": null, - "rule_score": null, - "run_id": "11111111-1111-4111-8111-111111111111", - "scoring_config_version": "1.0.0", - "scoring_mode": null, - "started_at": null, - "status": "pending", - "subject_ref": "ems-lead-1001", - "tenant_id": "omni-account-42", - "updated_at": "2026-08-27T12:00:00Z", - "version": 1 -} diff --git a/old_repo/contracts/lead-scoring-polling/v1/fixtures/running.json b/old_repo/contracts/lead-scoring-polling/v1/fixtures/running.json deleted file mode 100644 index 76aee88..0000000 --- a/old_repo/contracts/lead-scoring-polling/v1/fixtures/running.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "ai_metadata": null, - "ai_score": null, - "breakdown": null, - "category": null, - "completed_at": null, - "confidence": null, - "confidence_threshold": 0.8, - "created_at": "2026-08-27T12:00:00Z", - "degraded": null, - "effective_config": null, - "effective_score": null, - "error_code": null, - "explanation": null, - "final_score": null, - "parent_run_id": null, - "recommended_action": null, - "retryable": null, - "rule_score": null, - "run_id": "22222222-2222-4222-8222-222222222222", - "scoring_config_version": "1.0.0", - "scoring_mode": null, - "started_at": "2026-08-27T12:00:01Z", - "status": "running", - "subject_ref": "ems-lead-1002", - "tenant_id": "omni-account-42", - "updated_at": "2026-08-27T12:00:01Z", - "version": 1 -} diff --git a/old_repo/contracts/lead-scoring-polling/v1/scoring-run-response.schema.json b/old_repo/contracts/lead-scoring-polling/v1/scoring-run-response.schema.json deleted file mode 100644 index 84e81e0..0000000 --- a/old_repo/contracts/lead-scoring-polling/v1/scoring-run-response.schema.json +++ /dev/null @@ -1,864 +0,0 @@ -{ - "$defs": { - "AiMetadataFailure": { - "additionalProperties": false, - "properties": { - "detail": { - "description": "AI error detail", - "maxLength": 255, - "minLength": 1, - "title": "Detail", - "type": "string" - }, - "error_code": { - "description": "Machine-readable AI error code", - "maxLength": 64, - "minLength": 1, - "title": "Error Code", - "type": "string" - }, - "fallback_policy": { - "$ref": "#/$defs/FallbackPolicy", - "description": "Fallback policy triggered" - } - }, - "required": [ - "error_code", - "detail", - "fallback_policy" - ], - "title": "AiMetadataFailure", - "type": "object" - }, - "AiMetadataSuccess": { - "additionalProperties": false, - "properties": { - "effort_profile": { - "$ref": "#/$defs/EffortProfile", - "description": "AI effort profile applied" - }, - "model": { - "description": "AI model used", - "maxLength": 64, - "minLength": 1, - "title": "Model", - "type": "string" - }, - "pass_count": { - "description": "Number of passes executed", - "minimum": 1, - "title": "Pass Count", - "type": "integer" - } - }, - "required": [ - "model", - "effort_profile", - "pass_count" - ], - "title": "AiMetadataSuccess", - "type": "object" - }, - "BehavioralMetric": { - "enum": [ - "message_count", - "conversation_count", - "conversation_turn_count", - "customer_message_count", - "customer_initiated_return" - ], - "title": "BehavioralMetric", - "type": "string" - }, - "DynamicRule": { - "description": "A configurable dynamic scoring rule that evaluates snapshot features.", - "properties": { - "custom_field_key": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Key inside lead_profile.custom_fields if target is custom_field", - "title": "Custom Field Key" - }, - "enabled": { - "default": true, - "description": "Whether this rule is active", - "title": "Enabled", - "type": "boolean" - }, - "id": { - "description": "Unique identifier for the rule", - "maxLength": 64, - "minLength": 1, - "title": "Id", - "type": "string" - }, - "name": { - "description": "Human-readable rule name", - "maxLength": 128, - "minLength": 1, - "title": "Name", - "type": "string" - }, - "operator": { - "$ref": "#/$defs/DynamicRuleOperator", - "description": "Comparison operator" - }, - "points": { - "description": "Points awarded when matched (-100 to 100)", - "maximum": 100, - "minimum": -100, - "title": "Points", - "type": "integer" - }, - "target": { - "$ref": "#/$defs/DynamicRuleTarget", - "default": "custom_field", - "description": "Target feature or custom_field to evaluate" - }, - "value": { - "description": "Target comparison value (number, string, or list)", - "title": "Value" - } - }, - "required": [ - "id", - "name", - "operator", - "value", - "points" - ], - "title": "DynamicRule", - "type": "object" - }, - "DynamicRuleOperator": { - "enum": [ - "eq", - "neq", - "gt", - "gte", - "lt", - "lte", - "contains", - "in" - ], - "title": "DynamicRuleOperator", - "type": "string" - }, - "DynamicRuleTarget": { - "enum": [ - "student_age", - "lead_source", - "course_interest", - "study_area", - "contact_message_recency_days", - "conversation_count", - "message_count", - "custom_field" - ], - "title": "DynamicRuleTarget", - "type": "string" - }, - "EffortProfile": { - "enum": [ - "light", - "medium", - "heavy" - ], - "title": "EffortProfile", - "type": "string" - }, - "FallbackPolicy": { - "enum": [ - "rules_only_review", - "fail_run" - ], - "title": "FallbackPolicy", - "type": "string" - }, - "RuleBreakdownItem": { - "properties": { - "detail": { - "title": "Detail", - "type": "string" - }, - "points_awarded": { - "title": "Points Awarded", - "type": "integer" - }, - "rule_name": { - "title": "Rule Name", - "type": "string" - }, - "status": { - "$ref": "#/$defs/RuleEvaluationStatus" - } - }, - "required": [ - "rule_name", - "status", - "points_awarded", - "detail" - ], - "title": "RuleBreakdownItem", - "type": "object" - }, - "RuleEvaluationStatus": { - "enum": [ - "matched", - "not_matched", - "skipped_missing_data" - ], - "title": "RuleEvaluationStatus", - "type": "string" - }, - "RuleMetric": { - "enum": [ - "profile_field_count", - "conversation_count", - "message_count" - ], - "title": "RuleMetric", - "type": "string" - }, - "ScoringConfig": { - "properties": { - "age_primary_max": { - "default": 15, - "maximum": 100, - "minimum": 0, - "title": "Age Primary Max", - "type": "integer" - }, - "age_primary_min": { - "default": 3, - "maximum": 100, - "minimum": 0, - "title": "Age Primary Min", - "type": "integer" - }, - "age_primary_points": { - "default": 40, - "maximum": 100, - "minimum": 0, - "title": "Age Primary Points", - "type": "integer" - }, - "age_secondary_max": { - "default": 18, - "maximum": 100, - "minimum": 0, - "title": "Age Secondary Max", - "type": "integer" - }, - "age_secondary_min": { - "default": 16, - "maximum": 100, - "minimum": 0, - "title": "Age Secondary Min", - "type": "integer" - }, - "age_secondary_points": { - "default": 20, - "maximum": 100, - "minimum": 0, - "title": "Age Secondary Points", - "type": "integer" - }, - "ai_model": { - "default": "openai/gpt-4o-mini", - "maxLength": 64, - "minLength": 1, - "title": "Ai Model", - "type": "string" - }, - "ai_timeout_seconds": { - "default": 10.0, - "exclusiveMinimum": 0, - "title": "Ai Timeout Seconds", - "type": "number" - }, - "ai_weight": { - "default": 0.6, - "maximum": 1.0, - "minimum": 0.0, - "title": "Ai Weight", - "type": "number" - }, - "base_score": { - "default": 0, - "maximum": 100, - "minimum": 0, - "title": "Base Score", - "type": "integer" - }, - "completeness_threshold": { - "default": 0.5, - "maximum": 1.0, - "minimum": 0.0, - "title": "Completeness Threshold", - "type": "number" - }, - "confidence": { - "maximum": 1.0, - "minimum": 0.0, - "title": "Confidence", - "type": "number" - }, - "confidence_threshold": { - "maximum": 1.0, - "minimum": 0.0, - "title": "Confidence Threshold", - "type": "number" - }, - "dynamic_rules": { - "default": [], - "description": "Operator-configured dynamic rules evaluated against lead features", - "items": { - "$ref": "#/$defs/DynamicRule" - }, - "title": "Dynamic Rules", - "type": "array" - }, - "education_demo_enabled": { - "default": false, - "title": "Education Demo Enabled", - "type": "boolean" - }, - "effort_profile": { - "$ref": "#/$defs/EffortProfile", - "default": "medium" - }, - "fallback_policy": { - "$ref": "#/$defs/FallbackPolicy", - "default": "rules_only_review" - }, - "hitl_policy_version": { - "maxLength": 64, - "minLength": 1, - "title": "Hitl Policy Version", - "type": "string" - }, - "medium_extract_prompt": { - "default": "B\u1ea1n l\u00e0 tr\u1ee3 l\u00fd AI ph\u00e2n t\u00edch h\u1ed9i tho\u1ea1i \u0111\u1ec3 \u0111\u00e1nh gi\u00e1 ti\u1ec1m n\u0103ng h\u1ecdc vi\u00ean. H\u00e3y tr\u00edch xu\u1ea5t b\u1eb1ng ch\u1ee9ng c\u00f3 c\u1ea5u tr\u00fac v\u1ec1 nhu c\u1ea7u kh\u00f3a h\u1ecdc, m\u1ee9c \u0111\u1ed9 quan t\u00e2m, th\u1eddi \u0111i\u1ec3m \u0111\u0103ng k\u00fd v\u00e0 r\u00e0o c\u1ea3n. Tr\u1ea3 l\u1eddi b\u1eb1ng ti\u1ebfng Vi\u1ec7t v\u00e0 kh\u00f4ng t\u1ef1 b\u1ecba d\u1eef ki\u1ec7n.", - "description": "Editable system prompt for Medium AI evidence extraction pass", - "maxLength": 4000, - "minLength": 1, - "title": "Medium Extract Prompt", - "type": "string" - }, - "medium_score_prompt": { - "default": "B\u1ea1n l\u00e0 chuy\u00ean gia ch\u1ea5m \u0111i\u1ec3m Lead gi\u00e1o d\u1ee5c t\u1eeb 0 \u0111\u1ebfn 100. D\u1ef1a tr\u00ean b\u1eb1ng ch\u1ee9ng \u0111\u00e3 tr\u00edch xu\u1ea5t, h\u00e3y \u0111\u00e1nh gi\u00e1 kh\u1ea3 n\u0103ng \u0111\u0103ng k\u00fd, \u0111\u1ed9 tin c\u1eady, l\u00fd do v\u00e0 c\u00e1c y\u1ebfu t\u1ed1 t\u00edch c\u1ef1c ho\u1eb7c ti\u00eau c\u1ef1c. L\u01b0u \u00fd: Kh\u00f4ng c\u1ed9ng l\u1ea1i \u0111i\u1ec3m c\u00e1c customer rules ho\u1eb7c metrics \u0111\u00e3 t\u00ednh \u1edf ph\u1ea7n quy t\u1eafc ri\u00eang. Tr\u1ea3 l\u1eddi b\u1eb1ng ti\u1ebfng Vi\u1ec7t v\u00e0 kh\u00f4ng t\u1ef1 b\u1ecba d\u1eef ki\u1ec7n.", - "description": "Editable system prompt for Medium AI lead scoring pass", - "maxLength": 4000, - "minLength": 1, - "title": "Medium Score Prompt", - "type": "string" - }, - "recency_cold_max_days": { - "default": 30, - "minimum": 0, - "title": "Recency Cold Max Days", - "type": "number" - }, - "recency_cold_points": { - "default": 5, - "maximum": 100, - "minimum": 0, - "title": "Recency Cold Points", - "type": "integer" - }, - "recency_cool_max_days": { - "default": 14, - "minimum": 0, - "title": "Recency Cool Max Days", - "type": "number" - }, - "recency_cool_points": { - "default": 15, - "maximum": 100, - "minimum": 0, - "title": "Recency Cool Points", - "type": "integer" - }, - "recency_hot_max_days": { - "default": 3, - "minimum": 0, - "title": "Recency Hot Max Days", - "type": "number" - }, - "recency_hot_points": { - "default": 40, - "maximum": 100, - "minimum": 0, - "title": "Recency Hot Points", - "type": "integer" - }, - "recency_warm_max_days": { - "default": 7, - "minimum": 0, - "title": "Recency Warm Max Days", - "type": "number" - }, - "recency_warm_points": { - "default": 25, - "maximum": 100, - "minimum": 0, - "title": "Recency Warm Points", - "type": "integer" - }, - "rule_weight": { - "default": 0.4, - "maximum": 1.0, - "minimum": 0.0, - "title": "Rule Weight", - "type": "number" - }, - "rules": { - "default": [], - "items": { - "$ref": "#/$defs/ScoringRule" - }, - "title": "Rules", - "type": "array" - }, - "selected_behavioral_metrics": { - "default": [ - "message_count", - "conversation_count", - "conversation_turn_count", - "customer_message_count", - "customer_initiated_return" - ], - "description": "System behavioral metrics calculated deterministically and supplied to AI context", - "items": { - "$ref": "#/$defs/BehavioralMetric" - }, - "title": "Selected Behavioral Metrics", - "type": "array" - }, - "source_call_in_points": { - "default": 15, - "maximum": 100, - "minimum": 0, - "title": "Source Call In Points", - "type": "integer" - }, - "source_campaign_points": { - "default": 8, - "maximum": 100, - "minimum": 0, - "title": "Source Campaign Points", - "type": "integer" - }, - "source_digital_points": { - "default": 10, - "maximum": 100, - "minimum": 0, - "title": "Source Digital Points", - "type": "integer" - }, - "source_other_points": { - "default": 5, - "maximum": 100, - "minimum": 0, - "title": "Source Other Points", - "type": "integer" - }, - "source_referral_points": { - "default": 20, - "maximum": 100, - "minimum": 0, - "title": "Source Referral Points", - "type": "integer" - }, - "version": { - "maxLength": 64, - "minLength": 1, - "title": "Version", - "type": "string" - } - }, - "required": [ - "version", - "confidence", - "confidence_threshold", - "hitl_policy_version" - ], - "title": "ScoringConfig", - "type": "object" - }, - "ScoringMode": { - "enum": [ - "light_ai", - "medium_ai", - "heavy_ai", - "rules_only_fallback", - "rule_only" - ], - "title": "ScoringMode", - "type": "string" - }, - "ScoringRule": { - "properties": { - "metric": { - "$ref": "#/$defs/RuleMetric" - }, - "minimum": { - "minimum": 0, - "title": "Minimum", - "type": "integer" - }, - "points": { - "maximum": 100, - "minimum": -100, - "title": "Points", - "type": "integer" - } - }, - "required": [ - "metric", - "minimum", - "points" - ], - "title": "ScoringRule", - "type": "object" - }, - "ScoringRunStatus": { - "enum": [ - "pending", - "running", - "completed", - "review_required", - "failed", - "superseded" - ], - "title": "ScoringRunStatus", - "type": "string" - } - }, - "additionalProperties": false, - "properties": { - "ai_metadata": { - "anyOf": [ - { - "$ref": "#/$defs/AiMetadataSuccess" - }, - { - "$ref": "#/$defs/AiMetadataFailure" - }, - { - "type": "null" - } - ], - "title": "Ai Metadata" - }, - "ai_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Ai Score" - }, - "breakdown": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/RuleBreakdownItem" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Rule evaluation breakdown", - "title": "Breakdown" - }, - "category": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Public lead tier / category derived deterministically from score", - "title": "Category" - }, - "completed_at": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Completed At" - }, - "confidence": { - "anyOf": [ - { - "maximum": 1.0, - "minimum": 0.0, - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Confidence" - }, - "confidence_threshold": { - "anyOf": [ - { - "maximum": 1.0, - "minimum": 0.0, - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Confidence Threshold" - }, - "created_at": { - "description": "Creation timestamp", - "format": "date-time", - "title": "Created At", - "type": "string" - }, - "degraded": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "Degraded" - }, - "effective_config": { - "anyOf": [ - { - "$ref": "#/$defs/ScoringConfig" - }, - { - "type": "null" - } - ] - }, - "effective_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Effective Score" - }, - "error_code": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Error Code" - }, - "explanation": { - "anyOf": [ - { - "maxLength": 1000, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Concise public explanation using AI reasoning where available or rule summary", - "title": "Explanation" - }, - "final_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Final Score" - }, - "parent_run_id": { - "anyOf": [ - { - "maxLength": 36, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent scoring run ID if created via re-evaluation", - "title": "Parent Run Id" - }, - "recommended_action": { - "anyOf": [ - { - "maxLength": 255, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Deterministic recommended next action based on score", - "title": "Recommended Action" - }, - "retryable": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "Retryable" - }, - "rule_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Rule Score" - }, - "run_id": { - "description": "Unique scoring run ID", - "maxLength": 36, - "title": "Run Id", - "type": "string" - }, - "scoring_config_version": { - "description": "Scoring config version", - "maxLength": 64, - "title": "Scoring Config Version", - "type": "string" - }, - "scoring_mode": { - "anyOf": [ - { - "$ref": "#/$defs/ScoringMode" - }, - { - "type": "null" - } - ] - }, - "started_at": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Started At" - }, - "status": { - "$ref": "#/$defs/ScoringRunStatus", - "description": "Current scoring run status" - }, - "subject_ref": { - "description": "EMS Lead reference", - "maxLength": 255, - "title": "Subject Ref", - "type": "string" - }, - "tenant_id": { - "description": "Tenant identifier", - "maxLength": 255, - "title": "Tenant Id", - "type": "string" - }, - "updated_at": { - "description": "Last updated timestamp", - "format": "date-time", - "title": "Updated At", - "type": "string" - }, - "version": { - "description": "Run version for optimistic concurrency", - "minimum": 1, - "title": "Version", - "type": "integer" - } - }, - "required": [ - "run_id", - "tenant_id", - "subject_ref", - "scoring_config_version", - "status", - "rule_score", - "ai_score", - "final_score", - "effective_score", - "scoring_mode", - "degraded", - "ai_metadata", - "confidence", - "confidence_threshold", - "effective_config", - "error_code", - "retryable", - "version", - "started_at", - "completed_at", - "created_at", - "updated_at" - ], - "title": "ScoringRunResponse", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-webhook/v1/contract.json b/old_repo/contracts/lead-scoring-webhook/v1/contract.json deleted file mode 100644 index a3cdf21..0000000 --- a/old_repo/contracts/lead-scoring-webhook/v1/contract.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "canonical_input": "..", - "contract_pack_version": "0.1.0", - "endpoint_path": "/api/v1/internal/lead-scoring/events", - "event_type": "lead_scoring.run.terminal", - "event_types": [ - "lead_scoring.run.terminal", - "lead_scoring.run.reviewed" - ], - "event_version": "1.0.0", - "method": "POST", - "replay_window_seconds": 300, - "signature_algorithm": "HMAC-SHA256", - "signature_version": "v1", - "supported_event_types": [ - "lead_scoring.run.terminal", - "lead_scoring.run.reviewed" - ] -} diff --git a/old_repo/contracts/lead-scoring-webhook/v1/fixtures/completed.json b/old_repo/contracts/lead-scoring-webhook/v1/fixtures/completed.json deleted file mode 100644 index 5ca7e6a..0000000 --- a/old_repo/contracts/lead-scoring-webhook/v1/fixtures/completed.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "data": { - "ai_score": null, - "category": null, - "confidence": 0.91, - "degraded": null, - "effective_score": null, - "error_code": null, - "explanation": null, - "final_score": null, - "recommended_action": null, - "retryable": null, - "rule_score": 82, - "scoring_mode": null, - "status": "completed" - }, - "event_id": "11111111-1111-4111-8111-111111111111", - "event_type": "lead_scoring.run.terminal", - "event_version": "1.0.0", - "occurred_at": "2026-08-27T12:00:03Z", - "run_created_at": "2026-08-27T12:00:00Z", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "scoring_config_version": "1.0.0", - "subject_ref": "ems-lead-1001", - "tenant_id": "omni-account-42" -} diff --git a/old_repo/contracts/lead-scoring-webhook/v1/fixtures/review_required.json b/old_repo/contracts/lead-scoring-webhook/v1/fixtures/review_required.json deleted file mode 100644 index 3988956..0000000 --- a/old_repo/contracts/lead-scoring-webhook/v1/fixtures/review_required.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "data": { - "ai_score": null, - "category": null, - "confidence": 0.64, - "degraded": null, - "effective_score": null, - "error_code": null, - "explanation": null, - "final_score": null, - "recommended_action": null, - "retryable": null, - "rule_score": 61, - "scoring_mode": null, - "status": "completed" - }, - "event_id": "22222222-2222-4222-8222-222222222222", - "event_type": "lead_scoring.run.terminal", - "event_version": "1.0.0", - "occurred_at": "2026-08-27T12:01:04Z", - "run_created_at": "2026-08-27T12:01:00Z", - "run_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - "scoring_config_version": "1.0.0", - "subject_ref": "ems-lead-1002", - "tenant_id": "omni-account-42" -} diff --git a/old_repo/contracts/lead-scoring-webhook/v1/fixtures/reviewed_approve.json b/old_repo/contracts/lead-scoring-webhook/v1/fixtures/reviewed_approve.json deleted file mode 100644 index e5d62fc..0000000 --- a/old_repo/contracts/lead-scoring-webhook/v1/fixtures/reviewed_approve.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "data": { - "calculated_score": 61, - "decision": "approve", - "effective_score": 61, - "reviewed_by": "agent_10", - "status": "completed", - "version": 2 - }, - "event_id": "44444444-4444-4444-8444-444444444444", - "event_type": "lead_scoring.run.reviewed", - "event_version": "1.0.0", - "occurred_at": "2026-08-27T12:05:00Z", - "run_created_at": "2026-08-27T12:01:00Z", - "run_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - "scoring_config_version": "1.0.0", - "subject_ref": "ems-lead-1002", - "tenant_id": "omni-account-42" -} diff --git a/old_repo/contracts/lead-scoring-webhook/v1/fixtures/reviewed_override.json b/old_repo/contracts/lead-scoring-webhook/v1/fixtures/reviewed_override.json deleted file mode 100644 index 864db94..0000000 --- a/old_repo/contracts/lead-scoring-webhook/v1/fixtures/reviewed_override.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "data": { - "calculated_score": 61, - "decision": "override", - "effective_score": 90, - "reviewed_by": "agent_10", - "status": "completed", - "version": 2 - }, - "event_id": "55555555-5555-4555-8555-555555555555", - "event_type": "lead_scoring.run.reviewed", - "event_version": "1.0.0", - "occurred_at": "2026-08-27T12:06:00Z", - "run_created_at": "2026-08-27T12:01:00Z", - "run_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - "scoring_config_version": "1.0.0", - "subject_ref": "ems-lead-1002", - "tenant_id": "omni-account-42" -} diff --git a/old_repo/contracts/lead-scoring-webhook/v1/fixtures/reviewed_reevaluate.json b/old_repo/contracts/lead-scoring-webhook/v1/fixtures/reviewed_reevaluate.json deleted file mode 100644 index 51239c8..0000000 --- a/old_repo/contracts/lead-scoring-webhook/v1/fixtures/reviewed_reevaluate.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "data": { - "calculated_score": 82, - "decision": "re_evaluate", - "effective_score": 82, - "reviewed_by": "agent_10", - "status": "superseded", - "version": 2 - }, - "event_id": "66666666-6666-4666-8666-666666666666", - "event_type": "lead_scoring.run.reviewed", - "event_version": "1.0.0", - "occurred_at": "2026-08-27T12:07:00Z", - "run_created_at": "2026-08-27T12:00:00Z", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "scoring_config_version": "1.0.0", - "subject_ref": "ems-lead-1001", - "tenant_id": "omni-account-42" -} diff --git a/old_repo/contracts/lead-scoring-webhook/v1/fixtures/tenant-unavailable.json b/old_repo/contracts/lead-scoring-webhook/v1/fixtures/tenant-unavailable.json deleted file mode 100644 index 43ae139..0000000 --- a/old_repo/contracts/lead-scoring-webhook/v1/fixtures/tenant-unavailable.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "detail": "Webhook destination is not available", - "error_code": "subscriber_tenant_unavailable" -} diff --git a/old_repo/contracts/lead-scoring-webhook/v1/fixtures/worker_interrupted.json b/old_repo/contracts/lead-scoring-webhook/v1/fixtures/worker_interrupted.json deleted file mode 100644 index a877309..0000000 --- a/old_repo/contracts/lead-scoring-webhook/v1/fixtures/worker_interrupted.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "data": { - "ai_score": null, - "category": null, - "confidence": null, - "degraded": null, - "effective_score": null, - "error_code": "worker_interrupted", - "explanation": null, - "final_score": null, - "recommended_action": null, - "retryable": true, - "rule_score": null, - "scoring_mode": null, - "status": "failed" - }, - "event_id": "33333333-3333-4333-8333-333333333333", - "event_type": "lead_scoring.run.terminal", - "event_version": "1.0.0", - "occurred_at": "2026-08-27T12:07:01Z", - "run_created_at": "2026-08-27T12:02:00Z", - "run_id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", - "scoring_config_version": "1.0.0", - "subject_ref": "ems-lead-1003", - "tenant_id": "omni-account-42" -} diff --git a/old_repo/contracts/lead-scoring-webhook/v1/lead-scoring-reviewed-event.schema.json b/old_repo/contracts/lead-scoring-webhook/v1/lead-scoring-reviewed-event.schema.json deleted file mode 100644 index 3f10f8a..0000000 --- a/old_repo/contracts/lead-scoring-webhook/v1/lead-scoring-reviewed-event.schema.json +++ /dev/null @@ -1,140 +0,0 @@ -{ - "$defs": { - "ReviewDecision": { - "enum": [ - "approve", - "override", - "re_evaluate" - ], - "title": "ReviewDecision", - "type": "string" - }, - "ReviewRunData": { - "additionalProperties": false, - "properties": { - "calculated_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Calculated Score" - }, - "decision": { - "$ref": "#/$defs/ReviewDecision" - }, - "effective_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Effective Score" - }, - "reviewed_by": { - "maxLength": 255, - "minLength": 1, - "title": "Reviewed By", - "type": "string" - }, - "status": { - "title": "Status", - "type": "string" - }, - "version": { - "minimum": 1, - "title": "Version", - "type": "integer" - } - }, - "required": [ - "status", - "decision", - "version", - "reviewed_by" - ], - "title": "ReviewRunData", - "type": "object" - } - }, - "additionalProperties": false, - "properties": { - "data": { - "$ref": "#/$defs/ReviewRunData" - }, - "event_id": { - "format": "uuid", - "title": "Event Id", - "type": "string" - }, - "event_type": { - "const": "lead_scoring.run.reviewed", - "title": "Event Type", - "type": "string" - }, - "event_version": { - "const": "1.0.0", - "title": "Event Version", - "type": "string" - }, - "occurred_at": { - "format": "date-time", - "title": "Occurred At", - "type": "string" - }, - "run_created_at": { - "format": "date-time", - "title": "Run Created At", - "type": "string" - }, - "run_id": { - "format": "uuid", - "title": "Run Id", - "type": "string" - }, - "scoring_config_version": { - "maxLength": 64, - "minLength": 1, - "title": "Scoring Config Version", - "type": "string" - }, - "subject_ref": { - "maxLength": 255, - "minLength": 1, - "title": "Subject Ref", - "type": "string" - }, - "tenant_id": { - "maxLength": 255, - "minLength": 1, - "title": "Tenant Id", - "type": "string" - } - }, - "required": [ - "event_id", - "event_type", - "event_version", - "tenant_id", - "subject_ref", - "scoring_config_version", - "run_id", - "run_created_at", - "occurred_at", - "data" - ], - "title": "LeadScoringReviewedEvent", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-webhook/v1/lead-scoring-terminal-event.schema.json b/old_repo/contracts/lead-scoring-webhook/v1/lead-scoring-terminal-event.schema.json deleted file mode 100644 index 95fc9ee..0000000 --- a/old_repo/contracts/lead-scoring-webhook/v1/lead-scoring-terminal-event.schema.json +++ /dev/null @@ -1,251 +0,0 @@ -{ - "$defs": { - "TerminalRunData": { - "additionalProperties": false, - "properties": { - "ai_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Ai Score" - }, - "category": { - "anyOf": [ - { - "maxLength": 32, - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Category" - }, - "confidence": { - "anyOf": [ - { - "maximum": 1.0, - "minimum": 0.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Confidence" - }, - "degraded": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Degraded" - }, - "effective_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Effective Score" - }, - "error_code": { - "anyOf": [ - { - "maxLength": 64, - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Error Code" - }, - "explanation": { - "anyOf": [ - { - "maxLength": 2000, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Explanation" - }, - "final_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Final Score" - }, - "recommended_action": { - "anyOf": [ - { - "maxLength": 2000, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Recommended Action" - }, - "retryable": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Retryable" - }, - "rule_score": { - "anyOf": [ - { - "maximum": 100, - "minimum": 0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Rule Score" - }, - "scoring_mode": { - "anyOf": [ - { - "maxLength": 32, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Scoring Mode" - }, - "status": { - "enum": [ - "completed", - "failed" - ], - "title": "Status", - "type": "string" - } - }, - "required": [ - "status" - ], - "title": "TerminalRunData", - "type": "object" - } - }, - "additionalProperties": false, - "properties": { - "data": { - "$ref": "#/$defs/TerminalRunData" - }, - "event_id": { - "format": "uuid", - "title": "Event Id", - "type": "string" - }, - "event_type": { - "const": "lead_scoring.run.terminal", - "title": "Event Type", - "type": "string" - }, - "event_version": { - "const": "1.0.0", - "title": "Event Version", - "type": "string" - }, - "occurred_at": { - "format": "date-time", - "title": "Occurred At", - "type": "string" - }, - "run_created_at": { - "format": "date-time", - "title": "Run Created At", - "type": "string" - }, - "run_id": { - "format": "uuid", - "title": "Run Id", - "type": "string" - }, - "scoring_config_version": { - "maxLength": 64, - "minLength": 1, - "title": "Scoring Config Version", - "type": "string" - }, - "subject_ref": { - "maxLength": 255, - "minLength": 1, - "title": "Subject Ref", - "type": "string" - }, - "tenant_id": { - "maxLength": 255, - "minLength": 1, - "title": "Tenant Id", - "type": "string" - } - }, - "required": [ - "event_id", - "event_type", - "event_version", - "tenant_id", - "subject_ref", - "scoring_config_version", - "run_id", - "run_created_at", - "occurred_at", - "data" - ], - "title": "LeadScoringTerminalEvent", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-webhook/v1/signature-vectors.json b/old_repo/contracts/lead-scoring-webhook/v1/signature-vectors.json deleted file mode 100644 index 70f1df0..0000000 --- a/old_repo/contracts/lead-scoring-webhook/v1/signature-vectors.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "algorithm": "HMAC-SHA256", - "canonical_input": "..", - "headers": { - "event_id": "X-DotB-Webhook-Id", - "key_id": "X-DotB-Webhook-Key-Id", - "signature": "X-DotB-Webhook-Signature", - "timestamp": "X-DotB-Webhook-Timestamp" - }, - "replay_window_seconds": 300, - "signature_version": "v1", - "test_secret": "whsec_test_contract_v1_not_for_production", - "vectors": [ - { - "body_file": "fixtures/completed.json", - "event_id": "11111111-1111-4111-8111-111111111111", - "key_id": "pilot-test-2026-01", - "signature": "v1=ac4c01e2c237185d42a178a00ec4175893e3af456495f503369a2b4bfc6e58a0", - "timestamp": "1787832600" - }, - { - "body_file": "fixtures/review_required.json", - "event_id": "22222222-2222-4222-8222-222222222222", - "key_id": "pilot-test-2026-01", - "signature": "v1=db51e5cecd34ef4b8bce843ddfc4d757c81afd3436ca0f932f1123ce215cfe46", - "timestamp": "1787832600" - }, - { - "body_file": "fixtures/worker_interrupted.json", - "event_id": "33333333-3333-4333-8333-333333333333", - "key_id": "pilot-test-2026-01", - "signature": "v1=e6a7bef67ed3c29634a35da49cb1b713c8fc8e53f8f0a6bd5449950ed368d661", - "timestamp": "1787832600" - }, - { - "body_file": "fixtures/reviewed_approve.json", - "event_id": "44444444-4444-4444-8444-444444444444", - "key_id": "pilot-test-2026-01", - "signature": "v1=021d40e273ad75fce6822fdec1a010b28a8b8dc55f850783e3afddd229636875", - "timestamp": "1787832600" - }, - { - "body_file": "fixtures/reviewed_override.json", - "event_id": "55555555-5555-4555-8555-555555555555", - "key_id": "pilot-test-2026-01", - "signature": "v1=ba06a10e0ed2badfd92a670b091b3187a03968d3abd5e24466384774e1606326", - "timestamp": "1787832600" - }, - { - "body_file": "fixtures/reviewed_reevaluate.json", - "event_id": "66666666-6666-4666-8666-666666666666", - "key_id": "pilot-test-2026-01", - "signature": "v1=3276fdafc130962fc8cea8ccd842827949d07efa3d6cdf08d1a8493e4ab62018", - "timestamp": "1787832600" - } - ] -} diff --git a/old_repo/contracts/lead-scoring-webhook/v1/webhook-ingest-error-response.schema.json b/old_repo/contracts/lead-scoring-webhook/v1/webhook-ingest-error-response.schema.json deleted file mode 100644 index 15c59cd..0000000 --- a/old_repo/contracts/lead-scoring-webhook/v1/webhook-ingest-error-response.schema.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "additionalProperties": false, - "properties": { - "detail": { - "const": "Webhook destination is not available", - "title": "Detail", - "type": "string" - }, - "error_code": { - "const": "subscriber_tenant_unavailable", - "title": "Error Code", - "type": "string" - } - }, - "required": [ - "error_code", - "detail" - ], - "title": "WebhookIngestErrorResponse", - "type": "object" -} diff --git a/old_repo/contracts/lead-scoring-webhook/v1/webhook-ingest-response.schema.json b/old_repo/contracts/lead-scoring-webhook/v1/webhook-ingest-response.schema.json deleted file mode 100644 index 9bc2497..0000000 --- a/old_repo/contracts/lead-scoring-webhook/v1/webhook-ingest-response.schema.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "additionalProperties": false, - "properties": { - "event_id": { - "format": "uuid", - "title": "Event Id", - "type": "string" - }, - "status": { - "enum": [ - "accepted", - "duplicate", - "stale", - "ordering_conflict" - ], - "title": "Status", - "type": "string" - } - }, - "required": [ - "status", - "event_id" - ], - "title": "WebhookIngestResponse", - "type": "object" -} diff --git a/old_repo/docs/integrations/lead-scoring-contract-pack-v1.md b/old_repo/docs/integrations/lead-scoring-contract-pack-v1.md deleted file mode 100644 index eceaf3c..0000000 --- a/old_repo/docs/integrations/lead-scoring-contract-pack-v1.md +++ /dev/null @@ -1,122 +0,0 @@ ---- -title: Lead Scoring Contract Pack v1 -description: Quy trình phát hành, ghim phiên bản và kiểm chứng Contract Pack dùng chung giữa AI Platform và DotB-Omni. -status: draft -tags: - - lead-scoring - - contract-pack - - integration ---- - -# Lead Scoring Contract Pack v1 - -## Quyền sở hữu và nguồn sự thật - -AI Platform sở hữu Contract Pack được sinh từ các Pydantic models trong -`src/lead_scoring/schemas/`. DotB-Omni review và consume artifact đã phát hành, nhưng -không tạo schema, payload hoặc fixture wire-format cạnh tranh. Master specification vẫn -sở hữu product behavior, scope và cross-service invariants; Contract Pack sở hữu exact -shape, field constraints, enum, error taxonomy và golden examples. - -Pack version hiện tại là `0.1.0`, ở trạng thái pre-release và nằm tại -`contracts/lead-scoring-contract-pack/v1/`. API major `v1` không đồng nghĩa với việc -mọi thay đổi trong pre-release đều tương thích. Consumer phải ghim cả pack version và -AI Platform Git commit đã sinh pack. - -## Sinh và kiểm tra - -```text -uv run generate-contract-pack -uv run generate-contract-pack --validate-fixtures -uv run generate-contract-pack --check -``` - -Generator tạo deterministic `openapi.json`, JSON Schemas, `contract.json`, -`error-taxonomy.json`, `outcome-taxonomy.json`, `security-profile.json`, -`fixture-index.json`, compatibility workflow, shared API và Marker Block fixtures cùng -`SHA256SUMS`. `--check` thất bại nếu file thiếu, nội dung khác với Pydantic source of truth -hoặc xuất hiện file ngoài danh sách generated. -Không sửa tay các artifact trong pack. - -Các generator standalone cho polling và webhook vẫn được giữ để tương thích với các -lát cắt đã phát hành. Pack versioned này lấy output của chúng làm một phần fixture corpus. -`fixtures/tenant-unavailable.json` và -`schemas/webhook-ingest-error-response.schema.json` giữ nguyên contract đã phát hành: -HTTP 404 với `subscriber_tenant_unavailable` và detail cố định, không tiết lộ tenant, -account hoặc mapping reason. - -## Phạm vi artifact - -| Profile | Schema hoặc artifact chính | -|---|---| -| Canonical Snapshot | `schemas/canonical-snapshot.schema.json` | -| Scoring Config | `schemas/scoring-config.schema.json`, `schemas/scoring-config-selection.schema.json` | -| Admission | `schemas/admission-request.schema.json`, `schemas/admission-response.schema.json` | -| Polling | `schemas/polling-response.schema.json` | -| Review | `schemas/review-request.schema.json`, `schemas/review-response.schema.json` | -| Lineage | `schemas/lineage.schema.json` | -| EMS Marker Block | `fixtures/marker/` với golden input/output JSON | -| Webhook events | `schemas/webhook-event-envelope.schema.json`, terminal và reviewed event schemas | -| Errors and outcomes | `error-taxonomy.json`, `outcome-taxonomy.json`, `schemas/error-response.schema.json` | -| API | `openapi.json` | -| Examples | `fixtures/` với positive và negative corpus | - -`contract.json` liên kết từng profile với version và schema file. Event envelope luôn -bao gồm event identity/type/version, tenant, subject, config version, `run_id`, -`run_created_at`, `occurred_at` và structured data. Raw V-Key, snapshot, conversation -evidence và raw PII không thuộc event fixture. - -### EMS Marker Block fixtures - -Marker Block dùng delimiter HTML comment ổn định: - -```text - -... - -``` - -Template canonical gồm `Score`, `Confidence`, `Mode`, `Degraded`, `Config`, `Run` và -`Run created`, mỗi field trên một dòng. Append dùng đúng hai newline làm separator. -Các fixture trong `fixtures/marker/` mô tả input description, marker input, expected -output hoặc `marker_block_malformed`, và cờ `writeback`. Phạm vi chỉ là calculated -pre-review score; failed run không render hoặc ghi Marker. - -## Ghim và nâng cấp giữa hai repository - -1. AI Platform sinh pack, chạy `--check` và `--validate-fixtures`, sau đó review diff. -2. Pack được phát hành cùng AI Platform Git commit. Commit đó là immutable input của - consumer verification. -3. Omni cập nhật pin tới đúng pack version và commit, rồi chạy consumer tests trực tiếp - trên cùng thư mục `fixtures/`, không copy hoặc rewrite payload. -4. Hai bên chạy sandbox verification. Chỉ sau khi provider và consumer đều pass mới - promote pin vào integration branch. -5. Một release mới không được sửa nội dung của pack đã được pin. - -### Thay đổi additive tương thích - -Giữ cùng API major, không thay đổi required fields, enum values, semantics hoặc error -handling hiện có. Field mới phải optional đối với consumer cũ. Provider validation chạy -fixtures hiện hành bằng schema consumer cũ và thêm fixture chứng minh field mới là -optional. Commit pin chỉ được cập nhật sau khi consumer cũ vẫn pass. - -### Thay đổi breaking - -Tạo API major directory và Contract Pack version mới. Giữ pack cũ để consumer đang ghim -tiếp tục chạy. Provider và consumer phải chạy fixture corpus của version mới trong -sandbox trước khi Omni đổi pin. Không dùng `--check` để hợp thức hóa việc sửa artifact -của version đang được ghim. - -## Provider và consumer fixture validation - -`fixture-index.json` đánh dấu các positive fixtures dùng chung cho provider và consumer, -cùng negative fixtures phải bị từ chối. AI Platform chạy provider-side validation bằng -`validate_fixture_corpus`. Omni dùng các path tương ứng để kiểm tra parser, event auth, -ordering, duplicate/stale acknowledgement và writeback boundary trong repository của -mình. Error text không phải contract ổn định; test phải kiểm tra error code, HTTP status -và retry semantics theo taxonomy. - -Contract Pack không phát hành monitoring vendor, deployment platform, public replay API -hoặc EMS Marker implementation. Webhook ingest acknowledgement và EMS writeback vẫn là -hai lifecycle riêng; tenant-unavailable response tiếp tục dùng operational DLQ replay -theo contract đã phát hành. diff --git a/old_repo/docs/integrations/lead-scoring-polling-v1.md b/old_repo/docs/integrations/lead-scoring-polling-v1.md deleted file mode 100644 index bebfff9..0000000 --- a/old_repo/docs/integrations/lead-scoring-polling-v1.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -title: Lead Scoring Polling v1 -description: Contract profile for polling Lead Scoring run status and DotB-Omni integration. -status: draft -tags: - - lead-scoring - - polling - - integration ---- - -# Lead Scoring Polling v1 - -## Authority and release - -This document defines polling semantics and security handling for inspecting scoring run -status. Exact JSON shapes, constraints, and golden response payloads are generated -deterministically from released Pydantic models and committed at -`contracts/lead-scoring-polling/v1/`. - -Generate the contract pack with `uv run generate-polling-contract`; verify it with -`uv run generate-polling-contract --check`. `contract.json` identifies the standalone -pre-release contract pack version (`contract_pack_version: 0.1.0`), which targets the -HTTP API v1 major route (`/v1/scoring-runs/{run_id}`). DotB-Omni must pin the pack by -AI Platform Git commit when implementation begins and run its consumer tests against -these committed artifacts. Do not recreate response schemas or status payload fixtures in -Omni. - -This pack is a pre-release baseline. The approved Customer Pilot specification remains -the authority for product behavior and ownership. Polling is an inspection and fallback -mechanism for internal callers and simple consumers alongside asynchronous webhook events. - -## Endpoint and transport - -Lead Scoring exposes `GET /v1/scoring-runs/{run_id}` with strict tenant scoping. - -### Request headers - -| Header | Type | Description | -|---|---|---| -| `Authorization` | `string` | `Bearer ` for authenticated service caller | -| `X-Tenant-Id` | `string` | Tenant identifier for scoped tenant isolation query | - -### Path parameters - -| Parameter | Type | Constraints | Description | -|---|---|---|---| -| `run_id` | `string` | `min_length=1`, `max_length=36` | Unique scoring run identifier | - -## Status lifecycle - -A scoring run progresses through the following runtime states, independent of the -underlying scoring mode (`light_ai`, `medium_ai`, `heavy_ai`, `rules_only_fallback`, or `rule_only`): - -- `pending`: Run is admitted and persisted, awaiting worker execution. -- `running`: Worker has claimed the run and execution is in progress. -- `completed`: Execution finished successfully with calculated scores and combined confidence meeting or exceeding threshold. -- `review_required`: Execution completed but requires Human-In-The-Loop review (e.g. combined confidence below threshold or degraded rules-only fallback). -- `failed`: Terminal execution failure (e.g. `error_code=worker_interrupted`, `retryable=true`). -- `superseded`: Run was superseded by a child run created via re-evaluation. - -## Response schemas and fixtures - -The response shape is strictly defined by `scoring-run-response.schema.json`. Every -persisted run includes a fully materialized `effective_config` (`ScoringConfig`) and -`confidence_threshold`. Committed fixtures in `contracts/lead-scoring-polling/v1/fixtures/` -represent normative wire payloads for each active runtime status: - -- `fixtures/pending.json`: Initial state before execution starts. -- `fixtures/running.json`: In-progress execution state. -- `fixtures/completed.json`: Successful run with full scores and AI metadata. -- `fixtures/review_required.json`: Degraded or low-confidence run needing review. -- `fixtures/failed.json`: Interrupted or failed run with error code and retry flag. -- `fixtures/superseded.json`: Superseded parent run after re-evaluation. - -## HTTP response status codes - -| HTTP Status | Description | Schema | -|---|---|---| -| `200 OK` | Scoring run metadata and current status | `scoring-run-response.schema.json` | -| `401 Unauthorized` | Invalid or missing Bearer service token | `error-response.schema.json` | -| `403 Forbidden` | Caller not authorized for tenant | `error-response.schema.json` | -| `404 Not Found` | Scoring run not found for tenant | `error-response.schema.json` | -| `422 Unprocessable Content` | Validation error for missing/invalid required headers or path parameters | `error-response.schema.json` | - -## Consumer implementation boundary - -DotB-Omni and internal callers use this endpoint to poll run status when webhook delivery -is unavailable, to reconcile run state after reconnection, or to verify execution progress -in internal tooling. Polling must always supply both `Authorization` and `X-Tenant-Id` -headers matching the tenant context of the requested run. diff --git a/old_repo/docs/integrations/lead-scoring-webhook-v1.md b/old_repo/docs/integrations/lead-scoring-webhook-v1.md deleted file mode 100644 index 7a6dbe8..0000000 --- a/old_repo/docs/integrations/lead-scoring-webhook-v1.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -title: Lead Scoring Webhook v1 -description: Contract profile for terminal Lead Scoring webhook delivery and DotB-Omni ingestion. -status: draft -tags: - - lead-scoring - - webhook - - integration ---- - -# Lead Scoring terminal webhook v1 - -## Authority and release - -This document defines delivery semantics and security handling around the executable -contract pack. Exact JSON shapes, constraints, golden payload bytes, and signature -vectors are generated from Pydantic models and committed at -`contracts/lead-scoring-webhook/v1/`. - -Generate the pack with `uv run generate-webhook-contract`; verify it with -`uv run generate-webhook-contract --check`. `contract.json` identifies the contract -pack version and event version. DotB-Omni must pin the pack by AI Platform Git commit -when implementation begins and run its consumer tests against those committed files. -Do not recreate envelope schemas, payload fixtures, or signature vectors in Omni. - -This pack is a pre-release baseline. The approved Customer Pilot specification remains -the authority for product behavior and ownership. Ticket 08 owns the future dispatcher; -this contract does not implement a dispatcher or receiver. - -## Delivery - -Lead Scoring sends `POST /api/v1/internal/lead-scoring/events` only to a -deployment-managed registered DotB-Omni subscriber. The endpoint is versioned by its -`/api/v1/` path and is never accepted from a scoring-run request. Each committed terminal -run emits `lead_scoring.run.terminal` event version `1.0.0` with one of these statuses: - -- `completed` -- `review_required` -- `failed`, with machine-readable `error_code` and `retryable` fields - -The generated `worker_interrupted.json` fixture is the normative failed-outcome example -for an in-memory worker interruption (`error_code=worker_interrupted`, -`retryable=true`). Other normalized failed outcomes can be added as versioned fixtures -when their producer path is enabled. Events contain no V-Key, raw snapshot, raw -conversation evidence, or unneeded PII. - -Delivery is at least once. A retry retains the event identity but creates a fresh Unix -timestamp and signature for that delivery attempt. Ingestion success means only that -Omni accepted and recorded the event decision; it does not mean EMS Marker Block -writeback has completed. - -## Signed request - -Every delivery has these headers: - -| Header | Value | -|---|---| -| `Content-Type` | `application/json` | -| `X-DotB-Webhook-Id` | The body `event_id` UUID | -| `X-DotB-Webhook-Key-Id` | Deployment-managed signing-key identifier | -| `X-DotB-Webhook-Timestamp` | Decimal Unix timestamp used for signing | -| `X-DotB-Webhook-Signature` | `v1=` | - -The signature input is the UTF-8 byte sequence -`..`. The receiver first rejects timestamps -outside 300 seconds before or after its current server clock, then verifies the signature -against the unparsed request bytes before JSON parsing. It must compare signatures in -constant time and confirm that the header event ID equals the payload event ID. A retry -outside the original freshness window is valid only when the dispatcher signs that attempt -with a fresh timestamp; event idempotency remains keyed by the unchanged event identity. - -`signature-vectors.json` contains deterministic test-only secrets and expected -signatures for every fixture. Those values are not production credentials and must not -be deployed as webhook secrets. Production signing keys live only in the deployment -secret store and are never written to source, fixtures, logs, or response bodies. - -`X-DotB-Webhook-Key-Id` supports rotation. Deployment configuration selects the active -key for sends; the receiver may accept the active and immediately previous configured -key during an explicitly bounded rotation window. Unknown key IDs or invalid signatures -are authentication failures, must be audited without the secret or raw body, and are not -acknowledged as successful deliveries. - -## Receiver acknowledgement and ordering - -For an authenticated, schema-valid delivery, DotB-Omni returns HTTP 200 and the body -validated by `webhook-ingest-response.schema.json`: - -- `accepted`: event is eligible for asynchronous writeback handling. -- `duplicate`: the same run is already reflected. -- `stale`: a newer applicable run is already reflected. -- `ordering_conflict`: another run has the same ordering key and `run_created_at`. - -All four are terminal ingestion acknowledgements. Invalid JSON or schema validation is -HTTP 400. Invalid webhook authentication is HTTP 401. The future dispatcher must treat -these non-2xx results as observable terminal delivery failures rather than success. - -When the webhook signature and payload schema are valid but the receiver cannot resolve -the tenant destination, the receiver must return the same response for all of these -cases: - -- `tenant_id` is not registered. -- Tenant-to-account mapping is invalid. -- The mapped account has been deleted. - -```http -HTTP/1.1 404 Not Found -Content-Type: application/json -``` - -```json -{ - "error_code": "subscriber_tenant_unavailable", - "detail": "Webhook destination is not available" -} -``` - -The executable error shape is `webhook-ingest-error-response.schema.json`, and -`fixtures/tenant-unavailable.json` is the shared golden response body. - -The response must not disclose the tenant ID, account ID, mapping state, or internal -reason. The dispatcher classifies this response as terminal `receiver_client_error`, -moves the event to the dead-letter state, and does not schedule an automatic retry. -After provisioning or mapping repair, recovery uses operational DLQ replay. The -receiver may retain a separate sanitized internal audit reason for operations, but -that reason is not part of the outward response. - -Transient receiver failures use non-2xx responses such as HTTP 429 or 5xx and are -eligible for Ticket 08's bounded retry and DLQ policy. - -For the same `(tenant_id, subject_ref, scoring_config_version)`, Omni compares -`run_created_at`, never `occurred_at`. A same-run duplicate and stale event are no-ops. -Two different run IDs with equal `run_created_at` are an ordering conflict and must not -overwrite current state. The receiver must preserve this decision and return the matching -acknowledgement even when delivery is replayed. - -## Consumer implementation boundary - -DotB-Omni owns signature verification, schema validation, tenant-scoped ordering, -identity-to-EMS resolution, and Marker Block writeback. It resolves the payload -`tenant_id` through a deployment-managed tenant-to-account mapping or allowlist and -scopes every lookup and writeback to that mapped account; the payload value is never used -as a direct record selector. Its background writeback job -handles EMS retry classification separately from webhook acknowledgement. AI Platform -owns outbox persistence and future dispatch, including subscriber provisioning and DLQ -visibility. Neither side should infer transport behavior from free-text error messages. diff --git a/old_repo/docs/operations/lead-scoring-observability.md b/old_repo/docs/operations/lead-scoring-observability.md deleted file mode 100644 index ec82e63..0000000 --- a/old_repo/docs/operations/lead-scoring-observability.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: Lead Scoring Observability -description: Quy ước telemetry, ngưỡng cảnh báo và kill switch cho Customer Pilot. -status: draft -tags: [lead-scoring, operations, privacy] ---- - -# Lead Scoring Observability - -## Telemetry boundary - -AI Platform dùng `Observability` làm telemetry sink trong pilot. Sink giữ counters, latency observations, structured events và alerts trong process; deployment có thể nối log handler hoặc backend vận hành mà không thay đổi domain code. - -Các metric chính: - -- `scoring_admission_total` và `scoring_admission_latency_seconds` -- `scoring_admission_accepted_total`, `scoring_admission_blocked_total`, `scoring_admission_replay_total` -- `scoring_run_latency_seconds`, `scoring_failed_total`, `scoring_stuck_runs_total` -- `aihub_failures_total`, `aihub_tokens_total` -- `scoring_degraded_total`, `scoring_hitl_total`, `scoring_override_total` -- `scoring_degraded_rate`, `scoring_hitl_rate`, `scoring_override_rate` -- `outbox_deliveries_total`, `outbox_retries_total`, `outbox_dead_letter_total` -- `retention_redactions_total`, `retention_events_cleaned_total`, `retention_failures_total` - -`run_id` được gắn vào event và latency record để trace giữa Lead Scoring và AIHub. `run_id`, `X-Request-ID` và `x-request-id` chỉ phục vụ correlation, không phải idempotency key và không tạo exactly-once guarantee. - -Outbound AIHub chat completions kèm `x-bf-dim-app` và `x-bf-dim-feature` (map từ `dotb_app`/`dotb_feature`) để ghi nhận app và feature dimension vào logs, Prometheus và OpenTelemetry của AIHub. Các header này chỉ phục vụ attribution, không dùng cho quota hay rate limit. - -Token usage chỉ ghi structured counts từ response `usage` của AIHub. Snapshot, prompt, response free text và PII không được ghi vào telemetry. - -## Redaction - -`redact_payload` thay thế các trường credential, snapshot, payload, message, content, email, phone, note, reason và reviewer identity bằng `[REDACTED]`. `record_exception` chỉ ghi exception type, không ghi exception message hoặc traceback. Credential được truyền vào redaction khi exception xảy ra ở AIHub client. - -Sentinel tests phải kiểm tra V-Key, service token, snapshot và PII không xuất hiện trong events, alerts, logs, traces hoặc error payloads. - -## Alert thresholds - -Ngưỡng được cấu hình qua `Settings` và chỉ là ngưỡng cảnh báo vận hành, không phải SLO hoặc quality gate: - -| Metric | Setting | Default | -| --- | --- | ---: | -| `scoring_stuck_runs_total` | `stuck_run_alert_threshold` | 1 | -| `aihub_failures_total` | `aihub_failure_alert_threshold` | 3 | -| `outbox_dead_letter_total` | `outbox_dlq_alert_threshold` | 1 | -| `retention_failures_total` | `retention_failure_alert_threshold` | 1 | - -Alert được phát ra khi counter lần đầu chạm ngưỡng. Các ngưỡng này không tự động retry, không tự động tắt hệ thống và không thay thế operational drill. - -## Kill switch - -`Settings.kill_switch_enabled=true` chặn admission mới tại `POST /v1/scoring-runs` và chặn child run mới từ Re-evaluate sau khi caller và tenant scope đã được xác thực. Approve và Override vẫn được phép. Response của admission bị chặn là `503` với body: - -```json -{ - "error_code": "scoring_admission_disabled", - "detail": "Scoring run initiation is temporarily disabled" -} -``` - -Kill switch không chặn polling, Approve/Override review, execution task đã được schedule, retention hoặc webhook dispatcher. Các run đã accepted và outbox events tiếp tục drain hoặc được terminalize theo recovery/DLQ policy. Tắt switch bằng cách cập nhật settings vận hành, không replay request cũ tự động. diff --git a/old_repo/docs/specs/lead-scoring-customer-pilot.md b/old_repo/docs/specs/lead-scoring-customer-pilot.md deleted file mode 100644 index 3b32015..0000000 --- a/old_repo/docs/specs/lead-scoring-customer-pilot.md +++ /dev/null @@ -1,454 +0,0 @@ ---- -title: "Lead Scoring Customer Pilot Specification" -description: "Đặc tả buildable và cross-service cho Lead Scoring Customer Pilot trên AI Platform, DotB-Omni, DotB EMS và AIHub." -version: "1.0.0" -status: approved -created: 2026-08-26 -updated: 2026-08-26 -owners: - - ai-platform - - dotb-omni ---- - -# Lead Scoring Customer Pilot Specification - -> Khi được phê duyệt, tài liệu này là nguồn sự thật cho **product behavior, scope, invariants và acceptance boundary** của Lead Scoring Customer Pilot. Executable contracts là nguồn sự thật cho exact wire format. `stack.md` là technology decision ledger có bằng chứng, không override tài liệu này. - -## Problem Statement - -DotB cần chấm mức độ tiềm năng của các Lead đang tồn tại trong DotB EMS bằng cả quy tắc nghiệp vụ và khả năng đọc hiểu ngữ cảnh hội thoại của AI. Hiện dữ liệu CRM, hội thoại, quyền người dùng, model access và quota nằm ở các hệ thống khác nhau; nếu tích hợp trực tiếp hoặc để mỗi ứng dụng tự định nghĩa scoring contract, hệ thống sẽ tạo nhiều nguồn sự thật, khó audit, dễ làm rò credential và khó phục hồi khi worker, AIHub hoặc EMS gặp lỗi. - -Customer Pilot cần giải quyết bài toán này cho khách hàng thật trên tenant allowlist mà vẫn giữ các boundary sau: - -- DotB EMS tiếp tục là nguồn dữ liệu CRM Lead. -- DotB-Omni chịu trách nhiệm identity resolution, conversation enrichment, giao diện cấu hình/HITL và EMS writeback. -- Lead Scoring service chịu trách nhiệm scoring config, scoring run, rule/AI scoring, review lifecycle và domain events. -- AIHub chịu trách nhiệm V-Key validation, model access, quota, credit và LLM audit. -- Raw tenant V-Key không trở thành dữ liệu persistent của Lead Scoring. -- Kết quả cũ, event trùng, AI failure hoặc Marker Block malformed không được âm thầm phá dữ liệu mới hơn hay ghi đè nội dung người dùng trên EMS. - -Pilot cũng cần tạo ra một executable contract đủ ổn định để AI Platform và DotB-Omni phát triển song song, thay vì phụ thuộc vào prose hoặc payload tự suy diễn. - -## Solution - -Xây một Lead Scoring service Python-first trên AI Platform với API caller-agnostic, durable scoring runs, bounded rule/AI pipelines, Generic Review API và Transactional Outbox. DotB-Omni tạo Canonical Lead Snapshot, gửi tenant V-Key qua header trong request, nhận domain events từ registered subscriber boundary và ghi kết quả hiện hành vào một Marker Block được quản lý an toàn trong EMS Lead `description`. - -Customer Pilot có hai production initiation paths: - -1. Người dùng chủ động chấm điểm trên Omni. -2. Captain chủ động gọi `create_and_score_ems_lead` trong một orchestration flow được phép. - -AI Platform Dev Console là caller nội bộ phục vụ test và integration verification với test V-Key riêng. Generic automatic input-event trigger chưa có use case và policy đủ rõ nên được defer sau Pilot. Điều này không ảnh hưởng completion events từ Lead Scoring sang Omni; completion delivery vẫn là phần bắt buộc của Pilot. - -Lead Scoring persist run trước khi trả `HTTP 202 Accepted`, giữ V-Key trong RAM của execution attempt, forward V-Key sang AIHub, tính rule/AI score và chuyển run tới `completed`, `review_required` hoặc `failed`. Khi AIHub thất bại, policy mặc định là `rules_only_review`: dùng rule score, đánh dấu degraded và bắt buộc HITL. Review actions gồm Approve, numeric Override và Re-evaluate; không có action hoặc state `Reject` riêng. - -Exact API, schema, error codes và examples được phát hành qua Shared Executable Contract Pack do AI Platform sở hữu và DotB-Omni review/consume. Mỗi repository có implementation spec riêng nhưng không được định nghĩa một wire contract cạnh tranh. - -## User Stories - -1. Là một tenant administrator, tôi muốn Lead Scoring chỉ xuất hiện khi EMS integration đang active, tenant thuộc Pilot allowlist và feature được bật, để tính năng không được sử dụng ngoài phạm vi thử nghiệm. -2. Là một tenant administrator, tôi muốn có kill switch an toàn, để có thể dừng initiation mới mà không làm hỏng EMS Lead hoặc conversation data. -3. Là một tenant administrator, tôi muốn cấu hình AIHub tenant credential qua một write-only backend flow, để raw V-Key không bị trả lại frontend sau khi lưu. -4. Là một tenant administrator, tôi muốn rotate hoặc clear AIHub tenant credential, để credential lifecycle không phụ thuộc vào deploy code. -5. Là một tenant administrator, tôi muốn quản lý scoring configuration trên Omni, để điều chỉnh rules, weights, score bands, HITL policy và AI effort cho tenant. -6. Là một tenant administrator, tôi muốn scoring configuration được lưu thành immutable versions tại Lead Scoring service, để mọi run có thể truy nguyên đúng config đã sử dụng. -7. Là một tenant administrator, tôi muốn review mọi run trong account, để có thể xử lý run unassigned, degraded hoặc có writeback failure. -8. Là một caller ứng dụng đã xác thực, tôi muốn chủ động chấm điểm Contact/EMS Lead theo quyền người dùng mà ứng dụng đã kiểm tra, để ưu tiên hành động tiếp theo bằng dữ liệu mới nhất. -9. Là một caller ứng dụng đã xác thực, tôi muốn thấy run đang pending, running, completed, review-required hoặc failed, để không bấm chấm điểm lặp lại vì thiếu feedback. -10. Là một caller ứng dụng đã xác thực, tôi muốn Approve kết quả theo quyền người dùng của ứng dụng, để xác nhận điểm hệ thống. -11. Là một caller ứng dụng đã xác thực, tôi muốn Override điểm bằng một số từ 0 đến 100, để phản ánh thông tin nghiệp vụ mà scoring chưa nhận biết đầy đủ. -12. Là một caller ứng dụng đã xác thực, tôi muốn dùng điểm 0 cho spam hoặc lead rác mà không cần một state Reject riêng, để domain state không phình to không cần thiết. -13. Là một caller ứng dụng đã xác thực, tôi muốn yêu cầu Re-evaluate, để hệ thống tạo snapshot mới và child run thay vì sửa kết quả cũ. -14. Là một caller ứng dụng, tôi muốn quyền review được kiểm tra ở hệ thống sở hữu phiên người dùng, để LS không phải đọc assignment hoặc mô hình quyền riêng của caller. -15. Là một caller ứng dụng, tôi muốn LS ghi nhận đúng actor và ghi chú review, để quyết định vẫn có audit dù authorization nằm ở caller. -16. Là một caller ứng dụng, tôi muốn run không có trigger conversation hoặc conversation chưa assign vẫn có thể review khi quyền caller cho phép, để không tạo rule authorization trùng lặp ở LS. -17. Là một Omni user, tôi muốn permission được backend Omni enforce chứ không chỉ ẩn button, để API không thể bị gọi vượt quyền từ client khác. -18. Là một Omni user, tôi muốn Contact có direct EMS mapping được dùng ngay, để không lookup và chọn nhầm Lead khác. -19. Là một Omni user, tôi muốn khi chưa có direct mapping, Omni tìm EMS candidates theo normalized phone, để identity resolution có điểm bắt đầu deterministic. -20. Là một Omni user, tôi muốn closed Leads bị loại khi vẫn còn active candidate, để scoring áp dụng cho Lead đang được xử lý. -21. Là một Omni user, tôi muốn email và tên chỉ xếp hạng trong phone candidate set, để một Lead chỉ khớp email không thay thế phone candidate ngoài policy. -22. Là một Omni user, tôi muốn `date_modified` làm tie-breaker deterministic, để cùng một input luôn chọn cùng EMS Lead. -23. Là một reviewer, tôi muốn phone-only match được ghi `match_quality=phone_only`, để biết identity evidence yếu hơn direct hoặc secondary match. -24. Là một Omni user, tôi muốn không có candidate thì không tạo scoring run, để LS không chấm một subject không xác định. -25. Là một Omni user, tôi muốn resolved EMS Lead được lưu lại trên Contact và gửi làm `subject_ref`, để các lần sau dùng cùng canonical identity. -26. Là một Lead Scoring caller, tôi muốn gửi Canonical Snapshot có version và field constraints rõ ràng, để provider có thể validate deterministic. -27. Là một privacy owner, tôi muốn snapshot chỉ chứa conversation evidence trong phạm vi đã chốt, để AI Platform không trở thành kho conversation thứ hai không giới hạn. -28. Là một tenant, tôi muốn snapshot giới hạn tối đa 10 conversations trong 90 ngày, để kiểm soát privacy và context size. -29. Là một tenant, tôi muốn evidence bị giới hạn ở 50 messages hoặc 20.000 ký tự, để chi phí và latency có hard cap. -30. Là một reviewer, tôi muốn evidence truncation loại messages cũ nhất trước và ghi metadata, để biết kết quả được tạo từ phạm vi dữ liệu nào. -31. Là một Omni user, tôi muốn manual scoring đóng băng snapshot tại thời điểm submit, để messages đến sau không âm thầm thay đổi run đang xử lý. -32. Là Captain, tôi muốn upsert EMS Lead rồi tạo scoring run qua cùng API với manual flow, để không có scoring logic riêng trong Captain. -33. Là Captain, tôi muốn EMS Lead vẫn được giữ nếu scoring initiation thất bại, để CRM data không bị rollback vì AI service lỗi. -34. Là Captain, tôi muốn raw V-Key không bao giờ được đưa vào prompt, tool result hoặc LLM-visible context, để credential không bị model nhìn thấy. -35. Là một internal developer, tôi muốn dùng AI Platform Dev Console để gửi fixtures qua cùng Scoring Run API, để kiểm tra contract mà không phụ thuộc Omni UI. -36. Là một internal developer, tôi muốn Dev Console dùng test V-Key riêng ở backend, để test không lộ hoặc tiêu quota production ngoài ý muốn. -37. Là một Lead Scoring caller, tôi muốn request hợp lệ được persist thành run trước khi nhận `HTTP 202`, để accepted request không biến mất im lặng. -38. Là một Lead Scoring caller, tôi muốn LS tự sinh `run_id` và trả `status_url`, để caller không phải sở hữu run identity. -39. Là một Lead Scoring caller, tôi muốn cùng tenant, idempotency key và payload trả lại run hiện có, để duplicate submission không tạo run mới. -40. Là một Lead Scoring caller, tôi muốn cùng idempotency key nhưng payload khác bị từ chối bằng `HTTP 409`, để key reuse không che giấu hai hành động khác nhau. -41. Là một operator, tôi muốn run bị mất in-memory task/V-Key do worker interruption chuyển thành failed/retryable, để không có run kẹt vô hạn. -42. Là một caller, tôi muốn tạo request mới với idempotency key mới sau run failed, để retry là một hành động scoring mới có audit rõ ràng. -43. Là một tenant, tôi muốn rule score và AI score được kết hợp bằng versioned weights, để kết quả vừa minh bạch vừa tận dụng ngữ cảnh hội thoại. -44. Là một tenant, tôi muốn `light` cung cấp một bounded AI pass, để ưu tiên tốc độ và chi phí. -45. Là một tenant, tôi muốn `medium` là mặc định và tách evidence extraction khỏi scoring, để AI feature có giá trị hơn một score prompt đơn lẻ. -46. Là một tenant, tôi muốn `heavy` là opt-in và thêm bounded critique/cross-check, để các trường hợp cần phân tích kỹ có thể dùng thêm reasoning. -47. Là một operator, tôi muốn mọi AI effort dùng chung output contract, để các thành phần downstream không cần biết pipeline nội bộ. -48. Là một security owner, tôi muốn AI pipelines không dùng Internet, autonomous tools hoặc unbounded loops, để runtime và dữ liệu nằm trong boundary kiểm soát. -49. Là một reviewer, tôi muốn partial AI result bị discard nếu bất kỳ pass nào thất bại, để không review một kết quả chắp vá. -50. Là một tenant, tôi muốn AIHub failure mặc định tạo `rules_only_review`, để hệ thống vẫn có điểm tạm nhưng không auto-approve khi AI bị degraded. -51. Là một tenant có policy nghiêm ngặt, tôi muốn có thể chọn `fail_run` khi AIHub lỗi, để không dùng rule-only result nếu nghiệp vụ không chấp nhận. -52. Là một operator, tôi muốn LS không auto-retry AIHub timeout/network/5xx trong Pilot, để hạn chế duplicate provider invocation và quota. -53. Là một operator, tôi muốn `run_id` được truyền qua `x-request-id` tới AIHub, để correlate logs mà không lưu thêm credential hoặc prompt. -54. Là một product owner, tôi chấp nhận AIHub invocation có semantics at-least-once trong crash window của Pilot, để không phải xây exactly-once subsystem trước khi có nhu cầu thực tế. -55. Là một reviewer, tôi muốn confidence quyết định auto-complete hay review-required, để score không bị dùng nhầm làm review threshold. -56. Là một reviewer, tôi muốn default confidence threshold là 0,80 nhưng policy có version, để thay đổi sau này có thể audit. -57. Là một reviewer, tôi muốn Approve giữ calculated score, để quyết định không làm mất kết quả gốc. -58. Là một reviewer, tôi muốn Override giữ cả calculated score và effective score, để audit thấy điểm nào do người sửa. -59. Là một reviewer, tôi muốn Re-evaluate tạo child run và supersede run cũ, để lineage được bảo toàn. -60. Là một reviewer, tôi muốn concurrent review command dùng `expected_run_version` và trả `409` khi stale, để quyết định cũ không ghi đè quyết định mới. -61. Là một Omni UI user, tôi muốn UI reload khi nhận review conflict và không tự replay command, để người dùng nhìn thấy trạng thái hiện tại trước khi quyết định lại. -62. Là một subscribing service, tôi muốn LS ghi event cùng run transition trong một transaction, để committed result không mất completion notification. -63. Là một subscribing service, tôi muốn completion delivery là at-least-once và có bounded retry/DLQ, để transient failure không mất event. -64. Là một security owner, tôi muốn LS chỉ gửi tới registered subscriber endpoint và không nhận arbitrary callback URL từ create request, để tránh SSRF. -65. Là một Omni consumer, tôi muốn event có `run_id`, `run_created_at`, tenant, subject và config version, để có thể xác định latest applicable run. -66. Là một Omni consumer, tôi muốn stale hoặc same-run duplicate event là no-op và được ack `HTTP 200`, để dispatcher không retry vô ích. -67. Là một Omni consumer, tôi muốn ordering dùng `run_created_at` thay vì `event.occurred_at`, để run hoàn tất muộn không bị hiểu là run mới hơn. -68. Là một Omni consumer, tôi muốn hai run khác nhau cùng `run_created_at` tạo ordering conflict thay vì tự ghi đè, để ambiguity không làm mất kết quả. -69. Là một EMS user, tôi muốn Omni chỉ thay đúng một Marker Block hợp lệ và giữ nguyên user text ngoài block, để scoring không phá ghi chú CRM. -70. Là một EMS user, tôi muốn Omni append Marker Block khi không có marker tag, để Lead lần đầu vẫn nhận kết quả. -71. Là một EMS user, tôi muốn orphan marker hoặc nhiều block làm writeback terminal mà không sửa description, để parser không nuốt hoặc ghi đè nội dung người dùng. -72. Là một operator, tôi muốn EMS timeout/network/5xx dùng bounded retry còn malformed block không retry, để retry chỉ áp dụng cho lỗi có khả năng tự hồi phục. -73. Là một operator, tôi muốn writeback failure không làm scoring run completed trở thành failed, để scoring lifecycle và delivery lifecycle không bị trộn. -74. Là một tenant administrator, tôi muốn xóa toàn bộ Marker Block không tự disable writeback, để feature state chỉ thay đổi qua configuration rõ ràng. -75. Là một privacy owner, tôi muốn raw conversation, snapshot, direct PII, free-text AI/reviewer và personal derived fields bị xóa sau 90 ngày, để AI Platform giảm dữ liệu nhạy cảm lưu lâu dài. -76. Là một privacy owner, tôi muốn chỉ giữ structured audit tối thiểu sau redaction, để vẫn điều tra run mà không giữ một hồ sơ Lead thứ hai. -77. Là một privacy owner, tôi muốn structured audit bị purge khi tenant offboard, để dữ liệu không tồn tại vô hạn sau khi quan hệ khách hàng kết thúc. -78. Là một product owner, tôi muốn early deletion integration chỉ được xây nếu Omni có reliable deletion/privacy event trong Pilot, để không tạo subsystem dựa trên giả định. -79. Là một operator, tôi muốn metrics bao phủ run terminality, latency, AIHub failure, HITL, override, token, outbox và writeback, để Pilot tạo baseline thật. -80. Là một product owner, tôi muốn chưa đặt SLO/quality threshold cứng trước baseline, để exit criteria không dựa trên con số suy đoán. -81. Là một operator, tôi muốn no silent stuck run/event/writeback, để mọi công việc đều complete, fail, retry, DLQ hoặc alert rõ ràng. -82. Là một product owner, tôi muốn Pilot chỉ kết thúc khi retention và kill switch được kiểm chứng, không còn P0/P1 mở và có workload tenant thật đã thỏa thuận, để GA decision dựa trên evidence. - -## Implementation Decisions - -### Authority và document hierarchy - -- Tài liệu này sở hữu product behavior, scope, cross-service invariants và acceptance boundary của Customer Pilot. -- Shared Executable Contract Pack sở hữu exact request/response/event shapes, enums, field constraints, machine-readable error codes và golden examples. -- Pydantic models là nguồn API/data model phía provider; FastAPI-generated OpenAPI và generated JSON Schemas là derived artifacts, không được hand-edit song song. -- DotB-Omni và AI Platform có implementation spec riêng cho code/module seams của từng repository; chúng không được định nghĩa wire contract cạnh tranh. -- `stack.md` ghi technology status, decisions và trade-offs; nó không thay đổi product scope hoặc approved behavior. -- Discovery docs, RFC drafts và Grill Brief vẫn là nguồn context/lý do. Khi một statement discovery cũ mâu thuẫn với spec đã approved, spec thắng; exact wire format vẫn theo released executable contract. - -### System ownership - -- DotB EMS là source of truth cho CRM Lead data. -- DotB-Omni sở hữu Contact-to-EMS identity resolution, Canonical Snapshot construction, caller-side V-Key boundary, customer-facing Settings/HITL UI, completion-event consumption và EMS Marker Block writeback. -- Lead Scoring sở hữu immutable scoring config versions, scoring runs, scoring calculations, review state, audit events và outgoing domain events. -- AIHub sở hữu V-Key semantics, model access/routing, quota, credit, rate limits và LLM audit. -- AI Platform Dev Console là internal test caller; nó không sở hữu customer-facing scoring configuration hoặc HITL UI. - -### Pilot initiation boundary - -- Customer-facing production initiation paths chỉ gồm manual Omni action và Captain tool invocation. -- Captain phải chủ động chọn tool trong một orchestration flow được phép; incoming message tự nó không phải scoring trigger. -- AI Platform Dev Console có thể gọi cùng API bằng fixture/test data và test V-Key riêng, nhưng không được xem là Customer Pilot feature. -- Generic automatic input-event trigger được defer. Completion events từ LS sang Omni vẫn bắt buộc và không phải input trigger. -- LS chưa cung cấp batch API; caller fan-out nếu sau này có use case batch được phê duyệt. - -### Eligibility và authorization - -- Lead Scoring chỉ có thể sử dụng khi EMS integration active, account có `lead_scoring` capability, tenant thuộc Pilot allowlist và global availability/kill-switch cho phép. -- Credential readiness và service caller authorization phải được backend kiểm tra; end-user authorization thuộc caller ứng dụng và UI không thay thế backend enforcement của caller. -- Administrator quản lý Settings và AIHub tenant credential; quyền review do caller ứng dụng quyết định. -- Omni chịu trách nhiệm xác thực người dùng cuối, vai trò, assignment và quyền Approve, Override hoặc Re-evaluate trước khi gọi Review API. -- `reviewer.actor_id` là danh tính do caller gửi để ghi audit. `reviewer.role` và `current_assignee_id` chỉ là metadata tương thích cho audit/context, không phải bằng chứng phân quyền tại LS. -- LS không đọc trigger conversation, assigned agent hoặc reviewer role để cấp hay từ chối quyền review. -- LS chỉ xác thực service caller, operation scope, tenant boundary, run state, idempotency và optimistic concurrency. -- Pilot dùng permission vocabulary hiện có của ứng dụng caller và không mở rộng CustomRole chỉ cho Lead Scoring. -- Nếu caller bỏ qua end-user authorization, đó là lỗi boundary của caller, không phải logic phân quyền mới tại LS. - -### Canonical Lead Identity - -- Canonical subject của một run là một EMS Lead đã tồn tại. -- Direct `dotb_ems_id` mapping có ưu tiên cao nhất. -- Khi chưa có mapping, Omni tạo candidate set bằng normalized phone lookup. -- Closed candidates bị loại khi còn active candidates. -- Exact email/name chỉ xếp hạng trong phone candidate set. -- `date_modified` là deterministic tie-breaker. -- Phone-only result được phép và phải mang `match_quality=phone_only`. -- Không có candidate thì không tạo run. -- Resolved EMS Lead, kể cả phone-only result, được lưu lại trên Contact và dùng làm `subject_ref` theo Policy v1. Omni phải lưu match quality cùng mapping hoặc cung cấp khả năng sửa mapping; Policy v1 không tự re-resolve một direct mapping ở lần scoring sau. - -### Canonical Snapshot - -- Omni tạo snapshot versioned từ EMS Lead profile, scoped conversation evidence, identity context, trigger metadata và HITL policy. -- Snapshot không chứa raw CRM credential hoặc AIHub V-Key. -- Contract hỗ trợ `trigger_only`, `linked_contact_history` và `multi_source_history`; `linked_contact_history` là mặc định. -- Omni chịu trách nhiệm structural caps: mọi conversation scope tối đa 10 conversations trong 90 ngày, 50 messages hoặc 20.000 ký tự; chỉ incoming/outgoing messages có content được dùng. -- Khi vượt structural cap, Omni bỏ evidence cũ nhất trước và ghi truncation metadata. -- LS vẫn kiểm tra internal model token budget sau admission và có thể rút gọn evidence thêm trước AIHub; lần rút gọn này phải cập nhật lineage/truncation metadata mà không thay đổi snapshot request gốc đã lưu. -- Hành vi admission khi caller vi phạm structural caps thuộc executable contract; spec không tự khóa HTTP status trước Contract Sprint. -- Create request không chứa `run_id`; LS sinh run identity sau khi admission thành công. -- Exact required/nullable fields thuộc executable schema, không được Omni hoặc LS suy diễn riêng. - -### Scoring Run admission và idempotency - -- Mọi initiation path dùng chung `POST /v1/scoring-runs` và cùng contract. -- Required caller metadata gồm tenant V-Key, application, feature và idempotency key qua headers được định nghĩa trong executable contract. -- LS validate request, persist run `pending`, sau đó trả `HTTP 202` với server-generated `run_id` và `status_url`. -- Idempotency key scoped theo tenant. -- Cùng key và cùng normalized request trả run hiện có. -- Cùng key nhưng normalized request khác trả `HTTP 409` với machine-readable mismatch error. -- Run đã failed muốn chấm lại là action mới với idempotency key mới. - -### V-Key lifecycle và execution durability - -- Caller sở hữu/provide V-Key phù hợp; AIHub tenant credential là domain concept riêng và không được mặc định đồng nhất với Captain/OpenAI account key. LS chỉ giữ raw V-Key trong RAM của current attempt và dùng transport chuẩn `Authorization: Bearer ` khi gọi AIHub. AIHub có thể hỗ trợ credential header khác, nhưng chúng không thuộc Lead Scoring contract và consumer không được tự chọn transport. -- Raw V-Key không được lưu vào DB, persistent queue, cache, checkpoint, logs, trace payload, error metadata, crash dump hoặc browser code. -- Run được persist trước `202`, nhưng execution task/V-Key vẫn là in-memory boundary của Pilot. -- Worker/process interruption làm mất task/V-Key phải chuyển run không còn khả năng hoàn tất thành `failed`, `error_code=worker_interrupted`, `retryable=true`. -- Recovery/stuck-run mechanism phải bảo đảm không có run tồn tại vô hạn ở `pending` hoặc `running` mà không terminalize/alert. -- Exact worker topology và database engine là implementation decisions, nhưng phải thỏa các invariants trên. - -### Service-to-service caller authentication - -- Customer Pilot chỉ cho phép một tập machine caller được registered/allowlisted. Omni production và AI Platform Dev Console là hai caller identities riêng; mỗi caller và mỗi environment dùng một opaque service token riêng. -- Caller gửi service token qua chuẩn `Authorization: Bearer `. Lead Scoring nạp allowlist từ deployment secret/configuration ngoài repository, map token với caller identity và allowed operation/scope, rồi xác thực trước khi xử lý payload hoặc tạo run. -- `X-DotB-App` và `X-DotB-Feature` là metadata/telemetry; chúng phải khớp caller đã authenticate nhưng không tự chứng minh caller identity. -- `X-Tenant-VKey` là tenant credential cho Lead Scoring → AIHub, không phải service identity cho Omni hoặc Dev Console. -- Service token được tạo bằng cryptographically secure random generation, provision ở backend deployment secret/ENV, không lưu trong source, browser, account data, integration settings, logs hoặc traces. Omni dùng system-level configuration; không lưu token theo từng tenant. -- Mất hoặc sai service token phải bị reject trước admission; authenticated caller nhưng sai scope hoặc tenant boundary cũng phải bị reject. Exact error taxonomy thuộc Contract Pack. -- Pilot không dựng mới JWT issuer/OAuth server, mTLS, gateway hoặc service mesh cho caller authentication. Rotation/revocation và secret delivery phải được kiểm thử trong deployment/operational runbook. - -### Scoring configuration và calculation - -- LS là source of truth cho immutable scoring config versions; Omni là editor/UI client. -- Rule score, AI score và final score nằm trong khoảng 0–100. -- Final score là weighted combination của rule và AI components, với weights có tổng bằng 1. -- Config lưu score bands, rules/weights, AI effort, failure policy và HITL policy/version cần thiết để tái hiện decision context. -- Legacy rules/prompts chỉ là candidate inputs; trước production use cần product validation và deterministic fixtures. -- Exact default rules/weights ngoài những gì released config xác định không được hard-code như universal domain truth. - -### Bounded AI pipelines - -- `light`, `medium` và `heavy` là orchestration semantics do LS sở hữu, không phải capability native mà AIHub phải cung cấp. -- `light` chạy một bounded AI analysis pass. -- `medium` là mặc định và dùng bounded evidence-extraction rồi scoring passes. -- `heavy` là opt-in và bổ sung bounded critique/cross-check trước final scoring. -- Ba effort profiles dùng chung output contract. -- Không profile nào dùng autonomous tools, Internet access hoặc unbounded loop trong Pilot. -- Nếu một pass thất bại, toàn bộ partial AI result bị discard và `on_ai_failure` được áp dụng. -- LS sở hữu số pass, model mapping, token caps và timeout budgets cho từng effort profile; các giá trị này được cấu hình/kiểm chứng trong provider implementation và Contract Sprint mà không thay đổi product semantics của ba profiles. - -### AIHub interaction và failure policy - -- LS gửi `run_id` qua `x-request-id` chỉ để correlation. Contract không cam kết AIHub trả header này về response, forward nó tới provider hoặc dùng nó cho idempotency. -- `dotb_app` và `dotb_feature` đã persist lúc admission được map sang AIHub dưới dạng `x-bf-dim-app` và `x-bf-dim-feature` để AIHub ghi nhận telemetry/log dimensions. Lead Scoring không forward `X-DotB-*` sang AIHub. Các dimension header này chỉ phục vụ log attribution, không phải auth hay quota. -- `run_id` và `x-request-id` không phải AIHub idempotency key; replay có thể tạo invocation và quota charge mới. -- LS không auto-retry AIHub invocation trong Pilot. AIHub có thể có retry/provider-fallback nội bộ theo gateway configuration; Contract Sprint phải pin Pilot profile về max attempts, timeout, fallback chain và quota semantics thay vì dựa vào gateway defaults. -- Remote AIHub invocation có semantics at-least-once; duplicate provider invocation/quota trong crash window được chấp nhận và phải được ghi như Pilot limitation. -- Không tuyên bố exactly-once end-to-end. -- `rules_only_review` là failure default: `final_score=rule_base_score`, `degraded=true`, scoring mode ghi rules-only fallback và run bắt buộc `review_required`. -- `fail_run` là config alternative và tạo failed run với machine-readable AIHub failure outcome. -- AIHub quota, budget, rate-limit, authentication và provider failures phải được normalize theo executable taxonomy có `error_code`, HTTP status, `retryable` và `fallback_allowed`; consumer không được parse reason text của AIHub làm stable contract. - -### Scoring Run và review lifecycle - -- Primary states gồm `pending`, `running`, `completed`, `review_required`, `failed`; `superseded` biểu diễn run cũ khi Re-evaluate tạo child run. -- Calculated data và config lineage của run không bị sửa để tạo lịch sử khác; retention redaction được phép xóa sensitive evidence theo policy mà không sửa structured audit. -- Approve giữ calculated score làm effective score. -- Override nhận numeric score 0–100 và giữ cả calculated/effective values. -- Với Re-evaluate, Omni thu thập snapshot mới tại thời điểm command; LS tạo child run với parent lineage và supersede run cũ sau khi child run được tạo thành công. Executable Review contract phải định nghĩa cách snapshot mới và optional reviewer context được truyền, không yêu cầu implementation suy diễn từ run cũ. -- Không có Reject action/state riêng. -- Review command dùng một Generic Review endpoint, idempotency key và `expected_run_version`. -- Stale review command trả `HTTP 409`; UI reload state và không tự replay command cũ. -- Free-text reviewer reason có thể tồn tại trong active retention window nhưng phải bị redacted theo mục Privacy, retention và audit; structured audit sau redaction không phụ thuộc free text. - -### Completion events và Transactional Outbox - -- Run transition và outbox event được commit cùng transaction. -- Kill switch chặn initiation mới nhưng không được làm rơi completion webhook hoặc writeback retry của các run đã accepted; các công việc đã accepted phải được drain hoặc terminalize có audit. -- Dispatcher gửi events at-least-once tới registered subscription endpoints với bounded retry và DLQ/terminal visibility. -- Caller không được cung cấp arbitrary callback URL trong create request. -- Event envelope chứa event identity/type/version, tenant, subject, config version, `run_id`, `run_created_at`, `occurred_at` và structured event data; không chứa V-Key hoặc raw PII/evidence. -- Provisioning và authentication của registered subscriptions phải được chốt trong Contract Sprint trước webhook implementation; dynamic registration UI/API không phải requirement của Pilot. -- Polling run status là fallback cho simple/internal clients. - -### Event ordering tại Omni - -- Latest applicable run là run có `run_created_at` mới nhất cho cùng `(tenant_ref, subject_ref / ems_lead_id, scoring_config_version)`. -- Event và Marker Block mang `run_id` cùng `run_created_at`. -- Stale event hoặc same-run duplicate đã writeback là no-op, được ack `HTTP 200` và ghi audit reason. -- `event.occurred_at` không dùng để xác định latest run. -- Hai run khác nhau cùng `run_created_at` tạo ordering conflict; consumer giữ state hiện tại và không tự ghi đè. -- Pilot không thêm Redis lock, distributed lock hoặc event-id idempotency table chỉ để xử lý ordering. - -### EMS Marker Block writeback - -- Omni đọc EMS Lead `description` mới nhất trước khi merge/write. -- Đúng một Marker Block hợp lệ được replace toàn khối, bảo toàn user text bên ngoài. -- Không có marker tag thì append block mới. -- Orphan tag hoặc nhiều block là malformed: không sửa description, đặt writeback terminal với `marker_block_malformed` và không auto-retry. -- EMS/network transient errors là retryable và dùng bounded backoff. Exact classification cho authentication, permission, not-found, rate-limit, timeout và HTTP 5xx thuộc consumer contract/implementation spec; chúng không được hard-code từ giả định trong master spec. -- Writeback lifecycle thuộc Omni và không thay đổi completed scoring lifecycle tại LS. -- Xóa toàn bộ Marker Block không có nghĩa disable writeback; disable chỉ qua feature/configuration. -- Exact Marker template và merge examples thuộc shared fixtures, không copy bằng prose khác nhau giữa hai implementations. - -### Privacy, retention và audit - -- Raw conversation, snapshot body/hash, direct PII, AI/reviewer free text và personal derived fields bị xóa/redact sau 90 ngày. -- Nếu Omni đã có reliable hard-deletion/privacy-erasure signal trong Pilot, LS nhận signal để redact sớm; nếu chưa có thì integration này được defer, scheduled retention vẫn bắt buộc. -- Sau redaction chỉ giữ structured audit tối thiểu: run/correlation IDs, tenant/subject refs, timestamps, status, scores/confidence/classification, config/model versions, scoring mode/degraded/error, token counts, review decision/numeric override, writeback outcome và non-personal structured codes. -- “Structured audit” là yêu cầu ở cấp hệ thống, không bắt mọi field nằm trong LS database. Writeback status/outcome thuộc Omni; Pilot không yêu cầu callback chỉ để sao chép writeback state về LS. -- Reviewer actor identity không được giữ sau post-redaction boundary trong Pilot. -- Structured audit tồn tại khi tenant active và bị purge khi tenant offboard. -- Pilot không hỗ trợ retention period tùy chỉnh theo tenant. - -### Dev Console boundary - -- Dev Console là internal Svelte/TypeScript test surface, không phải customer-facing Pilot UI. -- Dev Console dùng cùng generated API client/contract và server-side test V-Key riêng. -- Raw test V-Key không xuống browser. -- Console có thể chạy fixtures, inspect run status, test effort/failure paths và một allowlisted integration tenant; nó không duplicate Omni Settings hoặc HITL business UI. - -### Shared Executable Contract Pack - -- Contract Pack phải cung cấp generated OpenAPI, generated JSON Schemas, machine-readable error/outcome taxonomies, security profile, golden request/response/event examples và Marker Block fixtures. -- Contract Pack phải bao phủ Scoring Config Management contract đủ để Omni đọc, tạo và chọn/apply immutable config versions mà không sở hữu một config copy authoritative. -- Generic Review contract phải định nghĩa decision variants, success response, status/polling và event semantics. -- Subscriber contract phải định nghĩa authentication/rotation/replay, ingest acknowledgement cho accepted/duplicate/stale/invalid deliveries, và tách webhook ingest thành công khỏi EMS writeback hoàn tất. -- Contract Pack bắt đầu bằng pre-release version và được version/pin để Omni và LS chạy producer/consumer validation trên cùng artifacts. -- Admission HTTP errors, asynchronous run outcomes và Omni writeback errors là ba lifecycle khác nhau; taxonomy phải ghi rõ owner, transport và retry semantics. -- Generic Review API là một endpoint với decision variants, không tách thành ba endpoint không cần thiết. -- Contract distribution mechanism và webhook authentication là technical decisions cần chốt trong Contract Sprint. -- Security profile phải ghi rõ approved service-token mechanism: header, caller mapping, scope/tenant binding, rotation/revocation và authentication failure semantics; không được biến `X-Tenant-VKey` hoặc self-asserted app headers thành caller identity. - -### Delivery boundaries - -- Contract Sprint phát hành executable contracts và fixtures trước khi hai repository triển khai sâu theo payload. -- Sau contract baseline, AI Platform và DotB-Omni triển khai song song qua provider/consumer specs riêng. -- AI Platform lane gồm service shell, contract models, run/config persistence, state machine, AIHub adapter, review API, outbox, retention và internal console boundary. -- Omni lane gồm feature gating, identity resolution, Snapshot Builder, client, Captain tool, webhook consumer, ordering/writeback state, Marker parser/worker và Settings/HITL UI. -- Hai lanes hội tụ ở bidirectional contract tests, sau đó E2E sandbox và failure injection. -- E2E runbook được hoàn thiện khi các executable components và integration environment tồn tại; không ghi secret thật. - -### Customer Pilot exit criteria - -- Không có raw V-Key/PII leakage trên các persistent và observability surfaces đã xác định. -- Manual và Captain E2E flows hoạt động với fallback, HITL, completion event và EMS writeback. -- Không có silent stuck runs, outbox deliveries hoặc writeback jobs. -- Retention cleanup và kill switch được kiểm chứng bằng executable tests/drills. -- Không còn P0/P1 mở thuộc Pilot scope. -- Allowlisted tenant hoàn thành duration/run volume đã thỏa thuận. -- Pilot thu baseline latency, AIHub failure, HITL, override, token usage, outbox và writeback metrics. -- Không dùng threshold SLO/quality suy đoán làm exit gate trước baseline. - -## Testing Decisions - -- Tests ưu tiên external behavior và contract seams; không khóa tests vào class layout, private methods hoặc implementation topology có thể thay đổi. -- Shared fixtures phải là cùng một corpus cho provider validation và Omni consumer contract tests; không duy trì payload giả cạnh tranh trong từng repository. -- Contract generation phải deterministic và verification phải fail khi generated OpenAPI/schema drift khỏi committed artifacts. -- Domain tests phải bao phủ deterministic identity ranking, snapshot caps/truncation, scoring formula bounds, state transitions, idempotency, review concurrency, latest-run ordering và Marker Block merge outcomes. -- API tests phải bao phủ admission success/validation, `202`, polling status, idempotency replay, payload mismatch `409`, review decisions và review-version conflict. -- Worker/fault-injection tests phải bao phủ worker interruption, AIHub timeout/error/quota, partial AI pass failure, outbox retry/DLQ và stuck-run recovery. -- Security tests phải dùng sentinel credentials để chứng minh V-Key không xuất hiện trong database, queue/checkpoint, logs, traces, error payloads, generated events hoặc browser-visible responses. -- Privacy tests phải chứng minh 90-day redaction xóa evidence/PII/free text/personal derived data nhưng giữ đúng structured audit, đồng thời purge tenant data khi offboard. -- Omni consumer tests phải bao phủ feature gating, RBAC enforcement, Identity Resolution v1, Snapshot schema validation, V-Key-safe client behavior, event auth/validation, stale/duplicate/order conflict và writeback retry classification. -- Marker Block tests phải dùng shared text fixtures cho append, replace, duplicate/stale no-op, orphan start/end, multiple blocks và user text preservation. -- Captain tests phải chứng minh explicit tool invocation, duplicate protection, EMS-first partial failure behavior và không đưa raw V-Key vào LLM-visible context. -- Dev Console tests phải chứng minh generated client compatibility và không expose server-side test V-Key. -- E2E integration tests phải chạy tối thiểu manual happy path, Captain partial failure, degraded AI fallback, review conflict, completion delivery, stale event, malformed Marker Block, EMS timeout và kill-switch behavior. -- Retention 90 ngày không cần test bằng chờ thời gian thật; clock được kiểm soát tại boundary để cleanup có deterministic verification. -- `oasdiff` chỉ trở thành blocking compatibility check sau khi có released OpenAPI baseline; schema/property testing được bật khi real app/auth/external overrides deterministic. -- Pilot readiness cần cả automated verification và operational drill cho kill switch, outbox backlog, worker interruption, AIHub outage, EMS outage và credential leak suspicion. - -## Out of Scope - -- Opportunity Scoring hoặc Contact Scoring độc lập. -- Customer-facing configuration/HITL frontend trong repository AI Platform. -- HITL UI nhúng trực tiếp trong DotB EMS. -- EMS custom database fields dành riêng cho Lead Scoring trong Pilot. -- Tự động map score/classification sang Contact Stage hoặc EMS Lead status. -- Generic automatic input-event trigger như message-created, conversation-created/resolved hoặc EMS lifecycle event. -- Public Internet API hoặc arbitrary caller callback URL. -- Batch Scoring API; caller-side fan-out là strategy hiện tại nếu có use case được phê duyệt. -- Dedicated broker như Kafka hoặc RabbitMQ. -- Exactly-once AIHub invocation, AIHub response cache hoặc provider billing deduplication. -- Automatic LS retry cho ambiguous AIHub failures. -- Autonomous AI tools, Internet search hoặc unbounded agent loops trong scoring pipeline. -- Dynamic per-tenant retention policy trong Pilot. -- General-purpose subject-erasure/privacy orchestration platform; early deletion chỉ tích hợp nếu Omni đã có reliable signal. -- Dynamic webhook registration UI/API nếu static/managed registered subscriptions đáp ứng Pilot; exact provisioning mechanism thuộc Contract Sprint. -- Score/confidence authoritative persistence trong Omni hoặc scoring config authoritative copy ngoài LS. -- Score-to-stage automation. -- Reuse production Omni tenant V-Key trong Dev Console ngoài integration test được phê duyệt. -- Reuse nguyên trạng runtime architecture, CRM adapter, in-memory checkpoint hoặc legacy HITL semantics của `lead-scoring-agent`. -- Production deployment platform, autoscaling topology, monitoring vendor hoặc infrastructure provisioning trong spec này; chúng phải thỏa invariants trước Pilot deployment. - -## Further Notes - -### Source hierarchy - -- Tài liệu này là master build specification sau khi được approved. -- Exact wire contract được phát hành qua Shared Executable Contract Pack do AI Platform sở hữu và DotB-Omni review. -- RFC, Current Status và Grill Brief trong DotB Vault là decision history/context và phải được cập nhật hoặc đánh dấu superseded khi mâu thuẫn với spec approved. -- `stack.md` tiếp tục ghi technology decisions/candidates. Mọi trạng thái “selected” chỉ có nghĩa adopted khi đã được spec hoặc explicit decision phê duyệt. - -### Known contract clarifications carried into Contract Sprint - -- LS sinh `run_id`; create request không gửi `run_id`. -- Same idempotency key với normalized payload khác trả `HTTP 409`. -- Generic Review dùng một endpoint và decision variants. -- Automatic input-event trigger bị defer; completion events vẫn thuộc Pilot. -- Webhook subscriber authentication, secret rotation, replay handling và acknowledgement semantics phải được chốt trước webhook implementation. -- Contract packaging/version pinning phải cho phép cùng fixtures chạy ở producer và consumer repositories. - -### Service implementation specs - -- AI Platform cần một provider implementation spec sau contract baseline, tập trung vào service/runtime/persistence/worker seams và không mô tả Omni UI/Rails internals. -- DotB-Omni cần một consumer implementation spec sau khi Omni grilling hoàn tất, tập trung vào identity/snapshot/client/RBAC/webhook/writeback/UI/Captain seams và không copy exact API/schema. -- Hai spec con phải tham chiếu master spec và một Contract Pack version cụ thể. - -### Delivery graph - -```text -Master spec approved - ↓ -Contract Pack baseline - ↓ -┌────────────────────────┬──────────────────────────┐ -│ AI Platform provider │ DotB-Omni consumer │ -│ implementation │ implementation │ -└────────────────────────┴──────────────────────────┘ - ↓ -Bidirectional contract verification - ↓ -E2E sandbox + fault injection - ↓ -Allowlisted Customer Pilot + baseline collection -``` - -### Remaining technical decisions - -Các quyết định sau không làm product frontier mở lại, nhưng phải được ghi và kiểm chứng trước subsystem tương ứng hoặc Pilot deployment: - -- Production database và migration tooling. -- Worker/runtime scheduling topology và stuck-run lease/timeout implementation. -- Registered subscriber provisioning và authentication. -- Contract artifact distribution/version pinning. -- Per-environment provisioning, rotation/revocation và operational ownership cho service tokens đã được approve. -- Hosting, network exposure và deployment ownership trước Customer Pilot deployment. -- Exact model mapping, token budget và timeout cho từng AI effort profile. -- Metrics/alerting backend và operational ownership. - -### Readiness for ticketing - -Các điều kiện chuyển sang `/to-tickets` đã được đáp ứng ngày 2026-08-26: - -1. AI Platform, DotB-Omni và AIHub đã review các boundary liên quan. -2. Tài liệu discovery mâu thuẫn đã được cập nhật hoặc đánh dấu superseded/historical. -3. Spec có trạng thái `approved` và version `1.0.0`. -4. Contract Sprint được xác định là blocker đầu tiên cho các consumer/provider tickets phụ thuộc payload. - -Spec sẵn sàng làm đầu vào cho `/to-tickets`; việc tạo ticket chưa được thực hiện trong migration tài liệu này. diff --git a/old_repo/pyproject.toml b/old_repo/pyproject.toml deleted file mode 100644 index cf3de15..0000000 --- a/old_repo/pyproject.toml +++ /dev/null @@ -1,67 +0,0 @@ -[project] -name = "lead-scoring" -version = "0.1.0" -description = "Lead Scoring service for AI Platform" -readme = "README.md" -requires-python = ">=3.13" -dependencies = [ - "fastapi>=0.115.0", - "uvicorn[standard]>=0.30.0", - "pydantic>=2.8.0", - "pydantic-settings>=2.4.0", - "sqlalchemy[asyncio]>=2.0.30", - "asyncpg>=0.29.0", - "alembic>=1.13.0", - "httpx>=0.27.0", -] - -[project.scripts] -generate-openapi = "lead_scoring.cli.openapi:main" -generate-webhook-contract = "lead_scoring.cli.webhook_contract:main" -generate-polling-contract = "lead_scoring.cli.polling_contract:main" -generate-contract-pack = "lead_scoring.cli.contract_pack:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/lead_scoring"] - -[dependency-groups] -dev = [ - "pytest>=8.0.0", - "pytest-asyncio>=0.23.0", - "testcontainers[postgres]>=4.0.0", - "ruff>=0.5.0", - "pyright>=1.1.370", - "ty>=0.0.74", -] - -[tool.pytest.ini_options] -asyncio_mode = "auto" -testpaths = ["tests"] -pythonpath = ["src"] - -[tool.ruff] -line-length = 100 -target-version = "py313" -src = ["src", "tests"] - -[tool.ruff.lint] -select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "I", # isort - "B", # flake8-bugbear - "UP", # pyupgrade -] -ignore = [] - -[tool.pyright] -include = ["src", "tests"] -pythonVersion = "3.13" -typeCheckingMode = "standard" -venvPath = "." -venv = ".venv" diff --git a/old_repo/src/lead_scoring/__init__.py b/old_repo/src/lead_scoring/__init__.py deleted file mode 100644 index 1aa5f46..0000000 --- a/old_repo/src/lead_scoring/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Lead Scoring service package.""" - -from importlib.metadata import PackageNotFoundError, version - -try: - __version__ = version("lead-scoring") -except PackageNotFoundError: - __version__ = "0.0.0+unknown" - -__all__ = ["__version__"] diff --git a/old_repo/src/lead_scoring/aihub/__init__.py b/old_repo/src/lead_scoring/aihub/__init__.py deleted file mode 100644 index 1879141..0000000 --- a/old_repo/src/lead_scoring/aihub/__init__.py +++ /dev/null @@ -1,51 +0,0 @@ -from lead_scoring.aihub.client import ( - AIHubClient, - AIHubClientProtocol, - run_heavy_ai_pipeline, - run_light_ai_pipeline, - run_medium_ai_pipeline, -) -from lead_scoring.aihub.errors import ( - AIHubAuthError, - AIHubError, - AIHubInvalidOutputError, - AIHubNetworkError, - AIHubQuotaError, - AIHubServerError, - AIHubTimeoutError, - normalize_aihub_error, -) -from lead_scoring.aihub.schemas import ( - AiCritiqueResult, - AiScoringRequest, - AiScoringResult, - EvidenceExtractionRequest, - EvidenceExtractionResult, - HeavyAiResult, - LightAiResult, - MediumAiResult, -) - -__all__ = [ - "AIHubAuthError", - "AIHubClient", - "AIHubClientProtocol", - "AIHubError", - "AIHubInvalidOutputError", - "AIHubNetworkError", - "AIHubQuotaError", - "AIHubServerError", - "AIHubTimeoutError", - "AiCritiqueResult", - "AiScoringRequest", - "AiScoringResult", - "EvidenceExtractionRequest", - "EvidenceExtractionResult", - "HeavyAiResult", - "LightAiResult", - "MediumAiResult", - "normalize_aihub_error", - "run_heavy_ai_pipeline", - "run_light_ai_pipeline", - "run_medium_ai_pipeline", -] diff --git a/old_repo/src/lead_scoring/aihub/client.py b/old_repo/src/lead_scoring/aihub/client.py deleted file mode 100644 index 35aed1b..0000000 --- a/old_repo/src/lead_scoring/aihub/client.py +++ /dev/null @@ -1,843 +0,0 @@ -import asyncio -import json -import logging -import urllib.parse -from collections.abc import Sequence -from typing import Any, Protocol, TypeVar - -import httpx -from pydantic import BaseModel - -from lead_scoring.aihub.errors import AIHubInvalidOutputError, normalize_aihub_error -from lead_scoring.aihub.schemas import ( - AiCritiqueResult, - AiScoringResult, - EvidenceExtractionResult, - HeavyAiResult, - LightAiResult, - MediumAiResult, -) -from lead_scoring.observability import Observability -from lead_scoring.schemas.snapshot import CanonicalSnapshot - -logger = logging.getLogger(__name__) - -T = TypeVar("T", bound=BaseModel) - -DEFAULT_AI_MODEL = "openai/gpt-4o-mini" -CHAT_COMPLETIONS_PATH = "/v1/chat/completions" -MAX_RESPONSE_BYTES = 64 * 1024 # 64 KB maximum response size - -PROMPT_PASS1_SYSTEM = ( - "Bạn là trợ lý AI phân tích hội thoại để đánh giá tiềm năng học viên. " - "Hãy trích xuất bằng chứng có cấu trúc về nhu cầu khóa học, mức độ quan tâm, " - "thời điểm đăng ký và rào cản từ ngữ cảnh hội thoại và metrics hành vi. " - "Trả lời bằng tiếng Việt và không tự bịa dữ kiện." -) - -PROMPT_PASS2_SYSTEM = ( - "Bạn là chuyên gia chấm điểm Lead giáo dục từ 0 đến 100. " - "Dựa trên bằng chứng đã trích xuất, hãy đánh giá khả năng đăng ký, độ tin cậy, " - "lý do và các yếu tố tích cực hoặc tiêu cực. " - "Lưu ý: Không cộng lại điểm các customer rules hoặc metrics đã tính ở phần quy tắc riêng. " - "Trả lời bằng tiếng Việt và không tự bịa dữ kiện." -) - -PROMPT_PASS_LIGHT_SYSTEM = ( - "You are an AI assistant analyzing customer interactions and scoring sales leads on a " - "0-100 scale. Evaluate the lead snapshot and determine qualification score, confidence, " - "reasoning, and contributing factors. Note: Do not recalculate or duplicate scores " - "for customer rules or metrics evaluated separately." -) - -PROMPT_PASS_HEAVY_CRITIQUE_SYSTEM = ( - "You are an AI critique and risk analyst evaluating sales lead evidence against the source " - "snapshot. Perform a critical review and cross-check to detect omissions, inconsistencies, " - "intent risks, and verified strengths." -) - -PROMPT_PASS_HEAVY_SCORING_SYSTEM = ( - "You are an AI assistant scoring sales leads on a 0-100 scale based on extracted evidence " - "and critique analysis. Synthesize the findings into a final qualification score, " - "confidence, reasoning, and contributing factors. Note: Do not recalculate or duplicate scores " - "for customer rules or metrics evaluated separately." -) - - -MAX_FIELD_VALUE_CHARS = 500 -MAX_DICT_ENTRIES = 50 -MAX_NESTED_DICT_CHARS = 4096 - - -def _truncate_scalar(val: Any, max_chars: int = MAX_FIELD_VALUE_CHARS) -> Any: - if isinstance(val, str): - if len(val) > max_chars: - return val[:max_chars] + "... [truncated]" - return val - elif isinstance(val, (int, float, bool)) or val is None: - return val - elif isinstance(val, list): - return [_truncate_scalar(item, max_chars) for item in val[:MAX_DICT_ENTRIES]] - elif isinstance(val, dict): - reduced, _ = _reduce_bounded_dict(val) - return reduced - return str(val)[:max_chars] - - -def _reduce_bounded_dict( - d: dict[str, Any], - max_entries: int = MAX_DICT_ENTRIES, - max_total_chars: int = MAX_NESTED_DICT_CHARS, -) -> tuple[dict[str, Any], bool]: - if not isinstance(d, dict): - return {}, False - - sorted_items = sorted(d.items(), key=lambda x: str(x[0])) - reduced: dict[str, Any] = {} - current_chars = 0 - was_truncated = False - - if len(sorted_items) > max_entries: - was_truncated = True - - for k, v in sorted_items[:max_entries]: - key_str = str(k)[:100] - val_bounded = _truncate_scalar(v) - val_str = json.dumps(val_bounded, ensure_ascii=False) - if current_chars + len(key_str) + len(val_str) > max_total_chars: - was_truncated = True - break - reduced[key_str] = val_bounded - current_chars += len(key_str) + len(val_str) - - if was_truncated: - reduced["_truncated_lineage"] = ( - "Nested dictionary exceeded token cap; remaining fields omitted" - ) - - return reduced, was_truncated - - -def reduce_snapshot_for_prompt( - snapshot: CanonicalSnapshot, - *, - customer_rules: Sequence[Any] | None = None, - behavioral_metrics: dict[str, Any] | None = None, -) -> dict[str, Any]: - reduction_occurred = False - lead_profile_dict = snapshot.lead_profile.model_dump(mode="json") - if "custom_fields" in lead_profile_dict and isinstance( - lead_profile_dict["custom_fields"], dict - ): - lead_profile_dict["custom_fields"], cf_truncated = _reduce_bounded_dict( - lead_profile_dict["custom_fields"] - ) - if cf_truncated: - reduction_occurred = True - - identity_dict = None - if snapshot.identity_context: - identity_dict = snapshot.identity_context.model_dump(mode="json") - if "details" in identity_dict and isinstance(identity_dict["details"], dict): - identity_dict["details"], dt_truncated = _reduce_bounded_dict(identity_dict["details"]) - if dt_truncated: - reduction_occurred = True - - trigger_dict = ( - snapshot.trigger_metadata.model_dump(mode="json") if snapshot.trigger_metadata else None - ) - - truncation_lineage: dict[str, Any] | None = None - if snapshot.truncation: - truncation_lineage = snapshot.truncation.model_dump(mode="json") - elif reduction_occurred: - truncation_lineage = { - "truncated": True, - "dropped_conversations_count": 0, - "dropped_messages_count": 0, - "reason": "AI prompt evidence reduced to fit token budget", - } - - if truncation_lineage and reduction_occurred: - truncation_lineage["truncated"] = True - if not truncation_lineage.get("reason"): - truncation_lineage["reason"] = "AI prompt evidence reduced to fit token budget" - - res: dict[str, Any] = { - "lead_profile": lead_profile_dict, - "conversations": [c.model_dump(mode="json") for c in snapshot.conversations], - "identity_context": identity_dict, - "trigger_metadata": trigger_dict, - "truncation_lineage": truncation_lineage, - } - if customer_rules: - res["customer_rules"] = [ - r.model_dump(mode="json") if hasattr(r, "model_dump") else r for r in customer_rules - ] - if behavioral_metrics: - res["behavioral_metrics"] = behavioral_metrics - return res - - -def to_strict_json_schema(model_cls: type[BaseModel]) -> dict[str, Any]: - schema = model_cls.model_json_schema() - return _apply_strict_schema_recursively(schema) - - -def _apply_strict_schema_recursively(node: Any) -> Any: - if isinstance(node, dict): - result = {k: _apply_strict_schema_recursively(v) for k, v in node.items() if k != "default"} - if result.get("type") == "object" or "properties" in result: - if "properties" in result and isinstance(result["properties"], dict): - result["required"] = list(result["properties"].keys()) - result["additionalProperties"] = False - return result - elif isinstance(node, list): - return [_apply_strict_schema_recursively(item) for item in node] - return node - - -class AIHubClientProtocol(Protocol): - async def extract_evidence( - self, - snapshot: CanonicalSnapshot, - *, - vkey: str, - run_id: str, - model: str = DEFAULT_AI_MODEL, - timeout_seconds: float = 10.0, - system_prompt: str | None = None, - app: str | None = None, - feature: str | None = None, - customer_rules: Sequence[Any] | None = None, - behavioral_metrics: dict[str, Any] | None = None, - ) -> EvidenceExtractionResult: ... - - async def score_lead( - self, - evidence: EvidenceExtractionResult, - *, - vkey: str, - run_id: str, - model: str = DEFAULT_AI_MODEL, - timeout_seconds: float = 10.0, - system_prompt: str | None = None, - app: str | None = None, - feature: str | None = None, - customer_rules: Sequence[Any] | None = None, - behavioral_metrics: dict[str, Any] | None = None, - ) -> AiScoringResult: ... - - async def analyze_and_score_lead( - self, - snapshot: CanonicalSnapshot, - *, - vkey: str, - run_id: str, - model: str = DEFAULT_AI_MODEL, - timeout_seconds: float = 10.0, - app: str | None = None, - feature: str | None = None, - customer_rules: Sequence[Any] | None = None, - behavioral_metrics: dict[str, Any] | None = None, - ) -> AiScoringResult: ... - - async def critique_evidence( - self, - snapshot: CanonicalSnapshot, - evidence: EvidenceExtractionResult, - *, - vkey: str, - run_id: str, - model: str = DEFAULT_AI_MODEL, - timeout_seconds: float = 10.0, - app: str | None = None, - feature: str | None = None, - customer_rules: Sequence[Any] | None = None, - behavioral_metrics: dict[str, Any] | None = None, - ) -> AiCritiqueResult: ... - - async def score_lead_heavy( - self, - evidence: EvidenceExtractionResult, - critique: AiCritiqueResult, - *, - vkey: str, - run_id: str, - model: str = DEFAULT_AI_MODEL, - timeout_seconds: float = 10.0, - app: str | None = None, - feature: str | None = None, - customer_rules: Sequence[Any] | None = None, - behavioral_metrics: dict[str, Any] | None = None, - ) -> AiScoringResult: ... - - -class AIHubClient: - def __init__( - self, - base_url: str, - *, - http_client: httpx.AsyncClient | None = None, - observability: Observability | None = None, - ) -> None: - parsed = urllib.parse.urlsplit(base_url) - if not parsed.scheme or not parsed.netloc: - raise ValueError(f"Invalid aihub base_url '{base_url}': missing scheme or host") - if parsed.username or parsed.password: - raise ValueError("aihub base_url must not contain user credentials") - if parsed.query or parsed.fragment: - raise ValueError("aihub base_url must not contain query parameters or fragments") - - self._base_url = base_url.rstrip("/") - self._http_client = http_client - self._observability = observability - - def _get_headers( - self, - vkey: str, - run_id: str, - app: str | None = None, - feature: str | None = None, - ) -> dict[str, str]: - headers = { - "Authorization": f"Bearer {vkey}", - "x-request-id": run_id, - "Content-Type": "application/json", - } - if app: - headers["x-bf-dim-app"] = app - if feature: - headers["x-bf-dim-feature"] = feature - return headers - - async def _stream_and_parse( - self, - client: httpx.AsyncClient, - url: str, - headers: dict[str, str], - payload: dict[str, Any], - timeout_seconds: float, - ) -> tuple[dict[str, Any], dict[str, Any] | None]: - async with client.stream( - "POST", - url, - headers=headers, - json=payload, - timeout=timeout_seconds, - ) as response: - response.raise_for_status() - - content_length = response.headers.get("content-length") - if ( - content_length - and content_length.isdigit() - and int(content_length) > MAX_RESPONSE_BYTES - ): - raise AIHubInvalidOutputError( - f"AIHub response Content-Length ({content_length} bytes) " - f"exceeds limit ({MAX_RESPONSE_BYTES} bytes)" - ) - - chunks: list[bytes] = [] - total_bytes = 0 - async for chunk in response.aiter_bytes(): - total_bytes += len(chunk) - if total_bytes > MAX_RESPONSE_BYTES: - raise AIHubInvalidOutputError( - "AIHub streaming response exceeded maximum limit of " - f"{MAX_RESPONSE_BYTES} bytes" - ) - chunks.append(chunk) - - raw_bytes = b"".join(chunks) - try: - raw_data = json.loads(raw_bytes.decode("utf-8")) - except Exception as exc: - raise AIHubInvalidOutputError("Unparseable JSON in AIHub response body") from exc - - parsed = self._parse_chat_response(raw_data) - usage = raw_data.get("usage") - return parsed, usage if isinstance(usage, dict) else None - - async def _post_chat_completions( - self, - headers: dict[str, str], - payload: dict[str, Any], - timeout_seconds: float, - ) -> tuple[dict[str, Any], dict[str, Any] | None]: - url = f"{self._base_url}{CHAT_COMPLETIONS_PATH}" - - async with asyncio.timeout(timeout_seconds): - if self._http_client is not None: - return await self._stream_and_parse( - self._http_client, url, headers, payload, timeout_seconds - ) - else: - async with httpx.AsyncClient(timeout=timeout_seconds, trust_env=False) as client: - return await self._stream_and_parse( - client, url, headers, payload, timeout_seconds - ) - - def _parse_chat_response(self, raw_data: Any) -> dict[str, Any]: - if not isinstance(raw_data, dict): - raise AIHubInvalidOutputError("Expected JSON object response from AIHub") - - choices = raw_data.get("choices") - if not isinstance(choices, list) or len(choices) == 0: - raise AIHubInvalidOutputError("AIHub response contains empty choices") - - first_choice = choices[0] - if not isinstance(first_choice, dict): - raise AIHubInvalidOutputError("Invalid choice format in AIHub response") - - message = first_choice.get("message") - if not isinstance(message, dict): - raise AIHubInvalidOutputError("Missing message in AIHub choice") - - if message.get("refusal"): - raise AIHubInvalidOutputError(f"Model refused request: {message['refusal']}") - - content = message.get("content") - if content is None or not isinstance(content, str) or not content.strip(): - raise AIHubInvalidOutputError("AIHub message content is empty or null") - - try: - parsed = json.loads(content) - if not isinstance(parsed, dict): - raise AIHubInvalidOutputError("Extracted content is not a JSON object") - return parsed - except json.JSONDecodeError as exc: - raise AIHubInvalidOutputError("Unparseable JSON in AIHub message content") from exc - - async def _invoke_structured_completion( - self, - *, - vkey: str, - run_id: str, - model: str, - system_prompt: str, - user_content: str, - schema_name: str, - response_model: type[T], - timeout_seconds: float, - stage_name: str, - app: str | None = None, - feature: str | None = None, - ) -> T: - headers = self._get_headers( - vkey=vkey, - run_id=run_id, - app=app, - feature=feature, - ) - payload = { - "model": model, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_content}, - ], - "stream": False, - "response_format": { - "type": "json_schema", - "json_schema": { - "name": schema_name, - "strict": True, - "schema": to_strict_json_schema(response_model), - }, - }, - } - - try: - extracted_dict, usage = await self._post_chat_completions( - headers=headers, - payload=payload, - timeout_seconds=timeout_seconds, - ) - if self._observability is not None and usage is not None: - prompt_tokens = usage.get("prompt_tokens", 0) - completion_tokens = usage.get("completion_tokens", 0) - total_tokens = usage.get("total_tokens") - if ( - not isinstance(prompt_tokens, int) - or isinstance(prompt_tokens, bool) - or prompt_tokens < 0 - ): - prompt_tokens = 0 - if ( - not isinstance(completion_tokens, int) - or isinstance(completion_tokens, bool) - or completion_tokens < 0 - ): - completion_tokens = 0 - if ( - not isinstance(total_tokens, int) - or isinstance(total_tokens, bool) - or total_tokens < 0 - ): - total_tokens = prompt_tokens + completion_tokens - self._observability.increment( - "aihub_tokens_total", - amount=total_tokens, - run_id=run_id, - fields={ - "prompt_tokens": prompt_tokens, - "completion_tokens": completion_tokens, - "total_tokens": total_tokens, - }, - ) - return response_model.model_validate(extracted_dict) - except Exception as exc: - normalized = normalize_aihub_error(exc) - if self._observability is not None: - self._observability.record_exception( - "invocation", - exc, - run_id=run_id, - secrets=(vkey,), - fields={"stage": stage_name, "error_code": normalized.error_code}, - ) - logger.warning( - "%s failed: %s (run_id=%s)", - stage_name, - normalized.error_code, - run_id, - ) - raise normalized from None - - async def extract_evidence( - self, - snapshot: CanonicalSnapshot, - *, - vkey: str, - run_id: str, - model: str = DEFAULT_AI_MODEL, - timeout_seconds: float = 10.0, - system_prompt: str | None = None, - app: str | None = None, - feature: str | None = None, - customer_rules: Sequence[Any] | None = None, - behavioral_metrics: dict[str, Any] | None = None, - ) -> EvidenceExtractionResult: - user_content = json.dumps( - reduce_snapshot_for_prompt( - snapshot, - customer_rules=customer_rules, - behavioral_metrics=behavioral_metrics, - ), - ensure_ascii=False, - ) - return await self._invoke_structured_completion( - vkey=vkey, - run_id=run_id, - model=model, - system_prompt=system_prompt if system_prompt is not None else PROMPT_PASS1_SYSTEM, - user_content=user_content, - schema_name="evidence_extraction", - response_model=EvidenceExtractionResult, - timeout_seconds=timeout_seconds, - stage_name="Pass 1 evidence extraction", - app=app, - feature=feature, - ) - - async def score_lead( - self, - evidence: EvidenceExtractionResult, - *, - vkey: str, - run_id: str, - model: str = DEFAULT_AI_MODEL, - timeout_seconds: float = 10.0, - system_prompt: str | None = None, - app: str | None = None, - feature: str | None = None, - customer_rules: Sequence[Any] | None = None, - behavioral_metrics: dict[str, Any] | None = None, - ) -> AiScoringResult: - payload_dict: dict[str, Any] = { - "evidence": evidence.model_dump(mode="json"), - } - if customer_rules: - payload_dict["customer_rules"] = [ - r.model_dump(mode="json") if hasattr(r, "model_dump") else r for r in customer_rules - ] - if behavioral_metrics: - payload_dict["behavioral_metrics"] = behavioral_metrics - user_content = json.dumps(payload_dict, ensure_ascii=False) - return await self._invoke_structured_completion( - vkey=vkey, - run_id=run_id, - model=model, - system_prompt=system_prompt if system_prompt is not None else PROMPT_PASS2_SYSTEM, - user_content=user_content, - schema_name="lead_scoring", - response_model=AiScoringResult, - timeout_seconds=timeout_seconds, - stage_name="Pass 2 lead scoring", - app=app, - feature=feature, - ) - - async def analyze_and_score_lead( - self, - snapshot: CanonicalSnapshot, - *, - vkey: str, - run_id: str, - model: str = DEFAULT_AI_MODEL, - timeout_seconds: float = 10.0, - app: str | None = None, - feature: str | None = None, - customer_rules: Sequence[Any] | None = None, - behavioral_metrics: dict[str, Any] | None = None, - ) -> AiScoringResult: - user_content = json.dumps( - reduce_snapshot_for_prompt( - snapshot, - customer_rules=customer_rules, - behavioral_metrics=behavioral_metrics, - ), - ensure_ascii=False, - ) - return await self._invoke_structured_completion( - vkey=vkey, - run_id=run_id, - model=model, - system_prompt=PROMPT_PASS_LIGHT_SYSTEM, - user_content=user_content, - schema_name="light_lead_scoring", - response_model=AiScoringResult, - timeout_seconds=timeout_seconds, - stage_name="Light AI analysis", - app=app, - feature=feature, - ) - - async def critique_evidence( - self, - snapshot: CanonicalSnapshot, - evidence: EvidenceExtractionResult, - *, - vkey: str, - run_id: str, - model: str = DEFAULT_AI_MODEL, - timeout_seconds: float = 10.0, - app: str | None = None, - feature: str | None = None, - customer_rules: Sequence[Any] | None = None, - behavioral_metrics: dict[str, Any] | None = None, - ) -> AiCritiqueResult: - user_content = json.dumps( - { - "snapshot": reduce_snapshot_for_prompt( - snapshot, - customer_rules=customer_rules, - behavioral_metrics=behavioral_metrics, - ), - "evidence": evidence.model_dump(mode="json"), - }, - ensure_ascii=False, - ) - return await self._invoke_structured_completion( - vkey=vkey, - run_id=run_id, - model=model, - system_prompt=PROMPT_PASS_HEAVY_CRITIQUE_SYSTEM, - user_content=user_content, - schema_name="evidence_critique", - response_model=AiCritiqueResult, - timeout_seconds=timeout_seconds, - stage_name="Pass 2 evidence critique", - app=app, - feature=feature, - ) - - async def score_lead_heavy( - self, - evidence: EvidenceExtractionResult, - critique: AiCritiqueResult, - *, - vkey: str, - run_id: str, - model: str = DEFAULT_AI_MODEL, - timeout_seconds: float = 10.0, - app: str | None = None, - feature: str | None = None, - customer_rules: Sequence[Any] | None = None, - behavioral_metrics: dict[str, Any] | None = None, - ) -> AiScoringResult: - payload_dict: dict[str, Any] = { - "evidence": evidence.model_dump(mode="json"), - "critique": critique.model_dump(mode="json"), - } - if customer_rules: - payload_dict["customer_rules"] = [ - r.model_dump(mode="json") if hasattr(r, "model_dump") else r for r in customer_rules - ] - if behavioral_metrics: - payload_dict["behavioral_metrics"] = behavioral_metrics - user_content = json.dumps(payload_dict, ensure_ascii=False) - return await self._invoke_structured_completion( - vkey=vkey, - run_id=run_id, - model=model, - system_prompt=PROMPT_PASS_HEAVY_SCORING_SYSTEM, - user_content=user_content, - schema_name="heavy_lead_scoring", - response_model=AiScoringResult, - timeout_seconds=timeout_seconds, - stage_name="Pass 3 heavy lead scoring", - app=app, - feature=feature, - ) - - -async def run_light_ai_pipeline( - client: AIHubClientProtocol, - snapshot: CanonicalSnapshot, - *, - vkey: str, - run_id: str, - model: str = DEFAULT_AI_MODEL, - timeout_seconds: float = 10.0, - app: str | None = None, - feature: str | None = None, - customer_rules: Sequence[Any] | None = None, - behavioral_metrics: dict[str, Any] | None = None, -) -> LightAiResult: - scoring = await client.analyze_and_score_lead( - snapshot, - vkey=vkey, - run_id=run_id, - model=model, - timeout_seconds=timeout_seconds, - app=app, - feature=feature, - customer_rules=customer_rules, - behavioral_metrics=behavioral_metrics, - ) - return LightAiResult( - ai_score=scoring.score, - confidence=scoring.confidence, - scoring=scoring, - metadata={ - "model": model, - "pass_count": 1, - "effort_profile": "light", - }, - ) - - -async def run_medium_ai_pipeline( - client: AIHubClientProtocol, - snapshot: CanonicalSnapshot, - *, - vkey: str, - run_id: str, - model: str = DEFAULT_AI_MODEL, - timeout_seconds: float = 10.0, - extract_prompt: str | None = None, - score_prompt: str | None = None, - app: str | None = None, - feature: str | None = None, - customer_rules: Sequence[Any] | None = None, - behavioral_metrics: dict[str, Any] | None = None, -) -> MediumAiResult: - evidence = await client.extract_evidence( - snapshot, - vkey=vkey, - run_id=run_id, - model=model, - timeout_seconds=timeout_seconds, - system_prompt=extract_prompt, - app=app, - feature=feature, - customer_rules=customer_rules, - behavioral_metrics=behavioral_metrics, - ) - scoring = await client.score_lead( - evidence, - vkey=vkey, - run_id=run_id, - model=model, - timeout_seconds=timeout_seconds, - system_prompt=score_prompt, - app=app, - feature=feature, - customer_rules=customer_rules, - behavioral_metrics=behavioral_metrics, - ) - return MediumAiResult( - ai_score=scoring.score, - confidence=scoring.confidence, - evidence=evidence, - scoring=scoring, - metadata={ - "model": model, - "pass_count": 2, - "effort_profile": "medium", - }, - ) - - -async def run_heavy_ai_pipeline( - client: AIHubClientProtocol, - snapshot: CanonicalSnapshot, - *, - vkey: str, - run_id: str, - model: str = DEFAULT_AI_MODEL, - timeout_seconds: float = 10.0, - app: str | None = None, - feature: str | None = None, - customer_rules: Sequence[Any] | None = None, - behavioral_metrics: dict[str, Any] | None = None, -) -> HeavyAiResult: - evidence = await client.extract_evidence( - snapshot, - vkey=vkey, - run_id=run_id, - model=model, - timeout_seconds=timeout_seconds, - app=app, - feature=feature, - customer_rules=customer_rules, - behavioral_metrics=behavioral_metrics, - ) - critique = await client.critique_evidence( - snapshot, - evidence, - vkey=vkey, - run_id=run_id, - model=model, - timeout_seconds=timeout_seconds, - app=app, - feature=feature, - customer_rules=customer_rules, - behavioral_metrics=behavioral_metrics, - ) - scoring = await client.score_lead_heavy( - evidence, - critique, - vkey=vkey, - run_id=run_id, - model=model, - timeout_seconds=timeout_seconds, - app=app, - feature=feature, - customer_rules=customer_rules, - behavioral_metrics=behavioral_metrics, - ) - return HeavyAiResult( - ai_score=scoring.score, - confidence=scoring.confidence, - evidence=evidence, - critique=critique, - scoring=scoring, - metadata={ - "model": model, - "pass_count": 3, - "effort_profile": "heavy", - }, - ) diff --git a/old_repo/src/lead_scoring/aihub/errors.py b/old_repo/src/lead_scoring/aihub/errors.py deleted file mode 100644 index ef5fb9c..0000000 --- a/old_repo/src/lead_scoring/aihub/errors.py +++ /dev/null @@ -1,180 +0,0 @@ -import json - -import httpx -from pydantic import ValidationError - - -class AIHubError(Exception): - def __init__( - self, - message: str, - *, - error_code: str = "aihub_error", - status_code: int | None = None, - retryable: bool = False, - fallback_allowed: bool = True, - detail: str = "", - ) -> None: - super().__init__(message) - self.error_code = error_code - self.status_code = status_code - self.retryable = retryable - self.fallback_allowed = fallback_allowed - self.detail = detail - - def __str__(self) -> str: - if self.status_code: - return f"[{self.error_code} ({self.status_code})] {super().__str__()}" - return f"[{self.error_code}] {super().__str__()}" - - def __repr__(self) -> str: - return ( - f"{self.__class__.__name__}(error_code={self.error_code!r}, " - f"status_code={self.status_code!r}, retryable={self.retryable!r}, " - f"fallback_allowed={self.fallback_allowed!r})" - ) - - -class AIHubAuthError(AIHubError): - def __init__( - self, - message: str = "AIHub authentication failed", - *, - status_code: int | None = 401, - detail: str = "Authentication failed with AIHub", - ) -> None: - super().__init__( - message, - error_code="aihub_auth_failed", - status_code=status_code, - retryable=False, - fallback_allowed=True, - detail=detail, - ) - - -class AIHubQuotaError(AIHubError): - def __init__( - self, - message: str = "AIHub quota or rate limit exceeded", - *, - status_code: int | None = 429, - detail: str = "Quota or rate limit exceeded on AIHub", - ) -> None: - super().__init__( - message, - error_code="aihub_quota_exceeded", - status_code=status_code, - retryable=False, - fallback_allowed=True, - detail=detail, - ) - - -class AIHubTimeoutError(AIHubError): - def __init__( - self, - message: str = "AIHub request timed out", - *, - detail: str = "Request to AIHub timed out", - ) -> None: - super().__init__( - message, - error_code="aihub_timeout", - status_code=None, - retryable=False, - fallback_allowed=True, - detail=detail, - ) - - -class AIHubServerError(AIHubError): - def __init__( - self, - message: str = "AIHub internal server error", - *, - status_code: int | None = 500, - detail: str = "AIHub returned server error", - ) -> None: - super().__init__( - message, - error_code="aihub_server_error", - status_code=status_code, - retryable=False, - fallback_allowed=True, - detail=detail, - ) - - -class AIHubInvalidOutputError(AIHubError): - def __init__( - self, - message: str = "AIHub returned invalid or unparseable output", - *, - detail: str = "Output does not conform to required schema", - ) -> None: - super().__init__( - message, - error_code="aihub_invalid_output", - status_code=None, - retryable=False, - fallback_allowed=True, - detail=detail, - ) - - -class AIHubNetworkError(AIHubError): - def __init__( - self, - message: str = "AIHub network connection failure", - *, - detail: str = "Failed to connect to AIHub", - ) -> None: - super().__init__( - message, - error_code="aihub_network_error", - status_code=None, - retryable=False, - fallback_allowed=True, - detail=detail, - ) - - -def normalize_aihub_error(exc: Exception) -> AIHubError: - if isinstance(exc, AIHubError): - return exc - - if isinstance(exc, httpx.HTTPStatusError): - status = exc.response.status_code - if status in {401, 403}: - return AIHubAuthError(status_code=status) - elif status == 429: - return AIHubQuotaError(status_code=status) - elif status >= 500: - return AIHubServerError(status_code=status) - else: - return AIHubError( - "AIHub client error", - error_code="aihub_client_error", - status_code=status, - retryable=False, - fallback_allowed=True, - detail=f"AIHub returned HTTP {status}", - ) - - if isinstance(exc, (httpx.TimeoutException, TimeoutError)): - return AIHubTimeoutError() - - if isinstance(exc, httpx.RequestError): - return AIHubNetworkError() - - if isinstance(exc, (json.JSONDecodeError, ValidationError)): - return AIHubInvalidOutputError() - - return AIHubError( - "Unexpected AIHub error", - error_code="aihub_unknown_error", - retryable=False, - fallback_allowed=True, - detail="Unexpected error occurred during AIHub invocation", - ) diff --git a/old_repo/src/lead_scoring/aihub/schemas.py b/old_repo/src/lead_scoring/aihub/schemas.py deleted file mode 100644 index d337f9d..0000000 --- a/old_repo/src/lead_scoring/aihub/schemas.py +++ /dev/null @@ -1,124 +0,0 @@ -from typing import Annotated, Any - -from pydantic import BaseModel, ConfigDict, Field - -from lead_scoring.schemas.snapshot import CanonicalSnapshot - - -class EvidenceExtractionRequest(BaseModel): - model_config = ConfigDict(extra="ignore") - - snapshot: CanonicalSnapshot - model: str = Field(default="openai/gpt-4o-mini", min_length=1, max_length=64) - - -class EvidenceExtractionResult(BaseModel): - model_config = ConfigDict(extra="forbid", strict=True) - - summary: str = Field( - ..., min_length=1, max_length=500, description="Structured synthesis of lead interactions" - ) - intent: str | None = Field( - default=None, max_length=64, description="Inferred customer intent level" - ) - key_signals: list[Annotated[str, Field(min_length=1, max_length=100)]] = Field( - default_factory=list, - max_length=10, - description="Key extracted business signals (max 10 items, max 100 chars each)", - ) - budget_signal: str | None = Field( - default=None, max_length=100, description="Budget signal if discussed" - ) - authority_signal: str | None = Field( - default=None, max_length=100, description="Authority/decision maker signal" - ) - need_signal: str | None = Field( - default=None, max_length=100, description="Customer need/pain point" - ) - timeline_signal: str | None = Field( - default=None, max_length=100, description="Purchasing/enrollment timeline" - ) - - -class AiScoringRequest(BaseModel): - model_config = ConfigDict(extra="ignore") - - snapshot: CanonicalSnapshot - evidence: EvidenceExtractionResult - model: str = Field(default="openai/gpt-4o-mini", min_length=1, max_length=64) - - -class AiScoringResult(BaseModel): - model_config = ConfigDict(extra="forbid", strict=True) - - score: int = Field(..., ge=0, le=100, description="AI-evaluated lead score between 0 and 100") - confidence: float = Field( - ..., ge=0.0, le=1.0, description="Confidence in scoring decision between 0.0 and 1.0" - ) - reasoning: str = Field( - ..., min_length=1, max_length=500, description="Explanation of the scoring decision" - ) - positive_factors: list[Annotated[str, Field(min_length=1, max_length=100)]] = Field( - default_factory=list, - max_length=10, - description="Positive contributing factors (max 10 items, max 100 chars each)", - ) - negative_factors: list[Annotated[str, Field(min_length=1, max_length=100)]] = Field( - default_factory=list, - max_length=10, - description="Negative or risk factors (max 10 items, max 100 chars each)", - ) - - -class AiCritiqueResult(BaseModel): - model_config = ConfigDict(extra="forbid", strict=True) - - critique_summary: str = Field( - ..., - min_length=1, - max_length=500, - description="Critical evaluation and risk assessment of the lead evidence", - ) - risks: list[Annotated[str, Field(min_length=1, max_length=100)]] = Field( - default_factory=list, - max_length=10, - description="Identified risks or inconsistencies (max 10 items, max 100 chars each)", - ) - strengths: list[Annotated[str, Field(min_length=1, max_length=100)]] = Field( - default_factory=list, - max_length=10, - description="Validated strengths or high-value signals (max 10 items, max 100 chars each)", - ) - cross_check_notes: str | None = Field( - default=None, max_length=500, description="Cross-check observations across signals" - ) - - -class LightAiResult(BaseModel): - model_config = ConfigDict(extra="ignore") - - ai_score: int = Field(..., ge=0, le=100) - confidence: float = Field(..., ge=0.0, le=1.0) - scoring: AiScoringResult - metadata: dict[str, Any] = Field(default_factory=dict) - - -class MediumAiResult(BaseModel): - model_config = ConfigDict(extra="ignore") - - ai_score: int = Field(..., ge=0, le=100) - confidence: float = Field(..., ge=0.0, le=1.0) - evidence: EvidenceExtractionResult - scoring: AiScoringResult - metadata: dict[str, Any] = Field(default_factory=dict) - - -class HeavyAiResult(BaseModel): - model_config = ConfigDict(extra="ignore") - - ai_score: int = Field(..., ge=0, le=100) - confidence: float = Field(..., ge=0.0, le=1.0) - evidence: EvidenceExtractionResult - critique: AiCritiqueResult - scoring: AiScoringResult - metadata: dict[str, Any] = Field(default_factory=dict) diff --git a/old_repo/src/lead_scoring/api/__init__.py b/old_repo/src/lead_scoring/api/__init__.py deleted file mode 100644 index 5bd132a..0000000 --- a/old_repo/src/lead_scoring/api/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""API package for Lead Scoring service.""" diff --git a/old_repo/src/lead_scoring/api/deps.py b/old_repo/src/lead_scoring/api/deps.py deleted file mode 100644 index 3c0e84b..0000000 --- a/old_repo/src/lead_scoring/api/deps.py +++ /dev/null @@ -1,24 +0,0 @@ -from collections.abc import AsyncIterator -from typing import Annotated - -from fastapi import Depends, HTTPException, Request, status -from sqlalchemy.ext.asyncio import AsyncSession - -from lead_scoring.auth import authenticate_service_caller, get_settings -from lead_scoring.config import ServiceCallerConfig, Settings - - -async def get_db_session(request: Request) -> AsyncIterator[AsyncSession]: - session_factory = getattr(request.app.state, "session_factory", None) - if session_factory is None: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Database session factory not configured", - ) - async with session_factory() as session: - yield session - - -DBSessionDep = Annotated[AsyncSession, Depends(get_db_session)] -SettingsDep = Annotated[Settings, Depends(get_settings)] -ServiceCallerDep = Annotated[ServiceCallerConfig, Depends(authenticate_service_caller)] diff --git a/old_repo/src/lead_scoring/api/v1/__init__.py b/old_repo/src/lead_scoring/api/v1/__init__.py deleted file mode 100644 index 73fd104..0000000 --- a/old_repo/src/lead_scoring/api/v1/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""API v1 package for Lead Scoring service.""" diff --git a/old_repo/src/lead_scoring/api/v1/scoring_runs.py b/old_repo/src/lead_scoring/api/v1/scoring_runs.py deleted file mode 100644 index e12b8de..0000000 --- a/old_repo/src/lead_scoring/api/v1/scoring_runs.py +++ /dev/null @@ -1,672 +0,0 @@ -import hashlib -import json -import uuid -from datetime import UTC, datetime, timedelta -from typing import Annotated, Any - -from fastapi import APIRouter, Header, HTTPException, Path, Query, Request, Response, status -from fastapi.responses import JSONResponse -from pydantic import TypeAdapter -from sqlalchemy import func, select -from sqlalchemy.dialects.postgresql import insert as pg_insert - -from lead_scoring.api.deps import DBSessionDep, ServiceCallerDep, SettingsDep -from lead_scoring.auth import verify_caller_scope, verify_tenant_scope -from lead_scoring.config import ServiceCallerConfig, Settings -from lead_scoring.db.models.scoring_run import ScoringRun -from lead_scoring.execution import ExecutionRuntime, recover_interrupted_runs -from lead_scoring.schemas.errors import ErrorResponse -from lead_scoring.schemas.scoring_run import ( - AiMetadataFailure, - AiMetadataSuccess, - CreateScoringRunRequest, - CreateScoringRunResponse, - ScoringRunResponse, - ScoringRunStatus, -) -from lead_scoring.schemas.snapshot import CanonicalSnapshot -from lead_scoring.scoring import ( - PROFILE_PASS_NAMES, - RuleBreakdownItem, - ScoringConfig, - ScoringMode, - calculate_min_worker_lease_seconds, - derive_outcome_fields, -) - -router = APIRouter(prefix="/v1/scoring-runs", tags=["Scoring Runs"]) -_AI_METADATA_ADAPTER = TypeAdapter(AiMetadataSuccess | AiMetadataFailure) - - -def validate_admission_config_and_lease( - scoring_config_version: str, - snapshot: CanonicalSnapshot, - settings: Settings, -) -> ScoringConfig: - scoring_config = settings.get_scoring_config(scoring_config_version) - if scoring_config is None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail={ - "error_code": "scoring_config_not_found", - "detail": "Requested scoring config version does not exist", - }, - ) - - min_lease = calculate_min_worker_lease_seconds(scoring_config) - if min_lease is not None and settings.worker_lease_seconds <= min_lease: - pass_name = PROFILE_PASS_NAMES.get(scoring_config.effort_profile, "AI") - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail={ - "error_code": "worker_lease_too_short", - "detail": ( - f"Worker lease ({settings.worker_lease_seconds}s) must exceed " - f"{pass_name} AI timeout plus margin ({min_lease}s)" - ), - }, - ) - - return scoring_config - - -def _contains_secret(data: object, secret: str) -> bool: - if not secret: - return False - if isinstance(data, str): - return secret in data - if isinstance(data, dict): - for key, val in data.items(): - if isinstance(key, str) and secret in key: - return True - if _contains_secret(val, secret): - return True - elif isinstance(data, (list, tuple, set)): - for item in data: - if _contains_secret(item, secret): - return True - return False - - -def _verify_internal_config_access( - *, - caller: ServiceCallerConfig, - settings: Settings, - tenant_id: str, - app: str, - feature: str, -) -> None: - if settings.environment == "production": - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={ - "error_code": "config_disabled_in_production", - "detail": "Scoring config management is disabled in production", - }, - ) - verify_caller_scope( - caller=caller, - app=app, - feature=feature, - tenant_id=tenant_id, - settings=settings, - ) - - -@router.get("/configs", response_model=list[ScoringConfig], summary="List internal scoring configs") -async def list_scoring_configs( - caller: ServiceCallerDep, - settings: SettingsDep, - x_tenant_id: Annotated[str, Header(alias="X-Tenant-Id", min_length=1, max_length=255)], - x_dotb_app: Annotated[str, Header(alias="X-DotB-App", min_length=1, max_length=64)], - x_dotb_feature: Annotated[str, Header(alias="X-DotB-Feature", min_length=1, max_length=64)], -) -> list[ScoringConfig]: - _verify_internal_config_access( - caller=caller, - settings=settings, - tenant_id=x_tenant_id, - app=x_dotb_app, - feature=x_dotb_feature, - ) - return settings.scoring_configs - - -@router.post( - "/configs", - status_code=status.HTTP_201_CREATED, - response_model=ScoringConfig, - summary="Register a process-local immutable internal scoring config", -) -async def create_scoring_config( - config: ScoringConfig, - caller: ServiceCallerDep, - settings: SettingsDep, - x_tenant_id: Annotated[str, Header(alias="X-Tenant-Id", min_length=1, max_length=255)], - x_dotb_app: Annotated[str, Header(alias="X-DotB-App", min_length=1, max_length=64)], - x_dotb_feature: Annotated[str, Header(alias="X-DotB-Feature", min_length=1, max_length=64)], -) -> ScoringConfig: - _verify_internal_config_access( - caller=caller, - settings=settings, - tenant_id=x_tenant_id, - app=x_dotb_app, - feature=x_dotb_feature, - ) - if settings.get_scoring_config(config.version) is not None: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail={ - "error_code": "scoring_config_version_exists", - "detail": "Scoring config version already exists", - }, - ) - settings.scoring_configs.append(config) - return config - - -def _to_scoring_run_response(run: ScoringRun) -> ScoringRunResponse: - ai_reasoning: str | None = None - breakdown: list[RuleBreakdownItem] | None = None - if run.execution_trace and isinstance(run.execution_trace, dict): - ai_eval = run.execution_trace.get("ai_evaluation") - if isinstance(ai_eval, dict): - ai_reasoning = ai_eval.get("reasoning") - raw_bd = run.execution_trace.get("rule_breakdown") - if isinstance(raw_bd, list): - breakdown = [RuleBreakdownItem.model_validate(item) for item in raw_bd] - - category, explanation, recommended_action = derive_outcome_fields( - status=run.status, - effective_score=run.effective_score, - final_score=run.final_score, - rule_score=run.rule_score, - ai_reasoning=ai_reasoning, - error_code=run.error_code, - ) - return ScoringRunResponse( - run_id=run.id, - tenant_id=run.tenant_id, - subject_ref=run.subject_ref, - scoring_config_version=run.scoring_config_version, - parent_run_id=run.parent_run_id, - status=ScoringRunStatus(run.status), - rule_score=run.rule_score, - ai_score=run.ai_score, - final_score=run.final_score, - effective_score=run.effective_score, - scoring_mode=(ScoringMode(run.scoring_mode) if run.scoring_mode is not None else None), - degraded=run.degraded, - ai_metadata=( - _AI_METADATA_ADAPTER.validate_python(run.ai_metadata) - if run.ai_metadata is not None - else None - ), - confidence=run.confidence, - confidence_threshold=run.confidence_threshold, - breakdown=breakdown, - effective_config=None, - error_code=run.error_code, - retryable=run.retryable, - version=run.version, - started_at=run.started_at, - completed_at=run.completed_at, - category=category, - explanation=explanation, - recommended_action=recommended_action, - created_at=run.created_at, - updated_at=run.updated_at, - ) - - -@router.get( - "", - response_model=list[ScoringRunResponse], - summary="List recent Scoring Runs for a tenant", - responses={ - status.HTTP_200_OK: { - "model": list[ScoringRunResponse], - "description": "Recent scoring runs scoped to the requested tenant", - "headers": { - "X-Total-Count": { - "description": "Authoritative total count of scoring runs for the tenant", - "schema": {"type": "integer"}, - } - }, - }, - status.HTTP_401_UNAUTHORIZED: { - "model": ErrorResponse, - "description": "Invalid or missing Bearer service token", - }, - status.HTTP_403_FORBIDDEN: { - "model": ErrorResponse, - "description": "Caller not authorized for tenant", - }, - }, -) -async def list_scoring_runs( - caller: ServiceCallerDep, - settings: SettingsDep, - db: DBSessionDep, - response: Response, - x_tenant_id: Annotated[ - str, - Header( - alias="X-Tenant-Id", - min_length=1, - max_length=255, - description="Tenant identifier for scoped tenant isolation query", - ), - ], - limit: Annotated[ - int, - Query(ge=1, le=100, description="Maximum number of recent runs to return"), - ] = 50, - offset: Annotated[ - int, - Query(ge=0, description="Number of recent runs to skip"), - ] = 0, -) -> list[ScoringRunResponse]: - verify_tenant_scope(caller=caller, tenant_id=x_tenant_id, settings=settings) - total_count_result = await db.execute( - select(func.count(ScoringRun.id)).where(ScoringRun.tenant_id == x_tenant_id) - ) - total_count = total_count_result.scalar_one() - response.headers["X-Total-Count"] = str(total_count) - - result = await db.execute( - select(ScoringRun) - .where(ScoringRun.tenant_id == x_tenant_id) - .order_by(ScoringRun.created_at.desc(), ScoringRun.id.desc()) - .offset(offset) - .limit(limit) - ) - return [_to_scoring_run_response(run) for run in result.scalars().all()] - - -@router.post( - "", - status_code=status.HTTP_202_ACCEPTED, - response_model=CreateScoringRunResponse, - responses={ - status.HTTP_202_ACCEPTED: { - "model": CreateScoringRunResponse, - "description": "Scoring run accepted and persisted in pending state", - }, - status.HTTP_401_UNAUTHORIZED: { - "model": ErrorResponse, - "description": "Invalid or missing Bearer service token", - }, - status.HTTP_403_FORBIDDEN: { - "model": ErrorResponse, - "description": "Caller not authorized for tenant, app, or feature", - }, - status.HTTP_409_CONFLICT: { - "model": ErrorResponse, - "description": "Idempotency key conflict with mismatched payload", - }, - status.HTTP_422_UNPROCESSABLE_CONTENT: { - "model": ErrorResponse, - "description": "Validation error or structural caps exceeded", - }, - status.HTTP_503_SERVICE_UNAVAILABLE: { - "model": ErrorResponse, - "description": "Scoring admission is disabled by the operational kill switch", - }, - }, - summary="Admit a new Lead Scoring Run", -) -async def create_scoring_run( - request: Request, - payload: CreateScoringRunRequest, - caller: ServiceCallerDep, - settings: SettingsDep, - db: DBSessionDep, - idempotency_key: Annotated[ - str, - Header( - alias="Idempotency-Key", - min_length=1, - max_length=255, - description="Tenant-scoped idempotency key", - ), - ], - x_dotb_app: Annotated[ - str, - Header( - alias="X-DotB-App", - min_length=1, - max_length=64, - description="Originating DotB application identifier", - ), - ], - x_dotb_feature: Annotated[ - str, - Header( - alias="X-DotB-Feature", - min_length=1, - max_length=64, - description="Originating DotB feature identifier", - ), - ], - x_tenant_vkey: Annotated[ - str | None, - Header( - alias="X-Tenant-VKey", - min_length=0, - max_length=1024, - description="Optional tenant AIHub credential passed in RAM for execution attempt", - ), - ] = None, -) -> CreateScoringRunResponse | JSONResponse: - # 1. Reject if raw V-Key appears anywhere inside the payload or caller headers - aggregate_input = [ - payload.model_dump(mode="python"), - idempotency_key, - x_dotb_app, - x_dotb_feature, - ] - if x_tenant_vkey and _contains_secret(aggregate_input, x_tenant_vkey): - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail={ - "error_code": "credential_in_payload", - "detail": "Payload must not contain raw tenant credential", - }, - ) - - # 2. Verify caller and tenant scope - verify_caller_scope( - caller=caller, - app=x_dotb_app, - feature=x_dotb_feature, - tenant_id=payload.tenant_id, - settings=settings, - ) - - scoring_config = validate_admission_config_and_lease( - scoring_config_version=payload.scoring_config_version, - snapshot=payload.snapshot, - settings=settings, - ) - - if payload.rule_overrides: - effective_config = scoring_config.model_copy( - update={"dynamic_rules": tuple(payload.rule_overrides)} - ) - else: - effective_config = scoring_config - - custom_fields = payload.snapshot.lead_profile.custom_fields or {} - has_course_interest = bool( - custom_fields.get("course_interest") - or custom_fields.get("course") - or custom_fields.get("program") - or custom_fields.get("target_course") - ) - has_customer_msg = any( - m.sender_type == "contact" for c in payload.snapshot.conversations for m in c.messages - ) - if not has_course_interest and not has_customer_msg: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail={ - "error_code": "insufficient_scoring_evidence", - "detail": "Admission requires at least course_interest or a customer message", - }, - ) - - # Canonical payload normalization (sorted keys, compact separators, json-mode datetime ISO) - canonical_payload_json = json.dumps( - payload.model_dump(mode="json"), - sort_keys=True, - separators=(",", ":"), - ensure_ascii=False, - ) - payload_hash = hashlib.sha256(canonical_payload_json.encode("utf-8")).hexdigest() - - run_id = str(uuid.uuid4()) - admitted_at = datetime.now(UTC) - confidence_threshold = effective_config.confidence_threshold - # Note: x_tenant_vkey is strictly NOT stored in the database. - insert_stmt = ( - pg_insert(ScoringRun) - .values( - id=run_id, - tenant_id=payload.tenant_id, - subject_ref=payload.subject_ref, - scoring_config_version=payload.scoring_config_version, - dotb_app=x_dotb_app, - dotb_feature=x_dotb_feature, - idempotency_key=idempotency_key, - payload_hash=payload_hash, - status=ScoringRunStatus.PENDING.value, - snapshot_data=payload.snapshot.model_dump(mode="json"), - effective_config=effective_config.model_dump(mode="json"), - confidence_threshold=confidence_threshold, - lease_expires_at=admitted_at + timedelta(seconds=settings.worker_lease_seconds), - created_at=admitted_at, - updated_at=admitted_at, - ) - .on_conflict_do_nothing( - index_elements=["tenant_id", "idempotency_key"], - ) - .returning(ScoringRun) - ) - - result = await db.execute(insert_stmt) - new_run = result.scalar_one_or_none() - - if settings.kill_switch_enabled and new_run is not None: - await db.rollback() - telemetry = getattr(request.app.state, "telemetry", None) - if telemetry is not None: - telemetry.increment("scoring_admission_blocked_total") - return JSONResponse( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - content=ErrorResponse( - error_code="scoring_admission_disabled", - detail="Scoring run initiation is temporarily disabled", - ).model_dump(), - ) - - if new_run is not None: - await db.commit() - runtime: ExecutionRuntime = request.app.state.execution_runtime - if not runtime.schedule(new_run.id, x_tenant_vkey): - await recover_interrupted_runs( - request.app.state.session_factory, - run_ids=[new_run.id], - observability=getattr(request.app.state, "telemetry", None), - ) - telemetry = getattr(request.app.state, "telemetry", None) - if telemetry is not None: - telemetry.increment("scoring_admission_accepted_total", run_id=new_run.id) - telemetry.record( - "scoring_admission.accepted", - run_id=new_run.id, - fields={"request_correlation_id": getattr(request.state, "correlation_id", None)}, - ) - return CreateScoringRunResponse( - run_id=new_run.id, - status=ScoringRunStatus(new_run.status), - status_url=f"/v1/scoring-runs/{new_run.id}", - tenant_id=new_run.tenant_id, - subject_ref=new_run.subject_ref, - scoring_config_version=new_run.scoring_config_version, - created_at=new_run.created_at, - ) - - # Conflict on (tenant_id, idempotency_key): query existing record in same transaction - stmt = select(ScoringRun).where( - ScoringRun.tenant_id == payload.tenant_id, - ScoringRun.idempotency_key == idempotency_key, - ) - existing_result = await db.execute(stmt) - existing_run = existing_result.scalar_one() - - if existing_run.payload_hash == payload_hash: - telemetry = getattr(request.app.state, "telemetry", None) - if telemetry is not None: - telemetry.increment( - "scoring_admission_replay_total", - run_id=existing_run.id, - ) - telemetry.record( - "scoring_admission.replay", - run_id=existing_run.id, - fields={"request_correlation_id": getattr(request.state, "correlation_id", None)}, - ) - return CreateScoringRunResponse( - run_id=existing_run.id, - status=ScoringRunStatus(existing_run.status), - status_url=f"/v1/scoring-runs/{existing_run.id}", - tenant_id=existing_run.tenant_id, - subject_ref=existing_run.subject_ref, - scoring_config_version=existing_run.scoring_config_version, - created_at=existing_run.created_at, - ) - - conflict_response = ErrorResponse( - error_code="idempotency_payload_mismatch", - detail="Idempotency key conflict: payload does not match existing run for this key", - ) - return JSONResponse( - status_code=status.HTTP_409_CONFLICT, - content=conflict_response.model_dump(), - ) - - -@router.get( - "/{run_id}", - status_code=status.HTTP_200_OK, - response_model=ScoringRunResponse, - responses={ - status.HTTP_200_OK: { - "model": ScoringRunResponse, - "description": "Scoring run metadata and current status", - }, - status.HTTP_401_UNAUTHORIZED: { - "model": ErrorResponse, - "description": "Invalid or missing Bearer service token", - }, - status.HTTP_403_FORBIDDEN: { - "model": ErrorResponse, - "description": "Caller not authorized for tenant", - }, - status.HTTP_404_NOT_FOUND: { - "model": ErrorResponse, - "description": "Scoring run not found for tenant", - }, - status.HTTP_422_UNPROCESSABLE_CONTENT: { - "model": ErrorResponse, - "description": "Validation error for missing required headers", - }, - }, - summary="Inspect Scoring Run status", -) -async def get_scoring_run( - run_id: Annotated[str, Path(min_length=1, max_length=36, description="Scoring run ID")], - caller: ServiceCallerDep, - settings: SettingsDep, - db: DBSessionDep, - x_tenant_id: Annotated[ - str, - Header( - alias="X-Tenant-Id", - min_length=1, - max_length=255, - description="Tenant identifier for scoped tenant isolation query", - ), - ], -) -> ScoringRunResponse: - verify_tenant_scope(caller=caller, tenant_id=x_tenant_id, settings=settings) - - stmt = select(ScoringRun).where( - ScoringRun.id == run_id, - ScoringRun.tenant_id == x_tenant_id, - ) - result = await db.execute(stmt) - run = result.scalar_one_or_none() - - if run is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Scoring run not found", - ) - - return _to_scoring_run_response(run) - - -@router.get( - "/{run_id}/trace", - status_code=status.HTTP_200_OK, - responses={ - status.HTTP_200_OK: { - "description": "Internal execution trace for scoring run", - }, - status.HTTP_401_UNAUTHORIZED: { - "model": ErrorResponse, - "description": "Invalid or missing Bearer service token", - }, - status.HTTP_403_FORBIDDEN: { - "model": ErrorResponse, - "description": "Caller unauthorized or trace disabled in production", - }, - status.HTTP_404_NOT_FOUND: { - "model": ErrorResponse, - "description": "Scoring run or trace not found", - }, - status.HTTP_422_UNPROCESSABLE_CONTENT: { - "model": ErrorResponse, - "description": "Validation error for missing required headers", - }, - }, - summary="Get internal execution trace for a scoring run (non-production only)", -) -async def get_scoring_run_trace( - run_id: Annotated[str, Path(min_length=1, max_length=36, description="Scoring run ID")], - caller: ServiceCallerDep, - settings: SettingsDep, - db: DBSessionDep, - x_tenant_id: Annotated[ - str, - Header( - alias="X-Tenant-Id", - min_length=1, - max_length=255, - description="Tenant identifier for scoped tenant isolation query", - ), - ], -) -> dict[str, Any]: - if settings.environment == "production": - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={ - "error_code": "trace_disabled_in_production", - "detail": "Trace inspection endpoint is disabled in production environment", - }, - ) - - verify_tenant_scope(caller=caller, tenant_id=x_tenant_id, settings=settings) - - stmt = select(ScoringRun).where( - ScoringRun.id == run_id, - ScoringRun.tenant_id == x_tenant_id, - ) - result = await db.execute(stmt) - run = result.scalar_one_or_none() - - if run is None or run.execution_trace is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail={ - "error_code": "trace_not_found", - "detail": "Execution trace not found for this scoring run", - }, - ) - - trace = dict(run.execution_trace) - trace["snapshot"] = run.snapshot_data - trace["subject_ref"] = run.subject_ref - trace["scoring_config_version"] = run.scoring_config_version - return trace diff --git a/old_repo/src/lead_scoring/auth.py b/old_repo/src/lead_scoring/auth.py deleted file mode 100644 index a24789a..0000000 --- a/old_repo/src/lead_scoring/auth.py +++ /dev/null @@ -1,96 +0,0 @@ -import secrets -from typing import Annotated - -from fastapi import Depends, HTTPException, Request, Security, status -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer - -from lead_scoring.config import ServiceCallerConfig, Settings - -http_bearer = HTTPBearer( - auto_error=False, - description="Bearer service token for machine caller authentication", -) - - -def get_settings(request: Request) -> Settings: - return getattr(request.app.state, "settings", Settings()) - - -def match_service_token( - token: str, service_callers: list[ServiceCallerConfig] -) -> ServiceCallerConfig | None: - if not token: - return None - matched_caller: ServiceCallerConfig | None = None - for caller in service_callers: - if secrets.compare_digest(caller.token.get_secret_value(), token): - matched_caller = caller - return matched_caller - - -async def authenticate_service_caller( - request: Request, - settings: Annotated[Settings, Depends(get_settings)], - credentials: Annotated[HTTPAuthorizationCredentials | None, Security(http_bearer)] = None, -) -> ServiceCallerConfig: - caller = getattr(request.state, "caller", None) - if isinstance(caller, ServiceCallerConfig): - return caller - - # Fallback when middleware is bypassed or in isolated tests - if credentials is None or not credentials.credentials: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid or missing service token", - headers={"WWW-Authenticate": "Bearer"}, - ) - - matched_caller = match_service_token(credentials.credentials, settings.service_callers) - if matched_caller is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid or missing service token", - headers={"WWW-Authenticate": "Bearer"}, - ) - - return matched_caller - - -def verify_tenant_scope( - caller: ServiceCallerConfig, - tenant_id: str, - settings: Settings, -) -> None: - if tenant_id not in settings.tenant_allowlist: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Tenant is not in pilot allowlist", - ) - - if caller.allowed_tenants != ["*"] and tenant_id not in caller.allowed_tenants: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Caller is not authorized for requested tenant", - ) - - -def verify_caller_scope( - caller: ServiceCallerConfig, - app: str, - feature: str, - tenant_id: str, - settings: Settings, -) -> None: - if caller.allowed_apps != ["*"] and app not in caller.allowed_apps: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Caller is not authorized for requested application", - ) - - if caller.allowed_features != ["*"] and feature not in caller.allowed_features: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Caller is not authorized for requested feature", - ) - - verify_tenant_scope(caller=caller, tenant_id=tenant_id, settings=settings) diff --git a/old_repo/src/lead_scoring/cli/__init__.py b/old_repo/src/lead_scoring/cli/__init__.py deleted file mode 100644 index b944440..0000000 --- a/old_repo/src/lead_scoring/cli/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""CLI entrypoints for Lead Scoring service.""" diff --git a/old_repo/src/lead_scoring/cli/contract_pack.py b/old_repo/src/lead_scoring/cli/contract_pack.py deleted file mode 100644 index 48071ee..0000000 --- a/old_repo/src/lead_scoring/cli/contract_pack.py +++ /dev/null @@ -1,996 +0,0 @@ -import argparse -import hashlib -import json -import sys -from collections.abc import Mapping, Sequence -from datetime import UTC, datetime, timedelta -from pathlib import Path -from typing import Any - -from pydantic import BaseModel, TypeAdapter, ValidationError - -from lead_scoring.cli.openapi import generate_openapi_json -from lead_scoring.cli.polling_contract import generate_contract_files as generate_polling_files -from lead_scoring.cli.webhook_contract import generate_contract_files as generate_webhook_files -from lead_scoring.schemas.errors import ( - ERROR_TAXONOMY, - ErrorResponse, - ErrorTaxonomy, -) -from lead_scoring.schemas.lineage import ScoringRunLineage -from lead_scoring.schemas.outcomes import OUTCOME_TAXONOMY, OutcomeTaxonomy -from lead_scoring.schemas.review import ( - ApproveReviewRequest, - LeadScoringReviewedEvent, - OverrideReviewRequest, - ReEvaluateReviewRequest, - ReviewDecision, - ReviewRequest, - ReviewResponse, -) -from lead_scoring.schemas.scoring_config import ( - EffortProfile, - FallbackPolicy, - RuleMetric, - ScoringConfig, - ScoringConfigVersionSelection, - ScoringRule, -) -from lead_scoring.schemas.scoring_run import ( - CreateScoringRunRequest, - CreateScoringRunResponse, - ScoringRunResponse, - ScoringRunStatus, -) -from lead_scoring.schemas.snapshot import ( - CanonicalSnapshot, - ConversationEvidence, - HitlPolicy, - IdentityContext, - LeadProfile, - MessageEvidence, - SnapshotScope, - TriggerMetadata, - TruncationMetadata, -) -from lead_scoring.schemas.webhook import ( - LeadScoringTerminalEvent, - WebhookIngestErrorResponse, - WebhookIngestResponse, -) - -PROJECT_ROOT = Path(__file__).resolve().parents[3] -DEFAULT_CONTRACT_DIR = PROJECT_ROOT / "contracts" / "lead-scoring-contract-pack" / "v1" -CONTRACT_PACK_VERSION = "0.1.0" -API_MAJOR_VERSION = "v1" -CONTRACT_SCHEMA_VERSION = "1.0.0" -MARKER_OPEN = "" -MARKER_CLOSE = "" -MARKER_FIXTURE_NAMES = ( - "plain-description-append", - "empty-description-append", - "single-valid-block-replace", - "orphan-opening", - "orphan-closing", - "nested-block", - "overlapping-block", - "multiple-complete-blocks", - "unicode-multiline-preservation", - "deleted-block-followed-by-append", - "failed-run-no-render", -) - -_REVIEW_REQUEST_ADAPTER = TypeAdapter(ReviewRequest) -_EVENT_ENVELOPE_ADAPTER = TypeAdapter(LeadScoringTerminalEvent | LeadScoringReviewedEvent) - - -def canonical_json(value: Any) -> str: - return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n" - - -def _sample_config() -> ScoringConfig: - return ScoringConfig( - version="1.0.0", - base_score=50, - confidence=0.85, - confidence_threshold=0.80, - hitl_policy_version="1.0.0", - rules=( - ScoringRule(metric=RuleMetric.PROFILE_FIELD_COUNT, minimum=2, points=20), - ScoringRule(metric=RuleMetric.CONVERSATION_COUNT, minimum=1, points=10), - ), - rule_weight=0.4, - ai_weight=0.6, - effort_profile=EffortProfile.MEDIUM, - ai_model="openai/gpt-4o-mini", - fallback_policy=FallbackPolicy.RULES_ONLY_REVIEW, - ai_timeout_seconds=10.0, - ) - - -def _sample_snapshot(*, captured_at: datetime | None = None) -> CanonicalSnapshot: - frozen_at = captured_at or datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - return CanonicalSnapshot( - version=CONTRACT_SCHEMA_VERSION, - scope=SnapshotScope.LINKED_CONTACT_HISTORY, - captured_at=frozen_at, - lead_profile=LeadProfile( - ems_lead_id="ems-lead-1001", - name="Example Lead", - phone="+84000000000", - email="lead@example.test", - status="open", - custom_fields={"industry": "software"}, - ), - conversations=[ - ConversationEvidence( - conversation_id="conversation-1001", - channel="chat", - assigned_agent_id="agent-10", - messages=[ - MessageEvidence( - message_id="message-1001", - sender_type="contact", - content="Interested in a product demonstration.", - sent_at=frozen_at - timedelta(minutes=10), - ), - MessageEvidence( - message_id="message-1002", - sender_type="agent", - content="I can arrange that for you.", - sent_at=frozen_at - timedelta(minutes=5), - ), - ], - ) - ], - identity_context=IdentityContext( - match_quality="direct", - contact_id="contact-1001", - details={"source": "ems"}, - ), - trigger_metadata=TriggerMetadata( - trigger_type="manual_omni", - trigger_conversation_id="conversation-1001", - actor_id="agent-10", - ), - hitl_policy=HitlPolicy(confidence_threshold=0.80, policy_version="1.0.0"), - truncation=TruncationMetadata(truncated=False), - ) - - -def _sample_admission_request() -> CreateScoringRunRequest: - return CreateScoringRunRequest( - tenant_id="omni-account-42", - subject_ref="ems-lead-1001", - scoring_config_version="1.0.0", - snapshot=_sample_snapshot(), - ) - - -def _sample_review_requests() -> dict[str, BaseModel]: - base = { - "expected_run_version": 1, - "reviewer": { - "actor_id": "agent-10", - "role": "agent", - "current_assignee_id": "agent-10", - }, - "note": "Reviewed against the latest customer context.", - } - return { - "approve": ApproveReviewRequest.model_validate({**base, "decision": "approve"}), - "override": OverrideReviewRequest.model_validate( - {**base, "decision": "override", "override_score": 90} - ), - "re_evaluate": ReEvaluateReviewRequest.model_validate( - { - **base, - "decision": "re_evaluate", - "snapshot": _sample_snapshot(captured_at=datetime(2026, 8, 27, 12, 5, tzinfo=UTC)), - } - ), - } - - -def _sample_lineage() -> ScoringRunLineage: - return ScoringRunLineage( - run_id="bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - parent_run_id="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - relationship="re_evaluation_child", - ) - - -def _marker_input( - *, - final_score: int = 61, - confidence: float = 0.85, - scoring_mode: str = "medium_ai", - degraded: bool = False, -) -> dict[str, Any]: - return { - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "run_created_at": "2026-08-27T12:00:00Z", - "scoring_config_version": "1.0.0", - "final_score": final_score, - "confidence": confidence, - "scoring_mode": scoring_mode, - "degraded": degraded, - } - - -def _render_marker_block(marker_input: Mapping[str, Any]) -> str: - degraded = "yes" if marker_input["degraded"] else "no" - return "\n".join( - ( - MARKER_OPEN, - "Lead Scoring", - f"Score: {marker_input['final_score']}", - f"Confidence: {marker_input['confidence']:.2f}", - f"Mode: {marker_input['scoring_mode']}", - f"Degraded: {degraded}", - f"Config: {marker_input['scoring_config_version']}", - f"Run: {marker_input['run_id']}", - f"Run created: {marker_input['run_created_at']}", - MARKER_CLOSE, - ) - ) - - -def _marker_fixture( - *, - case: str, - run_status: str, - current_description: str, - marker_input: Mapping[str, Any] | None, - expected_description: str | None, - expected_error: str | None = None, - writeback: bool, -) -> str: - return canonical_json( - { - "case": case, - "run_status": run_status, - "current_description": current_description, - "marker_input": marker_input, - "expected_description": expected_description, - "expected_error": expected_error, - "writeback": writeback, - } - ) - - -def _marker_fixture_files() -> dict[str, str]: - marker_input = _marker_input() - degraded_input = _marker_input( - final_score=50, - confidence=0.62, - scoring_mode="rules_only_fallback", - degraded=True, - ) - old_marker = _render_marker_block( - _marker_input(final_score=42, confidence=0.71, scoring_mode="light_ai") - ) - new_marker = _render_marker_block(marker_input) - - def append(description: str, block: str) -> str: - return f"{description}\n\n{block}" if description else block - - def replace(description: str, block: str) -> str: - start = description.index(MARKER_OPEN) - end = description.index(MARKER_CLOSE, start) + len(MARKER_CLOSE) - return description[:start] + block + description[end:] - - descriptions = { - "plain-description-append": "Existing customer note.", - "empty-description-append": "", - "single-valid-block-replace": ( - f"Customer note:\nKeep this line.\n\n{old_marker}\n\nFollow up tomorrow." - ), - "orphan-opening": f"Notes before\n\n{MARKER_OPEN}\npartial marker", - "orphan-closing": f"Notes before\n\npartial marker\n{MARKER_CLOSE}", - "nested-block": ( - f"{MARKER_OPEN}\nouter\n{MARKER_OPEN}\ninner\n{MARKER_CLOSE}\n{MARKER_CLOSE}" - ), - "overlapping-block": f"{MARKER_OPEN}\nfirst\n{MARKER_CLOSE}{MARKER_CLOSE}", - "multiple-complete-blocks": f"{old_marker}\n\nUser text\n\n{old_marker}", - "unicode-multiline-preservation": ( - f"Khách hàng: Nguyễn Ánh 😊\nDòng thứ hai.\n\n{old_marker}" - "\n\nGhi chú cuối: giữ nguyên nội dung người dùng." - ), - "deleted-block-followed-by-append": ( - "Lead owner: Nguyễn Ánh 😊\n\nThe previous score block was removed." - ), - "failed-run-no-render": "Customer note: do not write a failure marker.", - } - - malformed_cases = ( - "orphan-opening", - "orphan-closing", - "nested-block", - "overlapping-block", - "multiple-complete-blocks", - ) - fixtures: dict[str, str] = { - "fixtures/marker/plain-description-append.json": _marker_fixture( - case="plain-description-append", - run_status="completed", - current_description=descriptions["plain-description-append"], - marker_input=marker_input, - expected_description=append(descriptions["plain-description-append"], new_marker), - writeback=True, - ), - "fixtures/marker/empty-description-append.json": _marker_fixture( - case="empty-description-append", - run_status="completed", - current_description=descriptions["empty-description-append"], - marker_input=marker_input, - expected_description=append(descriptions["empty-description-append"], new_marker), - writeback=True, - ), - "fixtures/marker/single-valid-block-replace.json": _marker_fixture( - case="single-valid-block-replace", - run_status="completed", - current_description=descriptions["single-valid-block-replace"], - marker_input=marker_input, - expected_description=replace(descriptions["single-valid-block-replace"], new_marker), - writeback=True, - ), - "fixtures/marker/unicode-multiline-preservation.json": _marker_fixture( - case="unicode-multiline-preservation", - run_status="review_required", - current_description=descriptions["unicode-multiline-preservation"], - marker_input=degraded_input, - expected_description=replace( - descriptions["unicode-multiline-preservation"], - _render_marker_block(degraded_input), - ), - writeback=True, - ), - "fixtures/marker/deleted-block-followed-by-append.json": _marker_fixture( - case="deleted-block-followed-by-append", - run_status="completed", - current_description=descriptions["deleted-block-followed-by-append"], - marker_input=marker_input, - expected_description=append( - descriptions["deleted-block-followed-by-append"], - new_marker, - ), - writeback=True, - ), - "fixtures/marker/failed-run-no-render.json": _marker_fixture( - case="failed-run-no-render", - run_status="failed", - current_description=descriptions["failed-run-no-render"], - marker_input=None, - expected_description=descriptions["failed-run-no-render"], - writeback=False, - ), - } - for case in malformed_cases: - fixtures[f"fixtures/marker/{case}.json"] = _marker_fixture( - case=case, - run_status="completed", - current_description=descriptions[case], - marker_input=marker_input, - expected_description=None, - expected_error="marker_block_malformed", - writeback=False, - ) - return fixtures - - -def _schema_files() -> dict[str, str]: - return { - "schemas/canonical-snapshot.schema.json": canonical_json( - CanonicalSnapshot.model_json_schema() - ), - "schemas/scoring-config.schema.json": canonical_json(ScoringConfig.model_json_schema()), - "schemas/scoring-config-selection.schema.json": canonical_json( - ScoringConfigVersionSelection.model_json_schema() - ), - "schemas/admission-request.schema.json": canonical_json( - CreateScoringRunRequest.model_json_schema() - ), - "schemas/admission-response.schema.json": canonical_json( - CreateScoringRunResponse.model_json_schema() - ), - "schemas/polling-response.schema.json": canonical_json( - ScoringRunResponse.model_json_schema() - ), - "schemas/review-request.schema.json": canonical_json(_REVIEW_REQUEST_ADAPTER.json_schema()), - "schemas/review-response.schema.json": canonical_json(ReviewResponse.model_json_schema()), - "schemas/lineage.schema.json": canonical_json(ScoringRunLineage.model_json_schema()), - "schemas/webhook-event-envelope.schema.json": canonical_json( - _EVENT_ENVELOPE_ADAPTER.json_schema() - ), - "schemas/terminal-event.schema.json": canonical_json( - LeadScoringTerminalEvent.model_json_schema() - ), - "schemas/reviewed-event.schema.json": canonical_json( - LeadScoringReviewedEvent.model_json_schema() - ), - "schemas/error-response.schema.json": canonical_json(ErrorResponse.model_json_schema()), - "schemas/error-taxonomy.schema.json": canonical_json(ErrorTaxonomy.model_json_schema()), - "schemas/outcome-taxonomy.schema.json": canonical_json(OutcomeTaxonomy.model_json_schema()), - "schemas/webhook-ingest-response.schema.json": canonical_json( - WebhookIngestResponse.model_json_schema() - ), - "schemas/webhook-ingest-error-response.schema.json": canonical_json( - WebhookIngestErrorResponse.model_json_schema() - ), - } - - -def _fixture_files() -> dict[str, str]: - admission = _sample_admission_request() - review_requests = _sample_review_requests() - positive: dict[str, str] = { - "fixtures/snapshot.json": canonical_json(_sample_snapshot().model_dump(mode="json")), - "fixtures/admission-request.json": canonical_json(admission.model_dump(mode="json")), - "fixtures/review-approve-request.json": canonical_json( - review_requests["approve"].model_dump(mode="json") - ), - "fixtures/review-override-request.json": canonical_json( - review_requests["override"].model_dump(mode="json") - ), - "fixtures/review-re_evaluate-request.json": canonical_json( - review_requests["re_evaluate"].model_dump(mode="json") - ), - "fixtures/lineage-root.json": canonical_json( - ScoringRunLineage( - run_id="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - relationship="root", - ).model_dump(mode="json") - ), - "fixtures/lineage-child.json": canonical_json(_sample_lineage().model_dump(mode="json")), - "fixtures/error-validation.json": canonical_json( - ErrorResponse( - error_code="validation_error", - detail="Request validation failed", - ).model_dump(mode="json") - ), - } - - polling_files = generate_polling_files() - webhook_files = generate_webhook_files() - positive["fixtures/scoring-config.json"] = canonical_json( - _sample_config().model_dump(mode="json") - ) - positive["fixtures/scoring-config-selection.json"] = canonical_json( - ScoringConfigVersionSelection(version="1.0.0").model_dump(mode="json") - ) - positive["fixtures/admission-response.json"] = canonical_json( - CreateScoringRunResponse( - run_id="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - status=ScoringRunStatus.PENDING, - status_url="/v1/scoring-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - tenant_id="omni-account-42", - subject_ref="ems-lead-1001", - scoring_config_version="1.0.0", - created_at=datetime(2026, 8, 27, 12, 0, tzinfo=UTC), - ).model_dump(mode="json") - ) - positive["fixtures/review-response.json"] = canonical_json( - ReviewResponse( - run_id="bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - tenant_id="omni-account-42", - subject_ref="ems-lead-1002", - status=ScoringRunStatus.COMPLETED, - decision=ReviewDecision.APPROVE, - calculated_score=61, - effective_score=61, - version=2, - reviewed_by="agent-10", - reviewed_at=datetime(2026, 8, 27, 12, 5, tzinfo=UTC), - ).model_dump(mode="json") - ) - for relative_path, content in polling_files.items(): - if relative_path.startswith("fixtures/"): - normalized_path = relative_path.removeprefix("fixtures/") - positive[f"fixtures/polling/{normalized_path}"] = content - else: - positive[f"profiles/polling/{relative_path}"] = content - for relative_path, content in webhook_files.items(): - if relative_path.startswith("fixtures/"): - normalized_path = relative_path.removeprefix("fixtures/") - positive[f"fixtures/webhook/{normalized_path}"] = content - else: - positive[f"profiles/webhook/{relative_path}"] = content - - # Giữ path này ổn định làm fixture response tenant-unavailable dùng chung. - positive["fixtures/tenant-unavailable.json"] = webhook_files["fixtures/tenant-unavailable.json"] - positive.update(_marker_fixture_files()) - - negative: dict[str, str] = { - "fixtures/negative/snapshot-future-message.json": canonical_json( - { - **_sample_snapshot().model_dump(mode="json"), - "conversations": [ - { - "conversation_id": "conversation-1001", - "messages": [ - { - "message_id": "message-future", - "sender_type": "contact", - "content": "This message is after capture.", - "sent_at": "2026-08-27T12:01:00Z", - } - ], - } - ], - } - ), - "fixtures/negative/admission-subject-mismatch.json": canonical_json( - { - **admission.model_dump(mode="json"), - "subject_ref": "ems-lead-other", - } - ), - "fixtures/negative/review-override-out-of-range.json": canonical_json( - { - **review_requests["override"].model_dump(mode="json"), - "override_score": 101, - } - ), - "fixtures/negative/event-missing-version.json": canonical_json( - { - "event_id": "11111111-1111-4111-8111-111111111111", - "event_type": "lead_scoring.run.terminal", - "tenant_id": "omni-account-42", - "subject_ref": "ems-lead-1001", - "scoring_config_version": "1.0.0", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "run_created_at": "2026-08-27T12:00:00Z", - "occurred_at": "2026-08-27T12:00:03Z", - "data": {"status": "completed", "rule_score": 82, "confidence": 0.91}, - } - ), - } - return {**positive, **negative} - - -def _manifest() -> dict[str, Any]: - return { - "contract_pack_version": CONTRACT_PACK_VERSION, - "api_major_version": API_MAJOR_VERSION, - "release_status": "pre-release", - "source_of_truth": "Pydantic models under src/lead_scoring/schemas", - "generator": "uv run generate-contract-pack", - "profiles": { - "canonical_snapshot": { - "version": CONTRACT_SCHEMA_VERSION, - "schema_file": "schemas/canonical-snapshot.schema.json", - }, - "scoring_config": { - "version": CONTRACT_SCHEMA_VERSION, - "schema_file": "schemas/scoring-config.schema.json", - "selection_schema_file": "schemas/scoring-config-selection.schema.json", - "immutable": True, - "selection": ( - "Callers select an existing version; Lead Scoring remains authoritative." - ), - }, - "admission": { - "version": CONTRACT_SCHEMA_VERSION, - "request_schema_file": "schemas/admission-request.schema.json", - "response_schema_file": "schemas/admission-response.schema.json", - }, - "polling": { - "version": CONTRACT_SCHEMA_VERSION, - "schema_file": "schemas/polling-response.schema.json", - "endpoint": "GET /v1/scoring-runs/{run_id}", - }, - "review": { - "version": CONTRACT_SCHEMA_VERSION, - "request_schema_file": "schemas/review-request.schema.json", - "response_schema_file": "schemas/review-response.schema.json", - "endpoint": "POST /v1/scoring-runs/{run_id}/reviews", - }, - "lineage": { - "version": CONTRACT_SCHEMA_VERSION, - "schema_file": "schemas/lineage.schema.json", - }, - "webhook": { - "version": CONTRACT_SCHEMA_VERSION, - "schema_file": "schemas/webhook-event-envelope.schema.json", - "endpoint": "POST /api/v1/internal/lead-scoring/events", - }, - "errors": { - "version": CONTRACT_SCHEMA_VERSION, - "schema_file": "schemas/error-taxonomy.schema.json", - "outcome_schema_file": "schemas/outcome-taxonomy.schema.json", - }, - }, - "artifacts": { - "manifest": "contract.json", - "openapi": "openapi.json", - "schemas_directory": "schemas/", - "fixtures_directory": "fixtures/", - "compatibility": "compatibility.json", - "security_profile": "security-profile.json", - "error_taxonomy": "error-taxonomy.json", - "outcome_taxonomy": "outcome-taxonomy.json", - "fixture_index": "fixture-index.json", - "checksums": "SHA256SUMS", - }, - } - - -def _security_profile() -> dict[str, Any]: - return { - "contract_pack_version": CONTRACT_PACK_VERSION, - "service_authentication": { - "scheme": "Bearer", - "header": "Authorization", - "identity_source": "deployment-managed opaque service token", - "caller_binding": "registered caller, operation scope, and tenant allowlist", - "rotation": "Provision a replacement token, verify it, then revoke the old token.", - "replay": "Service-token replay protection is owned by the deployment boundary.", - }, - "tenant_credential": { - "header": "X-Tenant-VKey", - "owner": "caller and AIHub", - "persistence": "in-memory execution attempt only", - "forbidden_surfaces": [ - "database", - "queue", - "cache", - "checkpoint", - "logs", - "traces", - "events", - "browser", - ], - }, - "webhook_authentication": { - "headers": [ - "X-DotB-Webhook-Id", - "X-DotB-Webhook-Key-Id", - "X-DotB-Webhook-Timestamp", - "X-DotB-Webhook-Signature", - ], - "algorithm": "HMAC-SHA256", - "rotation": ( - "Receiver may accept active and immediately previous key during a bounded window." - ), - }, - } - - -def _fixture_index() -> dict[str, Any]: - positive = [ - { - "path": "fixtures/snapshot.json", - "role": "provider_consumer", - "schema": "schemas/canonical-snapshot.schema.json", - }, - { - "path": "fixtures/scoring-config.json", - "role": "provider_consumer", - "schema": "schemas/scoring-config.schema.json", - }, - { - "path": "fixtures/scoring-config-selection.json", - "role": "provider_consumer", - "schema": "schemas/scoring-config-selection.schema.json", - }, - { - "path": "fixtures/admission-request.json", - "role": "provider_consumer", - "schema": "schemas/admission-request.schema.json", - }, - { - "path": "fixtures/admission-response.json", - "role": "provider_consumer", - "schema": "schemas/admission-response.schema.json", - }, - { - "path": "fixtures/review-approve-request.json", - "role": "provider_consumer", - "schema": "schemas/review-request.schema.json", - }, - { - "path": "fixtures/review-override-request.json", - "role": "provider_consumer", - "schema": "schemas/review-request.schema.json", - }, - { - "path": "fixtures/review-re_evaluate-request.json", - "role": "provider_consumer", - "schema": "schemas/review-request.schema.json", - }, - { - "path": "fixtures/review-response.json", - "role": "provider_consumer", - "schema": "schemas/review-response.schema.json", - }, - { - "path": "fixtures/lineage-root.json", - "role": "provider_consumer", - "schema": "schemas/lineage.schema.json", - }, - { - "path": "fixtures/lineage-child.json", - "role": "provider_consumer", - "schema": "schemas/lineage.schema.json", - }, - { - "path": "fixtures/tenant-unavailable.json", - "role": "provider_consumer", - "schema": "schemas/webhook-ingest-error-response.schema.json", - }, - *[ - { - "path": f"fixtures/polling/{status}.json", - "role": "provider_consumer", - "schema": "schemas/polling-response.schema.json", - } - for status in ( - "pending", - "running", - "completed", - "review_required", - "failed", - "superseded", - ) - ], - *[ - { - "path": f"fixtures/webhook/{name}.json", - "role": "provider_consumer", - "schema": "schemas/terminal-event.schema.json", - } - for name in ("completed", "review_required", "worker_interrupted") - ], - *[ - { - "path": f"fixtures/webhook/{name}.json", - "role": "provider_consumer", - "schema": "schemas/reviewed-event.schema.json", - } - for name in ("reviewed_approve", "reviewed_override", "reviewed_reevaluate") - ], - ] - marker = [ - { - "path": f"fixtures/marker/{name}.json", - "role": "provider_consumer", - } - for name in MARKER_FIXTURE_NAMES - ] - negative = [ - { - "path": "fixtures/negative/snapshot-future-message.json", - "schema": "schemas/canonical-snapshot.schema.json", - }, - { - "path": "fixtures/negative/admission-subject-mismatch.json", - "schema": "schemas/admission-request.schema.json", - }, - { - "path": "fixtures/negative/review-override-out-of-range.json", - "schema": "schemas/review-request.schema.json", - }, - { - "path": "fixtures/negative/event-missing-version.json", - "schema": "schemas/terminal-event.schema.json", - }, - ] - return { - "contract_pack_version": CONTRACT_PACK_VERSION, - "provider_validation": "validate_fixture_corpus", - "consumer_validation": "Consume the same paths without copying or rewriting payloads.", - "positive": positive, - "marker": marker, - "negative": negative, - } - - -def _compatibility() -> dict[str, Any]: - return { - "contract_pack_version": CONTRACT_PACK_VERSION, - "api_major_version": API_MAJOR_VERSION, - "provider": "ai-platform", - "consumer": "dotb-omni", - "pinning": { - "unit": "contract_pack_version_and_ai_platform_git_commit", - "required_before_implementation": True, - "fixture_corpus": "fixtures/", - "provider_check": "uv run generate-contract-pack --check", - "consumer_check": "Run Omni consumer tests against the pinned fixtures/ directory", - }, - "upgrade": { - "compatible_additive": { - "rule": ( - "Keep the same major version and preserve existing required fields " - "and enum values." - ), - "verification": ( - "Validate current fixtures with the previous consumer schema and add " - "a fixture for the new optional field." - ), - }, - "breaking": { - "rule": ( - "Publish a new API major directory and contract pack version; never " - "mutate a pinned pack in place." - ), - "verification": ( - "Run provider and consumer fixture validation before changing the Omni pin." - ), - }, - "sequence": [ - "Generate and review artifacts from Pydantic source of truth.", - "Run provider --check and fixture validation.", - "Publish the immutable pack and record its AI Platform Git commit.", - "Update Omni's pin and run consumer validation against the same corpus.", - "Promote the pin only after sandbox compatibility verification.", - ], - }, - } - - -def _checksum_file(files: Mapping[str, str]) -> str: - lines = [] - for relative_path in sorted(files): - digest = hashlib.sha256(files[relative_path].encode("utf-8")).hexdigest() - lines.append(f"{digest} {relative_path}") - return "\n".join(lines) + "\n" - - -def generate_contract_files() -> dict[str, str]: - files: dict[str, str] = { - "contract.json": canonical_json(_manifest()), - "compatibility.json": canonical_json(_compatibility()), - "security-profile.json": canonical_json(_security_profile()), - "fixture-index.json": canonical_json(_fixture_index()), - "openapi.json": generate_openapi_json(), - "error-taxonomy.json": canonical_json( - [entry.model_dump(mode="json") for entry in ERROR_TAXONOMY] - ), - "outcome-taxonomy.json": canonical_json( - [entry.model_dump(mode="json") for entry in OUTCOME_TAXONOMY] - ), - } - files.update(_schema_files()) - files.update(_fixture_files()) - files["SHA256SUMS"] = _checksum_file(files) - return files - - -generate_contract_pack_files = generate_contract_files - - -def validate_fixture_corpus(files: Mapping[str, str] | None = None) -> None: - """Kiểm tra positive fixtures dùng chung và xác nhận negative fixtures vẫn bị từ chối.""" - - generated = files or generate_contract_files() - positive_validators = { - "fixtures/snapshot.json": CanonicalSnapshot, - "fixtures/scoring-config.json": ScoringConfig, - "fixtures/scoring-config-selection.json": ScoringConfigVersionSelection, - "fixtures/admission-request.json": CreateScoringRunRequest, - "fixtures/admission-response.json": CreateScoringRunResponse, - "fixtures/review-approve-request.json": ApproveReviewRequest, - "fixtures/review-override-request.json": OverrideReviewRequest, - "fixtures/review-re_evaluate-request.json": ReEvaluateReviewRequest, - "fixtures/review-response.json": ReviewResponse, - "fixtures/lineage-root.json": ScoringRunLineage, - "fixtures/lineage-child.json": ScoringRunLineage, - "fixtures/error-validation.json": ErrorResponse, - "fixtures/tenant-unavailable.json": WebhookIngestErrorResponse, - } - for relative_path, model in positive_validators.items(): - model.model_validate(json.loads(generated[relative_path])) - ErrorTaxonomy.model_validate(json.loads(generated["error-taxonomy.json"])) - OutcomeTaxonomy.model_validate(json.loads(generated["outcome-taxonomy.json"])) - - for status in ("pending", "running", "completed", "failed"): - ScoringRunResponse.model_validate(json.loads(generated[f"fixtures/polling/{status}.json"])) - for name in ("completed", "review_required", "worker_interrupted"): - LeadScoringTerminalEvent.model_validate( - json.loads(generated[f"fixtures/webhook/{name}.json"]) - ) - for name in ("reviewed_approve", "reviewed_override", "reviewed_reevaluate"): - LeadScoringReviewedEvent.model_validate( - json.loads(generated[f"fixtures/webhook/{name}.json"]) - ) - WebhookIngestErrorResponse.model_validate( - json.loads(generated["fixtures/webhook/tenant-unavailable.json"]) - ) - for name in MARKER_FIXTURE_NAMES: - fixture = json.loads(generated[f"fixtures/marker/{name}.json"]) - if fixture.get("expected_error") == "marker_block_malformed": - if fixture.get("expected_description") is not None or fixture.get("writeback"): - raise AssertionError(f"Malformed Marker fixture has output: {name}") - elif fixture.get("run_status") == "failed": - if fixture.get("marker_input") is not None or fixture.get("writeback"): - raise AssertionError(f"Failed Marker fixture writes back: {name}") - elif not fixture.get("expected_description") or not fixture.get("writeback"): - raise AssertionError(f"Valid Marker fixture has no writeback output: {name}") - - for relative_path in ( - "fixtures/negative/snapshot-future-message.json", - "fixtures/negative/admission-subject-mismatch.json", - "fixtures/negative/review-override-out-of-range.json", - "fixtures/negative/event-missing-version.json", - ): - raw = json.loads(generated[relative_path]) - model = ( - CanonicalSnapshot - if "snapshot" in relative_path - else CreateScoringRunRequest - if "admission" in relative_path - else OverrideReviewRequest - if "override" in relative_path - else LeadScoringTerminalEvent - ) - try: - model.model_validate(raw) - except ValidationError: - continue - raise AssertionError(f"Negative fixture unexpectedly validated: {relative_path}") - - -def run_contract_pack_cli(args: Sequence[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Generate or check the versioned Contract Pack.") - parser.add_argument("--output", default=str(DEFAULT_CONTRACT_DIR)) - parser.add_argument("--check", action="store_true") - parser.add_argument( - "--validate-fixtures", - action="store_true", - help="Validate provider-side positive and negative fixture expectations.", - ) - parsed_args = parser.parse_args(args=args) - output_dir = Path(parsed_args.output).resolve() - generated = generate_contract_files() - - if parsed_args.validate_fixtures: - try: - validate_fixture_corpus(generated) - except (AssertionError, KeyError, ValidationError) as exc: - print(f"[Error] Contract fixture validation failed: {exc}", file=sys.stderr) - return 1 - print("[OK] Contract fixture corpus validates against provider models.") - if not parsed_args.check: - return 0 - - if parsed_args.check: - for relative_path, content in generated.items(): - target = output_dir / relative_path - if not target.exists() or target.read_text(encoding="utf-8") != content: - print( - f"[Error] Contract Pack artifact is missing or stale: {target}", - file=sys.stderr, - ) - return 1 - actual_files = { - path.relative_to(output_dir).as_posix() - for path in output_dir.rglob("*") - if path.is_file() - } - if actual_files != set(generated): - print("[Error] Contract Pack directory contains unexpected artifacts.", file=sys.stderr) - return 1 - print(f"[OK] Contract Pack artifacts at '{output_dir}' are up to date.") - return 0 - - if output_dir.exists(): - for path in sorted(output_dir.rglob("*"), reverse=True): - if path.is_file() and path.relative_to(output_dir).as_posix() not in generated: - path.unlink() - - for relative_path, content in generated.items(): - target = output_dir / relative_path - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(content, encoding="utf-8") - print(f"[OK] Generated Contract Pack artifacts at '{output_dir}'.") - return 0 - - -def main() -> None: - sys.exit(run_contract_pack_cli()) - - -if __name__ == "__main__": - main() diff --git a/old_repo/src/lead_scoring/cli/openapi.py b/old_repo/src/lead_scoring/cli/openapi.py deleted file mode 100644 index a57a485..0000000 --- a/old_repo/src/lead_scoring/cli/openapi.py +++ /dev/null @@ -1,83 +0,0 @@ -import argparse -import json -import sys -from collections.abc import Sequence -from pathlib import Path -from typing import Any - -from fastapi import FastAPI - -from lead_scoring import __version__ -from lead_scoring.config import Settings -from lead_scoring.main import create_app - -PROJECT_ROOT = Path(__file__).resolve().parents[3] -DEFAULT_OPENAPI_PATH = PROJECT_ROOT / "openapi.json" - - -def generate_openapi_dict(app: FastAPI | None = None) -> dict[str, Any]: - if app is None: - deterministic_settings = Settings(app_name="lead-scoring", app_version=__version__) - target_app = create_app(settings=deterministic_settings) - else: - target_app = app - return target_app.openapi() - - -def generate_openapi_json(app: FastAPI | None = None) -> str: - schema_dict = generate_openapi_dict(app) - return json.dumps(schema_dict, indent=2, sort_keys=True) + "\n" - - -def run_openapi_cli(args: Sequence[str] | None = None) -> int: - parser = argparse.ArgumentParser( - description="Generate or check OpenAPI schema for Lead Scoring service.", - ) - parser.add_argument( - "-o", - "--output", - type=str, - default=str(DEFAULT_OPENAPI_PATH), - help=f"Path to the OpenAPI JSON file (default: {DEFAULT_OPENAPI_PATH}).", - ) - parser.add_argument( - "--check", - action="store_true", - help="Check if the target file matches current OpenAPI schema instead of writing it.", - ) - - parsed_args = parser.parse_args(args=args) - target_path = Path(parsed_args.output).resolve() - generated_content = generate_openapi_json() - - if parsed_args.check: - if not target_path.exists(): - print( - f"[Error] OpenAPI file does not exist at '{target_path}'.", - file=sys.stderr, - ) - return 1 - - existing_content = target_path.read_text(encoding="utf-8") - if existing_content != generated_content: - print( - f"[Error] OpenAPI schema at '{target_path}' is stale or modified.", - file=sys.stderr, - ) - return 1 - - print(f"[OK] OpenAPI schema at '{target_path}' is up to date.") - return 0 - - target_path.parent.mkdir(parents=True, exist_ok=True) - target_path.write_text(generated_content, encoding="utf-8") - print(f"[OK] Generated OpenAPI schema written to '{target_path}'.") - return 0 - - -def main() -> None: - sys.exit(run_openapi_cli()) - - -if __name__ == "__main__": - main() diff --git a/old_repo/src/lead_scoring/cli/polling_contract.py b/old_repo/src/lead_scoring/cli/polling_contract.py deleted file mode 100644 index 741f744..0000000 --- a/old_repo/src/lead_scoring/cli/polling_contract.py +++ /dev/null @@ -1,285 +0,0 @@ -import argparse -import json -import sys -from collections.abc import Mapping, Sequence -from pathlib import Path -from typing import Any - -from lead_scoring.schemas.errors import ErrorResponse -from lead_scoring.schemas.scoring_run import ( - ScoringRunResponse, - ScoringRunStatus, -) -from lead_scoring.scoring import ( - EffortProfile, - FallbackPolicy, - RuleMetric, - ScoringConfig, - ScoringMode, - ScoringRule, -) - -PROJECT_ROOT = Path(__file__).resolve().parents[3] -DEFAULT_CONTRACT_DIR = PROJECT_ROOT / "contracts" / "lead-scoring-polling" / "v1" -POLLING_METHOD = "GET" -POLLING_ENDPOINT_PATH = "/v1/scoring-runs/{run_id}" - -DEFAULT_SCORING_CONFIG = ScoringConfig( - version="1.0.0", - base_score=50, - confidence=0.85, - confidence_threshold=0.80, - hitl_policy_version="1.0.0", - rules=( - ScoringRule(metric=RuleMetric.PROFILE_FIELD_COUNT, minimum=2, points=20), - ScoringRule(metric=RuleMetric.CONVERSATION_COUNT, minimum=1, points=10), - ), - rule_weight=0.4, - ai_weight=0.6, - effort_profile=EffortProfile.MEDIUM, - ai_model="openai/gpt-4o-mini", - fallback_policy=FallbackPolicy.RULES_ONLY_REVIEW, - ai_timeout_seconds=10.0, -) - -_FIXTURES: Mapping[str, dict[str, Any]] = { - "pending": { - "run_id": "11111111-1111-4111-8111-111111111111", - "tenant_id": "omni-account-42", - "subject_ref": "ems-lead-1001", - "scoring_config_version": "1.0.0", - "parent_run_id": None, - "status": ScoringRunStatus.PENDING.value, - "rule_score": None, - "ai_score": None, - "final_score": None, - "effective_score": None, - "scoring_mode": None, - "degraded": None, - "ai_metadata": None, - "confidence": None, - "confidence_threshold": DEFAULT_SCORING_CONFIG.confidence_threshold, - "breakdown": None, - "effective_config": None, - "error_code": None, - "retryable": None, - "version": 1, - "started_at": None, - "completed_at": None, - "created_at": "2026-08-27T12:00:00Z", - "updated_at": "2026-08-27T12:00:00Z", - }, - "running": { - "run_id": "22222222-2222-4222-8222-222222222222", - "tenant_id": "omni-account-42", - "subject_ref": "ems-lead-1002", - "scoring_config_version": "1.0.0", - "parent_run_id": None, - "status": ScoringRunStatus.RUNNING.value, - "rule_score": None, - "ai_score": None, - "final_score": None, - "effective_score": None, - "scoring_mode": None, - "degraded": None, - "ai_metadata": None, - "confidence": None, - "confidence_threshold": DEFAULT_SCORING_CONFIG.confidence_threshold, - "breakdown": None, - "effective_config": None, - "error_code": None, - "retryable": None, - "version": 1, - "started_at": "2026-08-27T12:00:01Z", - "completed_at": None, - "created_at": "2026-08-27T12:00:00Z", - "updated_at": "2026-08-27T12:00:01Z", - }, - "completed": { - "run_id": "33333333-3333-4333-8333-333333333333", - "tenant_id": "omni-account-42", - "subject_ref": "ems-lead-1003", - "scoring_config_version": "1.0.0", - "parent_run_id": None, - "status": ScoringRunStatus.COMPLETED.value, - "rule_score": 82, - "ai_score": 78, - "final_score": 80, - "effective_score": 80, - "category": "hot", - "explanation": "Lead đạt 80/100 sau khi kết hợp bộ quy tắc và phân tích hội thoại bằng AI.", - "recommended_action": "Liên hệ với Lead trong 24 giờ để xác nhận bước đăng ký tiếp theo.", - "scoring_mode": ScoringMode.MEDIUM_AI.value, - "degraded": False, - "ai_metadata": { - "effort_profile": DEFAULT_SCORING_CONFIG.effort_profile.value, - "model": DEFAULT_SCORING_CONFIG.ai_model, - "pass_count": 2, - }, - "confidence": 0.91, - "confidence_threshold": DEFAULT_SCORING_CONFIG.confidence_threshold, - "breakdown": None, - "effective_config": None, - "error_code": None, - "retryable": None, - "version": 1, - "started_at": "2026-08-27T12:00:01Z", - "completed_at": "2026-08-27T12:00:03Z", - "created_at": "2026-08-27T12:00:00Z", - "updated_at": "2026-08-27T12:00:03Z", - }, - "failed": { - "run_id": "55555555-5555-4555-8555-555555555555", - "tenant_id": "omni-account-42", - "subject_ref": "ems-lead-1005", - "scoring_config_version": "1.0.0", - "parent_run_id": None, - "status": ScoringRunStatus.FAILED.value, - "rule_score": None, - "ai_score": None, - "final_score": None, - "effective_score": None, - "category": "cold", - "explanation": "Không thể chấm điểm.", - "recommended_action": "Kiểm tra lỗi và thử chấm điểm lại.", - "scoring_mode": None, - "degraded": None, - "ai_metadata": None, - "confidence": None, - "confidence_threshold": DEFAULT_SCORING_CONFIG.confidence_threshold, - "breakdown": None, - "effective_config": None, - "error_code": "worker_interrupted", - "retryable": True, - "version": 1, - "started_at": "2026-08-27T12:02:01Z", - "completed_at": "2026-08-27T12:07:01Z", - "created_at": "2026-08-27T12:02:00Z", - "updated_at": "2026-08-27T12:07:01Z", - }, -} - - -def canonical_json(value: Any) -> str: - return json.dumps(value, indent=2, sort_keys=True) + "\n" - - -def generate_contract_files() -> dict[str, str]: - files = { - "contract.json": canonical_json( - { - "api_major_version": "v1", - "contract_pack_version": "0.1.0", - "endpoint_path": POLLING_ENDPOINT_PATH, - "error_schema_file": "error-response.schema.json", - "headers": { - "authorization": "Bearer ", - "tenant_id": "X-Tenant-Id", - }, - "http_responses": { - "200": { - "description": "Scoring run metadata and current status", - "schema_file": "scoring-run-response.schema.json", - }, - "401": { - "description": "Invalid or missing Bearer service token", - "schema_file": "error-response.schema.json", - }, - "403": { - "description": "Caller not authorized for tenant", - "schema_file": "error-response.schema.json", - }, - "404": { - "description": "Scoring run not found for tenant", - "schema_file": "error-response.schema.json", - }, - "422": { - "description": ( - "Validation error for missing/invalid required headers " - "or path parameters" - ), - "schema_file": "error-response.schema.json", - }, - }, - "method": POLLING_METHOD, - "path_params": { - "run_id": { - "description": "Unique scoring run identifier", - "max_length": 36, - "min_length": 1, - "type": "string", - } - }, - "reserved_statuses": [], - "response_schema_file": "scoring-run-response.schema.json", - "supported_statuses": [ - ScoringRunStatus.PENDING.value, - ScoringRunStatus.RUNNING.value, - ScoringRunStatus.COMPLETED.value, - ScoringRunStatus.FAILED.value, - ], - } - ), - "scoring-run-response.schema.json": canonical_json(ScoringRunResponse.model_json_schema()), - "error-response.schema.json": canonical_json(ErrorResponse.model_json_schema()), - } - - for name, raw_fixture in _FIXTURES.items(): - run_response = ScoringRunResponse.model_validate(raw_fixture) - body = canonical_json(run_response.model_dump(mode="json")) - files[f"fixtures/{name}.json"] = body - - return files - - -def run_polling_contract_cli(args: Sequence[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Generate or check the polling contract pack.") - parser.add_argument("--output", default=str(DEFAULT_CONTRACT_DIR)) - parser.add_argument("--check", action="store_true") - parsed_args = parser.parse_args(args=args) - output_dir = Path(parsed_args.output).resolve() - generated = generate_contract_files() - - if parsed_args.check: - for relative_path, content in generated.items(): - target = output_dir / relative_path - if not target.exists() or target.read_text(encoding="utf-8") != content: - print( - f"[Error] Polling contract artifact is missing or stale: {target}", - file=sys.stderr, - ) - return 1 - actual_files = { - path.relative_to(output_dir).as_posix() - for path in output_dir.rglob("*") - if path.is_file() - } - if actual_files != set(generated): - print( - "[Error] Polling contract directory contains unexpected artifacts.", file=sys.stderr - ) - return 1 - print(f"[OK] Polling contract artifacts at '{output_dir}' are up to date.") - return 0 - - if output_dir.exists(): - for path in list(output_dir.rglob("*")): - if path.is_file(): - rel_path = path.relative_to(output_dir).as_posix() - if rel_path not in generated: - path.unlink() - - for relative_path, content in generated.items(): - target = output_dir / relative_path - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(content, encoding="utf-8") - print(f"[OK] Generated polling contract artifacts at '{output_dir}'.") - return 0 - - -def main() -> None: - sys.exit(run_polling_contract_cli()) - - -if __name__ == "__main__": - main() diff --git a/old_repo/src/lead_scoring/cli/webhook_contract.py b/old_repo/src/lead_scoring/cli/webhook_contract.py deleted file mode 100644 index 7e29e0b..0000000 --- a/old_repo/src/lead_scoring/cli/webhook_contract.py +++ /dev/null @@ -1,273 +0,0 @@ -import argparse -import sys -from collections.abc import Mapping, Sequence -from pathlib import Path -from typing import Any - -from lead_scoring.schemas.review import ( - REVIEW_EVENT_TYPE, - REVIEW_EVENT_VERSION, - LeadScoringReviewedEvent, -) -from lead_scoring.schemas.webhook import ( - WEBHOOK_EVENT_TYPE, - WEBHOOK_EVENT_VERSION, - LeadScoringTerminalEvent, - WebhookIngestErrorResponse, - WebhookIngestResponse, -) -from lead_scoring.webhook.signing import ( - TEST_SIGNING_SECRET, - WEBHOOK_CANONICAL_INPUT, - WEBHOOK_ENDPOINT_PATH, - WEBHOOK_METHOD, - WEBHOOK_REPLAY_WINDOW_SECONDS, - WEBHOOK_SIGNATURE_ALGORITHM, - WEBHOOK_SIGNATURE_VERSION, - canonical_json, - sign_webhook, -) - -PROJECT_ROOT = Path(__file__).resolve().parents[3] -DEFAULT_CONTRACT_DIR = PROJECT_ROOT / "contracts" / "lead-scoring-webhook" / "v1" - -_FIXTURES: Mapping[str, dict[str, Any]] = { - "completed": { - "event_id": "11111111-1111-4111-8111-111111111111", - "event_type": WEBHOOK_EVENT_TYPE, - "event_version": "1.0.0", - "tenant_id": "omni-account-42", - "subject_ref": "ems-lead-1001", - "scoring_config_version": "1.0.0", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "run_created_at": "2026-08-27T12:00:00Z", - "occurred_at": "2026-08-27T12:00:03Z", - "data": { - "status": "completed", - "rule_score": 82, - "confidence": 0.91, - "error_code": None, - "retryable": None, - }, - }, - "review_required": { - "event_id": "22222222-2222-4222-8222-222222222222", - "event_type": WEBHOOK_EVENT_TYPE, - "event_version": "1.0.0", - "tenant_id": "omni-account-42", - "subject_ref": "ems-lead-1002", - "scoring_config_version": "1.0.0", - "run_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - "run_created_at": "2026-08-27T12:01:00Z", - "occurred_at": "2026-08-27T12:01:04Z", - "data": { - "status": "completed", - "rule_score": 61, - "confidence": 0.64, - "error_code": None, - "retryable": None, - }, - }, - "worker_interrupted": { - "event_id": "33333333-3333-4333-8333-333333333333", - "event_type": WEBHOOK_EVENT_TYPE, - "event_version": "1.0.0", - "tenant_id": "omni-account-42", - "subject_ref": "ems-lead-1003", - "scoring_config_version": "1.0.0", - "run_id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", - "run_created_at": "2026-08-27T12:02:00Z", - "occurred_at": "2026-08-27T12:07:01Z", - "data": { - "status": "failed", - "rule_score": None, - "confidence": None, - "error_code": "worker_interrupted", - "retryable": True, - }, - }, - "reviewed_approve": { - "event_id": "44444444-4444-4444-8444-444444444444", - "event_type": REVIEW_EVENT_TYPE, - "event_version": REVIEW_EVENT_VERSION, - "tenant_id": "omni-account-42", - "subject_ref": "ems-lead-1002", - "scoring_config_version": "1.0.0", - "run_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - "run_created_at": "2026-08-27T12:01:00Z", - "occurred_at": "2026-08-27T12:05:00Z", - "data": { - "status": "completed", - "decision": "approve", - "calculated_score": 61, - "effective_score": 61, - "version": 2, - "reviewed_by": "agent_10", - }, - }, - "reviewed_override": { - "event_id": "55555555-5555-4555-8555-555555555555", - "event_type": REVIEW_EVENT_TYPE, - "event_version": REVIEW_EVENT_VERSION, - "tenant_id": "omni-account-42", - "subject_ref": "ems-lead-1002", - "scoring_config_version": "1.0.0", - "run_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - "run_created_at": "2026-08-27T12:01:00Z", - "occurred_at": "2026-08-27T12:06:00Z", - "data": { - "status": "completed", - "decision": "override", - "calculated_score": 61, - "effective_score": 90, - "version": 2, - "reviewed_by": "agent_10", - }, - }, - "reviewed_reevaluate": { - "event_id": "66666666-6666-4666-8666-666666666666", - "event_type": REVIEW_EVENT_TYPE, - "event_version": REVIEW_EVENT_VERSION, - "tenant_id": "omni-account-42", - "subject_ref": "ems-lead-1001", - "scoring_config_version": "1.0.0", - "run_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "run_created_at": "2026-08-27T12:00:00Z", - "occurred_at": "2026-08-27T12:07:00Z", - "data": { - "status": "superseded", - "decision": "re_evaluate", - "calculated_score": 82, - "effective_score": 82, - "version": 2, - "reviewed_by": "agent_10", - }, - }, -} - - -def generate_contract_files() -> dict[str, str]: - files = { - "contract.json": canonical_json( - { - "canonical_input": WEBHOOK_CANONICAL_INPUT, - "contract_pack_version": "0.1.0", - "endpoint_path": WEBHOOK_ENDPOINT_PATH, - "event_type": WEBHOOK_EVENT_TYPE, - "event_types": [ - WEBHOOK_EVENT_TYPE, - REVIEW_EVENT_TYPE, - ], - "event_version": WEBHOOK_EVENT_VERSION, - "method": WEBHOOK_METHOD, - "replay_window_seconds": WEBHOOK_REPLAY_WINDOW_SECONDS, - "signature_algorithm": WEBHOOK_SIGNATURE_ALGORITHM, - "signature_version": WEBHOOK_SIGNATURE_VERSION, - "supported_event_types": [ - WEBHOOK_EVENT_TYPE, - REVIEW_EVENT_TYPE, - ], - } - ), - "lead-scoring-reviewed-event.schema.json": canonical_json( - LeadScoringReviewedEvent.model_json_schema() - ), - "lead-scoring-terminal-event.schema.json": canonical_json( - LeadScoringTerminalEvent.model_json_schema() - ), - "webhook-ingest-response.schema.json": canonical_json( - WebhookIngestResponse.model_json_schema() - ), - "webhook-ingest-error-response.schema.json": canonical_json( - WebhookIngestErrorResponse.model_json_schema() - ), - "fixtures/tenant-unavailable.json": canonical_json( - WebhookIngestErrorResponse( - error_code="subscriber_tenant_unavailable", - detail="Webhook destination is not available", - ).model_dump(mode="json") - ), - } - vectors: list[dict[str, str]] = [] - timestamp = "1787832600" - for name, raw_fixture in _FIXTURES.items(): - if raw_fixture["event_type"] == WEBHOOK_EVENT_TYPE: - event = LeadScoringTerminalEvent.model_validate(raw_fixture) - else: - event = LeadScoringReviewedEvent.model_validate(raw_fixture) - body = canonical_json(event.model_dump(mode="json")) - fixture_path = f"fixtures/{name}.json" - files[fixture_path] = body - event_id = str(event.event_id) - vectors.append( - { - "body_file": fixture_path, - "event_id": event_id, - "key_id": "pilot-test-2026-01", - "signature": sign_webhook(TEST_SIGNING_SECRET, event_id, timestamp, body.encode()), - "timestamp": timestamp, - } - ) - files["signature-vectors.json"] = canonical_json( - { - "algorithm": WEBHOOK_SIGNATURE_ALGORITHM, - "canonical_input": WEBHOOK_CANONICAL_INPUT, - "headers": { - "event_id": "X-DotB-Webhook-Id", - "key_id": "X-DotB-Webhook-Key-Id", - "signature": "X-DotB-Webhook-Signature", - "timestamp": "X-DotB-Webhook-Timestamp", - }, - "replay_window_seconds": WEBHOOK_REPLAY_WINDOW_SECONDS, - "signature_version": WEBHOOK_SIGNATURE_VERSION, - "test_secret": TEST_SIGNING_SECRET, - "vectors": vectors, - } - ) - return files - - -def run_webhook_contract_cli(args: Sequence[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Generate or check the webhook contract pack.") - parser.add_argument("--output", default=str(DEFAULT_CONTRACT_DIR)) - parser.add_argument("--check", action="store_true") - parsed_args = parser.parse_args(args=args) - output_dir = Path(parsed_args.output).resolve() - generated = generate_contract_files() - - if parsed_args.check: - for relative_path, content in generated.items(): - target = output_dir / relative_path - if not target.exists() or target.read_text(encoding="utf-8") != content: - print( - f"[Error] Webhook contract artifact is missing or stale: {target}", - file=sys.stderr, - ) - return 1 - actual_files = { - path.relative_to(output_dir).as_posix() - for path in output_dir.rglob("*") - if path.is_file() - } - if actual_files != set(generated): - print( - "[Error] Webhook contract directory contains unexpected artifacts.", file=sys.stderr - ) - return 1 - print(f"[OK] Webhook contract artifacts at '{output_dir}' are up to date.") - return 0 - - for relative_path, content in generated.items(): - target = output_dir / relative_path - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(content, encoding="utf-8") - print(f"[OK] Generated webhook contract artifacts at '{output_dir}'.") - return 0 - - -def main() -> None: - sys.exit(run_webhook_contract_cli()) - - -if __name__ == "__main__": - main() diff --git a/old_repo/src/lead_scoring/config.py b/old_repo/src/lead_scoring/config.py deleted file mode 100644 index 966e4e6..0000000 --- a/old_repo/src/lead_scoring/config.py +++ /dev/null @@ -1,215 +0,0 @@ -import urllib.parse - -from pydantic import BaseModel, Field, SecretStr, field_validator, model_validator -from pydantic_settings import BaseSettings, SettingsConfigDict - -from lead_scoring import __version__ -from lead_scoring.observability import DEFAULT_ALERT_THRESHOLDS -from lead_scoring.scoring import ( - PROFILE_PASS_NAMES, - ScoringConfig, - calculate_min_worker_lease_seconds, -) -from lead_scoring.webhook.signing import WEBHOOK_ENDPOINT_PATH - - -class ServiceCallerConfig(BaseModel): - caller_id: str - token: SecretStr - allowed_apps: list[str] = Field(..., description="Explicitly allowed applications") - allowed_features: list[str] = Field(..., description="Explicitly allowed features") - allowed_tenants: list[str] = Field(..., description="Explicitly allowed tenants") - - -class Settings(BaseSettings): - app_name: str = "lead-scoring" - app_version: str = __version__ - environment: str = "development" - database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/lead_scoring" - db_health_timeout_seconds: float = Field(default=2.0, gt=0) - aihub_base_url: str = "http://localhost:8080" - debug: bool = False - - service_callers: list[ServiceCallerConfig] = Field(default_factory=list) - tenant_allowlist: list[str] = Field(default_factory=list) - scoring_configs: list[ScoringConfig] = Field(default_factory=list) - worker_lease_seconds: float = Field(default=30.0, gt=0) - worker_recovery_interval_seconds: float = Field(default=5.0, gt=0) - kill_switch_enabled: bool = False - stuck_run_alert_threshold: int = Field( - default=DEFAULT_ALERT_THRESHOLDS["scoring_stuck_runs_total"], ge=1 - ) - aihub_failure_alert_threshold: int = Field( - default=DEFAULT_ALERT_THRESHOLDS["aihub_failures_total"], ge=1 - ) - outbox_dlq_alert_threshold: int = Field( - default=DEFAULT_ALERT_THRESHOLDS["outbox_dead_letter_total"], ge=1 - ) - retention_failure_alert_threshold: int = Field( - default=DEFAULT_ALERT_THRESHOLDS["retention_failures_total"], ge=1 - ) - - webhook_subscriber_url: str | None = None - webhook_signing_secret: SecretStr | None = None - webhook_signing_key_id: str = "pilot-2026-01" - webhook_timeout_seconds: float = Field(default=10.0, gt=0) - webhook_max_attempts: int = Field(default=5, ge=1) - webhook_backoff_base_seconds: float = Field(default=2.0, gt=0) - webhook_backoff_max_seconds: float = Field(default=60.0, gt=0) - webhook_dispatch_interval_seconds: float = Field(default=1.0, gt=0) - webhook_lease_seconds: float = Field(default=30.0, gt=0) - webhook_batch_size: int = Field(default=50, ge=1) - - retention_days: int = Field(default=90, ge=1) - retention_cleanup_interval_seconds: float = Field(default=3600.0, gt=0) - retention_batch_size: int = Field(default=100, ge=1) - - @field_validator("service_callers") - @classmethod - def validate_service_callers( - cls, callers: list[ServiceCallerConfig] - ) -> list[ServiceCallerConfig]: - seen_caller_ids: set[str] = set() - seen_tokens: set[str] = set() - for caller in callers: - if caller.caller_id in seen_caller_ids: - raise ValueError(f"Duplicate caller_id '{caller.caller_id}' in service_callers") - raw_token = caller.token.get_secret_value() - if raw_token in seen_tokens: - raise ValueError("Duplicate service token configured in service_callers") - seen_caller_ids.add(caller.caller_id) - seen_tokens.add(raw_token) - return callers - - @field_validator("scoring_configs") - @classmethod - def validate_scoring_configs(cls, configs: list[ScoringConfig]) -> list[ScoringConfig]: - versions = [config.version for config in configs] - if len(versions) != len(set(versions)): - raise ValueError("Duplicate scoring config version") - return configs - - def get_scoring_config(self, version: str) -> ScoringConfig | None: - return next((config for config in self.scoring_configs if config.version == version), None) - - def alert_thresholds(self) -> dict[str, int]: - return { - "scoring_stuck_runs_total": self.stuck_run_alert_threshold, - "aihub_failures_total": self.aihub_failure_alert_threshold, - "outbox_dead_letter_total": self.outbox_dlq_alert_threshold, - "retention_failures_total": self.retention_failure_alert_threshold, - } - - @field_validator("tenant_allowlist") - @classmethod - def validate_tenant_allowlist(cls, allowlist: list[str]) -> list[str]: - if "*" in allowlist: - raise ValueError("Wildcard '*' is prohibited in global tenant_allowlist") - return allowlist - - @field_validator("database_url", mode="before") - @classmethod - def normalize_database_url(cls, v: str) -> str: - if isinstance(v, str): - if v.startswith("postgres://"): - return "postgresql+asyncpg://" + v[len("postgres://") :] - if v.startswith("postgresql://"): - return "postgresql+asyncpg://" + v[len("postgresql://") :] - return v - - @model_validator(mode="after") - def validate_worker_lease_covers_ai_timeout(self) -> "Settings": - for config in self.scoring_configs: - min_lease = calculate_min_worker_lease_seconds(config) - if min_lease is not None and self.worker_lease_seconds <= min_lease: - pass_name = PROFILE_PASS_NAMES.get(config.effort_profile, "AI") - raise ValueError( - f"worker_lease_seconds ({self.worker_lease_seconds}s) must exceed " - f"{pass_name} AI timeout plus margin ({min_lease}s) " - f"for config '{config.version}'" - ) - return self - - @model_validator(mode="after") - def validate_aihub_base_url(self) -> "Settings": - parsed = urllib.parse.urlsplit(self.aihub_base_url) - if not parsed.scheme or not parsed.netloc: - raise ValueError( - f"Invalid aihub_base_url '{self.aihub_base_url}': missing scheme or host" - ) - if parsed.scheme not in ("http", "https"): - raise ValueError( - f"Invalid aihub_base_url scheme '{parsed.scheme}': must be http or https" - ) - if parsed.username or parsed.password: - raise ValueError("aihub_base_url must not contain user credentials") - if parsed.query or parsed.fragment: - raise ValueError("aihub_base_url must not contain query parameters or fragments") - - hostname = (parsed.hostname or "").lower() - is_local = hostname in ("localhost", "127.0.0.1", "::1", "testserver") - - if self.environment == "production": - if parsed.scheme != "https": - raise ValueError("aihub_base_url must use HTTPS in production environment") - else: - if parsed.scheme == "http" and not is_local: - raise ValueError( - "aihub_base_url HTTP scheme is only allowed for localhost " - f"in non-production, got '{hostname}'" - ) - return self - - @model_validator(mode="after") - def validate_webhook_settings(self) -> "Settings": - if self.webhook_subscriber_url is None: - return self - - parsed = urllib.parse.urlsplit(self.webhook_subscriber_url) - if not parsed.scheme or not parsed.netloc: - raise ValueError( - f"Invalid webhook_subscriber_url '{self.webhook_subscriber_url}': " - "missing scheme or host" - ) - if parsed.scheme not in ("http", "https"): - raise ValueError( - f"Invalid webhook_subscriber_url scheme '{parsed.scheme}': must be http or https" - ) - if parsed.username or parsed.password: - raise ValueError("webhook_subscriber_url must not contain user credentials") - if parsed.query or parsed.fragment: - raise ValueError( - "webhook_subscriber_url must not contain query parameters or fragments" - ) - - if parsed.path not in ("", "/", WEBHOOK_ENDPOINT_PATH): - raise ValueError( - f"webhook_subscriber_url path must be empty, '/', or '{WEBHOOK_ENDPOINT_PATH}', " - f"got '{parsed.path}'" - ) - - hostname = (parsed.hostname or "").lower() - is_local = hostname in ("localhost", "127.0.0.1", "::1", "testserver") - - if self.environment == "production": - if parsed.scheme != "https" and not is_local: - raise ValueError("webhook_subscriber_url must use HTTPS in production environment") - else: - if parsed.scheme == "http" and not is_local: - raise ValueError( - "webhook_subscriber_url HTTP scheme is only allowed for localhost " - f"in non-production, got '{hostname}'" - ) - - if not self.webhook_signing_secret or not self.webhook_signing_secret.get_secret_value(): - raise ValueError( - "webhook_signing_secret is required when webhook_subscriber_url is configured" - ) - - return self - - model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - extra="ignore", - ) diff --git a/old_repo/src/lead_scoring/db/__init__.py b/old_repo/src/lead_scoring/db/__init__.py deleted file mode 100644 index 7608806..0000000 --- a/old_repo/src/lead_scoring/db/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Database package for Lead Scoring service.""" - -from lead_scoring.db.base import Base -from lead_scoring.db.models.outbox_event import OutboxEvent -from lead_scoring.db.models.scoring_run import ScoringRun -from lead_scoring.db.session import ( - check_db_health, - create_engine_from_url, - create_session_factory, -) - -__all__ = [ - "Base", - "OutboxEvent", - "ScoringRun", - "check_db_health", - "create_engine_from_url", - "create_session_factory", -] diff --git a/old_repo/src/lead_scoring/db/base.py b/old_repo/src/lead_scoring/db/base.py deleted file mode 100644 index fa2b68a..0000000 --- a/old_repo/src/lead_scoring/db/base.py +++ /dev/null @@ -1,5 +0,0 @@ -from sqlalchemy.orm import DeclarativeBase - - -class Base(DeclarativeBase): - pass diff --git a/old_repo/src/lead_scoring/db/models/__init__.py b/old_repo/src/lead_scoring/db/models/__init__.py deleted file mode 100644 index 26789ad..0000000 --- a/old_repo/src/lead_scoring/db/models/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from lead_scoring.db.models.outbox_event import OutboxEvent -from lead_scoring.db.models.review_audit import ReviewAudit -from lead_scoring.db.models.scoring_run import ScoringRun - -__all__ = ["OutboxEvent", "ReviewAudit", "ScoringRun"] diff --git a/old_repo/src/lead_scoring/db/models/outbox_event.py b/old_repo/src/lead_scoring/db/models/outbox_event.py deleted file mode 100644 index a911309..0000000 --- a/old_repo/src/lead_scoring/db/models/outbox_event.py +++ /dev/null @@ -1,53 +0,0 @@ -from datetime import UTC, datetime -from typing import Any - -from sqlalchemy import DateTime, Index, Integer, String -from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.orm import Mapped, mapped_column - -from lead_scoring.db.base import Base - - -class OutboxEvent(Base): - __tablename__ = "outbox_events" - - id: Mapped[str] = mapped_column(String(36), primary_key=True) - event_type: Mapped[str] = mapped_column(String(128), nullable=False) - event_version: Mapped[str] = mapped_column(String(32), nullable=False) - run_id: Mapped[str] = mapped_column(String(36), nullable=False) - payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) - occurred_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) - ) - - delivery_status: Mapped[str] = mapped_column( - String(32), nullable=False, default="pending", server_default="pending" - ) - attempt_count: Mapped[int] = mapped_column( - Integer, nullable=False, default=0, server_default="0" - ) - next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - lease_expires_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True) - last_error_code: Mapped[str | None] = mapped_column(String(64), nullable=True) - last_error_detail: Mapped[str | None] = mapped_column(String(2000), nullable=True) - last_http_status: Mapped[int | None] = mapped_column(Integer, nullable=True) - - __table_args__ = ( - Index( - "uq_outbox_terminal_run", - "run_id", - "event_type", - unique=True, - postgresql_where=(event_type == "lead_scoring.run.terminal"), - ), - Index( - "ix_outbox_events_delivery_claim", - "delivery_status", - "next_attempt_at", - ), - ) diff --git a/old_repo/src/lead_scoring/db/models/review_audit.py b/old_repo/src/lead_scoring/db/models/review_audit.py deleted file mode 100644 index cafac2c..0000000 --- a/old_repo/src/lead_scoring/db/models/review_audit.py +++ /dev/null @@ -1,44 +0,0 @@ -from datetime import UTC, datetime - -from sqlalchemy import DateTime, Index, Integer, String, UniqueConstraint -from sqlalchemy.orm import Mapped, mapped_column - -from lead_scoring.db.base import Base - - -class ReviewAudit(Base): - __tablename__ = "review_audits" - - id: Mapped[str] = mapped_column(String(36), primary_key=True) - run_id: Mapped[str] = mapped_column(String(36), nullable=False) - tenant_id: Mapped[str] = mapped_column(String(255), nullable=False) - decision: Mapped[str] = mapped_column(String(32), nullable=False) - actor_id: Mapped[str] = mapped_column(String(255), nullable=False) - actor_role: Mapped[str] = mapped_column(String(32), nullable=False) - idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) - payload_hash: Mapped[str] = mapped_column(String(64), nullable=False) - previous_version: Mapped[int] = mapped_column(Integer, nullable=False) - new_version: Mapped[int] = mapped_column(Integer, nullable=False) - calculated_score: Mapped[int | None] = mapped_column(Integer, nullable=True) - effective_score: Mapped[int | None] = mapped_column(Integer, nullable=True) - child_run_id: Mapped[str | None] = mapped_column(String(36), nullable=True) - note: Mapped[str | None] = mapped_column(String(2000), nullable=True) - redacted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - nullable=False, - default=lambda: datetime.now(UTC), - ) - - __table_args__ = ( - UniqueConstraint( - "tenant_id", - "idempotency_key", - name="uq_review_audits_tenant_idempotency", - ), - Index( - "ix_review_audits_retention", - "created_at", - "redacted_at", - ), - ) diff --git a/old_repo/src/lead_scoring/db/models/scoring_run.py b/old_repo/src/lead_scoring/db/models/scoring_run.py deleted file mode 100644 index 4f2bf1a..0000000 --- a/old_repo/src/lead_scoring/db/models/scoring_run.py +++ /dev/null @@ -1,72 +0,0 @@ -from datetime import UTC, datetime -from typing import Any - -from sqlalchemy import Boolean, DateTime, Float, Index, Integer, String, UniqueConstraint -from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.orm import Mapped, mapped_column - -from lead_scoring.db.base import Base - - -class ScoringRun(Base): - __tablename__ = "scoring_runs" - - id: Mapped[str] = mapped_column(String(36), primary_key=True) - tenant_id: Mapped[str] = mapped_column(String(255), nullable=False) - subject_ref: Mapped[str] = mapped_column(String(255), nullable=False) - scoring_config_version: Mapped[str] = mapped_column(String(64), nullable=False) - dotb_app: Mapped[str | None] = mapped_column(String(64), nullable=True) - dotb_feature: Mapped[str | None] = mapped_column(String(64), nullable=True) - parent_run_id: Mapped[str | None] = mapped_column(String(36), nullable=True) - idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) - payload_hash: Mapped[str] = mapped_column(String(64), nullable=False) - status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending") - snapshot_data: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) - effective_config: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) - confidence_threshold: Mapped[float | None] = mapped_column(Float, nullable=True) - rule_score: Mapped[int | None] = mapped_column(Integer, nullable=True) - ai_score: Mapped[int | None] = mapped_column(Integer, nullable=True) - final_score: Mapped[int | None] = mapped_column(Integer, nullable=True) - effective_score: Mapped[int | None] = mapped_column(Integer, nullable=True) - scoring_mode: Mapped[str | None] = mapped_column(String(32), nullable=True) - degraded: Mapped[bool | None] = mapped_column(Boolean, nullable=True) - ai_metadata: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) - confidence: Mapped[float | None] = mapped_column(Float, nullable=True) - error_code: Mapped[str | None] = mapped_column(String(64), nullable=True) - retryable: Mapped[bool | None] = mapped_column(Boolean, nullable=True) - execution_trace: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) - version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) - review_decision: Mapped[str | None] = mapped_column(String(32), nullable=True) - reviewed_by: Mapped[str | None] = mapped_column(String(255), nullable=True) - reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - review_note: Mapped[str | None] = mapped_column(String(2000), nullable=True) - started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - lease_expires_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - redacted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - nullable=False, - default=lambda: datetime.now(UTC), - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - nullable=False, - default=lambda: datetime.now(UTC), - onupdate=lambda: datetime.now(UTC), - ) - - __table_args__ = ( - UniqueConstraint( - "tenant_id", - "idempotency_key", - name="uq_scoring_runs_tenant_idempotency", - ), - Index( - "ix_scoring_runs_retention", - "created_at", - "redacted_at", - ), - ) diff --git a/old_repo/src/lead_scoring/db/session.py b/old_repo/src/lead_scoring/db/session.py deleted file mode 100644 index 58a8c04..0000000 --- a/old_repo/src/lead_scoring/db/session.py +++ /dev/null @@ -1,39 +0,0 @@ -import asyncio - -from sqlalchemy import text -from sqlalchemy.ext.asyncio import ( - AsyncEngine, - AsyncSession, - async_sessionmaker, - create_async_engine, -) - - -def create_engine_from_url(database_url: str, echo: bool = False) -> AsyncEngine: - return create_async_engine( - database_url, - echo=echo, - hide_parameters=True, - future=True, - ) - - -def create_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: - return async_sessionmaker( - bind=engine, - class_=AsyncSession, - expire_on_commit=False, - ) - - -async def check_db_health(engine: AsyncEngine, timeout: float = 2.0) -> bool: - try: - - async def _query() -> None: - async with engine.connect() as conn: - await conn.execute(text("SELECT 1")) - - await asyncio.wait_for(_query(), timeout=timeout) - return True - except Exception: - return False diff --git a/old_repo/src/lead_scoring/dynamic_rules.py b/old_repo/src/lead_scoring/dynamic_rules.py deleted file mode 100644 index 6cd50f1..0000000 --- a/old_repo/src/lead_scoring/dynamic_rules.py +++ /dev/null @@ -1,215 +0,0 @@ -import unicodedata -from typing import Any - -from lead_scoring.schemas.dynamic_rule import DynamicRule, DynamicRuleOperator, DynamicRuleTarget -from lead_scoring.schemas.rule_breakdown import ( - NormalizedFeatures, - RuleBreakdownItem, - RuleEvaluationStatus, -) -from lead_scoring.schemas.snapshot import CanonicalSnapshot - - -def _normalize_str(val: Any) -> str: - """Normalize string to lowercase stripped form.""" - return str(val).strip().lower() - - -def _strip_accents(s: str) -> str: - """Strip Vietnamese/unicode combining diacritics and normalize đ/Đ.""" - s = s.replace("đ", "d").replace("Đ", "d") - return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn") - - -def _extract_target_value( - rule: DynamicRule, - snapshot: CanonicalSnapshot, - features: NormalizedFeatures, -) -> tuple[bool, Any]: - """Extract target value. Returns (present, value).""" - target = rule.target - - if target == DynamicRuleTarget.STUDENT_AGE: - return (features.student_age is not None, features.student_age) - if target == DynamicRuleTarget.LEAD_SOURCE: - return (features.lead_source is not None, features.lead_source) - if target == DynamicRuleTarget.COURSE_INTEREST: - return (features.course_interest is not None, features.course_interest) - if target == DynamicRuleTarget.CONTACT_RECENCY_DAYS: - return ( - features.contact_message_recency_days is not None, - features.contact_message_recency_days, - ) - if target == DynamicRuleTarget.CONVERSATION_COUNT: - return (True, features.conversation_count) - if target == DynamicRuleTarget.MESSAGE_COUNT: - return (True, features.message_count) - if target == DynamicRuleTarget.STUDY_AREA: - custom_fields = snapshot.lead_profile.custom_fields or {} - val = custom_fields.get("study_area") - return (val is not None and str(val).strip() != "", val) - - if target == DynamicRuleTarget.CUSTOM_FIELD: - key = rule.custom_field_key - if not key: - return (False, None) - custom_fields = snapshot.lead_profile.custom_fields or {} - if key in custom_fields: - val = custom_fields[key] - if val is None or (isinstance(val, str) and not val.strip()): - return (False, None) - return (True, val) - return (False, None) - - return (False, None) - - -def _compare_values(actual: Any, op: DynamicRuleOperator, target_val: Any) -> bool: - """Safely compare actual vs target value using the operator.""" - try: - # Numeric conversions when both look like numbers (explicitly exclude bool) - if op in ( - DynamicRuleOperator.GT, - DynamicRuleOperator.GTE, - DynamicRuleOperator.LT, - DynamicRuleOperator.LTE, - ): - if isinstance(actual, bool) or isinstance(target_val, bool): - return False - act_num = float(actual) - tgt_num = float(target_val) - if op == DynamicRuleOperator.GT: - return act_num > tgt_num - if op == DynamicRuleOperator.GTE: - return act_num >= tgt_num - if op == DynamicRuleOperator.LT: - return act_num < tgt_num - if op == DynamicRuleOperator.LTE: - return act_num <= tgt_num - - if op == DynamicRuleOperator.EQ: - # Prevent bool from matching ints/floats (e.g. True == 1) - if isinstance(actual, bool) or isinstance(target_val, bool): - if type(actual) is not type(target_val): - return False - return bool(actual) is bool(target_val) - - # Try numeric equality when both can be parsed as floats - try: - act_num = float(actual) - tgt_num = float(target_val) - return act_num == tgt_num - except (ValueError, TypeError): - pass - - act_s = _normalize_str(actual) - tgt_s = _normalize_str(target_val) - return act_s == tgt_s or _strip_accents(act_s) == _strip_accents(tgt_s) - - if op == DynamicRuleOperator.NEQ: - # Prevent bool from matching ints/floats - if isinstance(actual, bool) or isinstance(target_val, bool): - if type(actual) is not type(target_val): - return True - return bool(actual) is not bool(target_val) - - try: - act_num = float(actual) - tgt_num = float(target_val) - return act_num != tgt_num - except (ValueError, TypeError): - pass - - act_s = _normalize_str(actual) - tgt_s = _normalize_str(target_val) - return act_s != tgt_s and _strip_accents(act_s) != _strip_accents(tgt_s) - - if op == DynamicRuleOperator.CONTAINS: - act_s = _normalize_str(actual) - tgt_s = _normalize_str(target_val) - return tgt_s in act_s or _strip_accents(tgt_s) in _strip_accents(act_s) - - if op == DynamicRuleOperator.IN: - act_s = _normalize_str(actual) - act_stripped = _strip_accents(act_s) - if isinstance(target_val, (list, tuple, set)): - return any( - _normalize_str(item) == act_s - or _strip_accents(_normalize_str(item)) == act_stripped - for item in target_val - ) - if isinstance(target_val, str): - items = [_normalize_str(x) for x in target_val.split(",") if x.strip()] - return any(item == act_s or _strip_accents(item) == act_stripped for item in items) - return False - - except (ValueError, TypeError): - return False - - return False - - -def evaluate_dynamic_rule( - rule: DynamicRule, - snapshot: CanonicalSnapshot, - features: NormalizedFeatures, -) -> RuleBreakdownItem: - """Evaluate a single dynamic rule and return a structured breakdown item.""" - if not rule.enabled: - return RuleBreakdownItem( - rule_name=rule.id, - status=RuleEvaluationStatus.NOT_MATCHED, - points_awarded=0, - detail=f"Quy tắc '{rule.name}' đang tạm tắt (disabled)", - ) - - present, actual_value = _extract_target_value(rule, snapshot, features) - - if not present or actual_value is None: - target_name = ( - f"custom_fields['{rule.custom_field_key}']" - if rule.target == DynamicRuleTarget.CUSTOM_FIELD - else rule.target.value - ) - return RuleBreakdownItem( - rule_name=rule.id, - status=RuleEvaluationStatus.SKIPPED_MISSING_DATA, - points_awarded=0, - detail=f"Thiếu dữ liệu trường '{target_name}' cho quy tắc '{rule.name}'", - ) - - matched = _compare_values(actual_value, rule.operator, rule.value) - sign = f"+{rule.points}" if rule.points >= 0 else str(rule.points) - - if matched: - return RuleBreakdownItem( - rule_name=rule.id, - status=RuleEvaluationStatus.MATCHED, - points_awarded=rule.points, - detail=( - f"Khớp quy tắc '{rule.name}': giá trị '{actual_value}' " - f"{rule.operator.value} '{rule.value}' ({sign})" - ), - ) - - return RuleBreakdownItem( - rule_name=rule.id, - status=RuleEvaluationStatus.NOT_MATCHED, - points_awarded=0, - detail=( - f"Không khớp quy tắc '{rule.name}': giá trị '{actual_value}' " - f"không {rule.operator.value} '{rule.value}' (0)" - ), - ) - - -def evaluate_dynamic_rules_collection( - rules: tuple[DynamicRule, ...] | list[DynamicRule], - snapshot: CanonicalSnapshot, - features: NormalizedFeatures, -) -> list[RuleBreakdownItem]: - """Evaluate all active dynamic rules in order.""" - results: list[RuleBreakdownItem] = [] - for rule in rules: - results.append(evaluate_dynamic_rule(rule, snapshot, features)) - return results diff --git a/old_repo/src/lead_scoring/execution.py b/old_repo/src/lead_scoring/execution.py deleted file mode 100644 index 9fc2266..0000000 --- a/old_repo/src/lead_scoring/execution.py +++ /dev/null @@ -1,632 +0,0 @@ -import asyncio -import logging -import time -import uuid -from collections.abc import Callable, Collection -from datetime import UTC, datetime, timedelta -from typing import Any - -from sqlalchemy import or_, update -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from sqlalchemy.sql.elements import ColumnElement - -from lead_scoring.aihub.client import ( - AIHubClientProtocol, - run_heavy_ai_pipeline, - run_light_ai_pipeline, - run_medium_ai_pipeline, -) -from lead_scoring.aihub.errors import AIHubError -from lead_scoring.db.models.outbox_event import OutboxEvent -from lead_scoring.db.models.scoring_run import ScoringRun -from lead_scoring.observability import Observability -from lead_scoring.schemas.scoring_run import ScoringRunStatus -from lead_scoring.schemas.snapshot import CanonicalSnapshot -from lead_scoring.schemas.webhook import ( - WEBHOOK_EVENT_TYPE, - WEBHOOK_EVENT_VERSION, - LeadScoringTerminalEvent, - TerminalRunData, -) -from lead_scoring.scoring import ( - EffortProfile, - FallbackPolicy, - ScoringConfig, - ScoringMode, - combine_confidence, - combine_final_score, - derive_outcome_fields, - evaluate_education_rules, - select_behavioral_metrics, -) - -Clock = Callable[[], datetime] -logger = logging.getLogger(__name__) - - -def utc_now() -> datetime: - return datetime.now(UTC) - - -async def execute_scoring_run( - session_factory: async_sessionmaker[AsyncSession], - run_id: str, - tenant_vkey: str | None, - lease_seconds: float, - *, - aihub_client: AIHubClientProtocol | None = None, - clock: Clock = utc_now, - observability: Observability | None = None, -) -> bool: - started_perf = time.perf_counter() - now = clock() - lease_expires_at = now + timedelta(seconds=lease_seconds) - async with session_factory() as session: - claim = ( - update(ScoringRun) - .where( - ScoringRun.id == run_id, - ScoringRun.status == ScoringRunStatus.PENDING.value, - ) - .values( - status=ScoringRunStatus.RUNNING.value, - started_at=now, - updated_at=now, - lease_expires_at=lease_expires_at, - ) - .returning( - ScoringRun.tenant_id, - ScoringRun.snapshot_data, - ScoringRun.effective_config, - ScoringRun.confidence_threshold, - ScoringRun.dotb_app, - ScoringRun.dotb_feature, - ) - ) - claimed = (await session.execute(claim)).one_or_none() - if claimed is None: - await session.rollback() - return False - await session.commit() - - snapshot = CanonicalSnapshot.model_validate(claimed.snapshot_data) - config = ScoringConfig.model_validate(claimed.effective_config) - threshold = ( - claimed.confidence_threshold - if claimed.confidence_threshold is not None - else config.confidence_threshold - ) - dotb_app = claimed.dotb_app - dotb_feature = claimed.dotb_feature - - rule_score, rule_breakdown, normalized_features, data_completeness = evaluate_education_rules( - snapshot, config - ) - ai_score: int | None = None - final_score: int | None = None - confidence: float | None = None - scoring_mode: str | None = None - degraded: bool | None = None - ai_metadata: dict[str, Any] | None = None - ai_trace_info: dict[str, Any] | None = None - error_code: str | None = None - retryable: bool | None = None - terminal_status: ScoringRunStatus - - if config.effort_profile in (EffortProfile.LIGHT, EffortProfile.MEDIUM, EffortProfile.HEAVY): - try: - if not tenant_vkey: - raise AIHubError( - "Tenant AIHub credential is missing", - error_code="aihub_vkey_missing", - retryable=False, - fallback_allowed=True, - detail="Tenant AIHub credential is missing", - ) - if aihub_client is None: - profile_val = config.effort_profile.value - raise AIHubError( - f"AIHub client is unconfigured for {profile_val} scoring effort", - error_code="aihub_client_unconfigured", - retryable=False, - fallback_allowed=True, - detail="AIHub client is unconfigured", - ) - behavioral_metrics = select_behavioral_metrics( - normalized_features, config.selected_behavioral_metrics - ) - customer_rules = config.dynamic_rules or config.rules - if config.effort_profile == EffortProfile.LIGHT: - ai_result = await run_light_ai_pipeline( - aihub_client, - snapshot, - vkey=tenant_vkey, - run_id=run_id, - model=config.ai_model, - timeout_seconds=config.ai_timeout_seconds, - app=dotb_app, - feature=dotb_feature, - customer_rules=customer_rules, - behavioral_metrics=behavioral_metrics, - ) - pass_count = 1 - scoring_mode = ScoringMode.LIGHT_AI.value - ai_trace_info = { - "model": config.ai_model, - "effort_profile": config.effort_profile.value, - "pass_count": pass_count, - "reasoning": ai_result.scoring.reasoning, - "positive_factors": list(ai_result.scoring.positive_factors), - "negative_factors": list(ai_result.scoring.negative_factors), - } - elif config.effort_profile == EffortProfile.MEDIUM: - ai_result = await run_medium_ai_pipeline( - aihub_client, - snapshot, - vkey=tenant_vkey, - run_id=run_id, - model=config.ai_model, - timeout_seconds=config.ai_timeout_seconds, - extract_prompt=config.medium_extract_prompt, - score_prompt=config.medium_score_prompt, - app=dotb_app, - feature=dotb_feature, - customer_rules=customer_rules, - behavioral_metrics=behavioral_metrics, - ) - pass_count = 2 - scoring_mode = ScoringMode.MEDIUM_AI.value - ai_trace_info = { - "model": config.ai_model, - "effort_profile": config.effort_profile.value, - "pass_count": pass_count, - "summary": ai_result.evidence.summary, - "intent": ai_result.evidence.intent, - "key_signals": list(ai_result.evidence.key_signals), - "reasoning": ai_result.scoring.reasoning, - "positive_factors": list(ai_result.scoring.positive_factors), - "negative_factors": list(ai_result.scoring.negative_factors), - } - else: # EffortProfile.HEAVY - ai_result = await run_heavy_ai_pipeline( - aihub_client, - snapshot, - vkey=tenant_vkey, - run_id=run_id, - model=config.ai_model, - timeout_seconds=config.ai_timeout_seconds, - app=dotb_app, - feature=dotb_feature, - customer_rules=customer_rules, - behavioral_metrics=behavioral_metrics, - ) - pass_count = 3 - scoring_mode = ScoringMode.HEAVY_AI.value - ai_trace_info = { - "model": config.ai_model, - "effort_profile": config.effort_profile.value, - "pass_count": pass_count, - "summary": ai_result.evidence.summary, - "intent": ai_result.evidence.intent, - "key_signals": list(ai_result.evidence.key_signals), - "reasoning": ai_result.scoring.reasoning, - "positive_factors": list(ai_result.scoring.positive_factors), - "negative_factors": list(ai_result.scoring.negative_factors), - "critique_summary": ai_result.critique.critique_summary, - } - - ai_score = ai_result.ai_score - final_score = combine_final_score( - rule_score, ai_score, config.rule_weight, config.ai_weight - ) - confidence = combine_confidence( - config.confidence, ai_result.confidence, config.rule_weight, config.ai_weight - ) - degraded = False - ai_metadata = { - "model": config.ai_model, - "effort_profile": config.effort_profile.value, - "pass_count": pass_count, - } - terminal_status = ScoringRunStatus.COMPLETED - except asyncio.CancelledError: - raise - except AIHubError as exc: - # Partial AI results must be discarded - logger.warning( - "AIHub %s pipeline failed for run %s: %s (fallback_policy=%s, fallback_allowed=%s)", - config.effort_profile.value, - run_id, - exc.error_code, - config.fallback_policy.value, - exc.fallback_allowed, - ) - if observability is not None: - observability.increment( - "aihub_failures_total", - run_id=run_id, - tags={"error_code": exc.error_code}, - ) - ai_detail = (exc.detail or "AIHub request failed")[:255] - ai_trace_info = { - "error_code": exc.error_code, - "detail": ai_detail, - "fallback_policy": config.fallback_policy.value, - } - if config.fallback_policy == FallbackPolicy.FAIL_RUN or not exc.fallback_allowed: - terminal_status = ScoringRunStatus.FAILED - rule_score = None - ai_score = None - final_score = None - confidence = None - scoring_mode = None - degraded = True - error_code = exc.error_code - retryable = exc.retryable - ai_metadata = { - "error_code": exc.error_code, - "detail": ai_detail, - "fallback_policy": config.fallback_policy.value, - } - else: - # Default fallback: use rule score, degraded=true, status completed - terminal_status = ScoringRunStatus.COMPLETED - error_code = exc.error_code - retryable = exc.retryable - ai_score = None - final_score = rule_score - confidence = config.confidence - scoring_mode = ScoringMode.RULES_ONLY_FALLBACK.value - degraded = True - ai_metadata = { - "error_code": exc.error_code, - "detail": ai_detail, - "fallback_policy": config.fallback_policy.value, - } - except Exception: - # Never expose str(exc), traceback, exc_info or secret content - canned_error = "aihub_unknown_error" - canned_detail = "Unexpected internal execution error" - logger.error("Scoring execution error for run %s: %s", run_id, canned_error) - if observability is not None: - observability.increment( - "aihub_failures_total", - run_id=run_id, - tags={"error_code": canned_error}, - ) - ai_trace_info = { - "error_code": canned_error, - "detail": canned_detail, - "fallback_policy": config.fallback_policy.value, - } - if config.fallback_policy == FallbackPolicy.FAIL_RUN: - terminal_status = ScoringRunStatus.FAILED - rule_score = None - ai_score = None - final_score = None - confidence = None - scoring_mode = None - degraded = True - error_code = canned_error - retryable = False - ai_metadata = { - "error_code": canned_error, - "detail": canned_detail, - "fallback_policy": config.fallback_policy.value, - } - else: - terminal_status = ScoringRunStatus.COMPLETED - error_code = canned_error - retryable = False - ai_score = None - final_score = rule_score - confidence = config.confidence - scoring_mode = ScoringMode.RULES_ONLY_FALLBACK.value - degraded = True - ai_metadata = { - "error_code": canned_error, - "detail": canned_detail, - "fallback_policy": config.fallback_policy.value, - } - else: - # Rule-only path - confidence = config.confidence - terminal_status = ScoringRunStatus.COMPLETED - final_score = rule_score - scoring_mode = ScoringMode.RULE_ONLY.value - degraded = False - ai_trace_info = None - - completed_at = clock() - - effective_score = final_score if terminal_status == ScoringRunStatus.COMPLETED else None - - formula_str = ( - f"round({rule_score} * {config.rule_weight} + {ai_score} * {config.ai_weight})" - f" = {final_score}" - if ai_score is not None and rule_score is not None - else (f"rule_score = {rule_score}" if rule_score is not None else "failed") - ) - calculation_trace = { - "base_score": config.base_score, - "rule_score": rule_score, - "ai_score": ai_score, - "rule_weight": config.rule_weight, - "ai_weight": config.ai_weight, - "final_score": final_score, - "confidence": confidence, - "confidence_threshold": threshold, - "completeness_threshold": config.completeness_threshold, - "effective_score": effective_score, - "formula": formula_str, - } - - if terminal_status == ScoringRunStatus.COMPLETED: - if degraded: - terminal_status_reason = ( - f"Đã hoàn tất (dự phòng): AI gặp lỗi ({error_code or 'degraded'}), " - "đã dùng điểm rule" - ) - else: - terminal_status_reason = "Đã hoàn tất" - elif terminal_status == ScoringRunStatus.FAILED: - terminal_status_reason = f"Chạy thất bại với mã lỗi: {error_code or 'unknown'}" - else: - terminal_status_reason = f"Trạng thái cuối: {terminal_status.value}" - - execution_trace = { - "run_id": run_id, - "tenant_id": claimed.tenant_id, - "status": terminal_status.value, - "normalized_features": normalized_features.model_dump(mode="json"), - "rule_breakdown": [r.model_dump(mode="json") for r in rule_breakdown], - "data_completeness": data_completeness.model_dump(mode="json"), - "ai_evaluation": ai_trace_info, - "calculation": calculation_trace, - "terminal_status_reason": terminal_status_reason, - } - - async with session_factory() as session: - terminal = ( - update(ScoringRun) - .where( - ScoringRun.id == run_id, - ScoringRun.status == ScoringRunStatus.RUNNING.value, - ) - .values( - status=terminal_status.value, - rule_score=rule_score, - ai_score=ai_score, - final_score=final_score, - effective_score=effective_score, - scoring_mode=scoring_mode, - degraded=degraded, - ai_metadata=ai_metadata, - confidence=confidence, - error_code=error_code, - retryable=retryable, - execution_trace=execution_trace, - completed_at=completed_at, - updated_at=completed_at, - lease_expires_at=None, - ) - .returning(ScoringRun) - ) - completed_run = (await session.execute(terminal)).scalar_one_or_none() - if completed_run is None: - await session.rollback() - return False - - session.add(_terminal_event(completed_run, terminal_status, completed_at)) - await session.commit() - - if observability is not None: - observability.observe( - "scoring_run_latency_seconds", - time.perf_counter() - started_perf, - run_id=run_id, - tags={"status": terminal_status.value}, - ) - observability.increment("scoring_runs_terminal_total", run_id=run_id) - observability.observe("scoring_degraded_rate", 1.0 if degraded else 0.0, run_id=run_id) - if degraded: - observability.increment( - "scoring_degraded_total", - run_id=run_id, - tags={"error_code": error_code or "aihub_failure"}, - ) - if terminal_status == ScoringRunStatus.FAILED: - observability.increment( - "scoring_failed_total", - run_id=run_id, - tags={"error_code": error_code or "unknown"}, - ) - return True - - -async def recover_interrupted_runs( - session_factory: async_sessionmaker[AsyncSession], - *, - clock: Clock = utc_now, - run_ids: Collection[str] | None = None, - observability: Observability | None = None, -) -> int: - now = clock() - conditions: list[ColumnElement[bool]] = [ - ScoringRun.status.in_([ScoringRunStatus.PENDING.value, ScoringRunStatus.RUNNING.value]) - ] - if run_ids is None: - conditions.append( - or_(ScoringRun.lease_expires_at.is_(None), ScoringRun.lease_expires_at <= now) - ) - else: - if not run_ids: - return 0 - conditions.append(ScoringRun.id.in_(run_ids)) - - async with session_factory() as session: - interrupted = ( - update(ScoringRun) - .where(*conditions) - .values( - status=ScoringRunStatus.FAILED.value, - error_code="worker_interrupted", - retryable=True, - completed_at=now, - updated_at=now, - lease_expires_at=None, - ) - .returning(ScoringRun) - ) - runs = list((await session.execute(interrupted)).scalars()) - for run in runs: - session.add(_terminal_event(run, ScoringRunStatus.FAILED, now)) - await session.commit() - if observability is not None and runs: - observability.increment( - "scoring_stuck_runs_total", - amount=len(runs), - fields={"recovered_count": len(runs)}, - ) - return len(runs) - - -def _terminal_event( - run: ScoringRun, status: ScoringRunStatus, occurred_at: datetime -) -> OutboxEvent: - ai_reasoning: str | None = None - if run.execution_trace and isinstance(run.execution_trace, dict): - ai_eval = run.execution_trace.get("ai_evaluation") - if isinstance(ai_eval, dict): - ai_reasoning = ai_eval.get("reasoning") - - category, explanation, recommended_action = derive_outcome_fields( - status=status.value, - effective_score=run.effective_score, - final_score=run.final_score, - rule_score=run.rule_score, - ai_reasoning=ai_reasoning, - error_code=run.error_code, - ) - - event = LeadScoringTerminalEvent( - event_id=uuid.uuid4(), - event_type=WEBHOOK_EVENT_TYPE, - event_version=WEBHOOK_EVENT_VERSION, - tenant_id=run.tenant_id, - subject_ref=run.subject_ref, - scoring_config_version=run.scoring_config_version, - run_id=uuid.UUID(run.id), - run_created_at=run.created_at, - occurred_at=occurred_at, - data=TerminalRunData.model_validate( - { - "status": status.value, - "rule_score": run.rule_score, - "ai_score": run.ai_score, - "final_score": run.final_score, - "effective_score": run.effective_score, - "confidence": run.confidence, - "category": category, - "explanation": explanation, - "recommended_action": recommended_action, - "scoring_mode": run.scoring_mode, - "degraded": run.degraded, - "error_code": run.error_code, - "retryable": run.retryable, - } - ), - ) - return OutboxEvent( - id=str(event.event_id), - event_type=event.event_type, - event_version=event.event_version, - run_id=run.id, - occurred_at=occurred_at, - payload=event.model_dump(mode="json"), - ) - - -class ExecutionRuntime: - def __init__( - self, - session_factory: async_sessionmaker[AsyncSession], - lease_seconds: float, - recovery_interval_seconds: float, - *, - aihub_client: AIHubClientProtocol | None = None, - clock: Clock = utc_now, - observability: Observability | None = None, - ) -> None: - self._session_factory = session_factory - self._lease_seconds = lease_seconds - self._recovery_interval_seconds = recovery_interval_seconds - self._aihub_client = aihub_client - self._clock = clock - self._observability = observability - self._tasks: dict[str, asyncio.Task[bool]] = {} - self._recovery_task: asyncio.Task[None] | None = None - - def start(self) -> None: - self._recovery_task = asyncio.create_task(self._recovery_loop()) - - def schedule(self, run_id: str, tenant_vkey: str | None) -> bool: - if run_id in self._tasks: - return False - execution = execute_scoring_run( - self._session_factory, - run_id, - tenant_vkey, - self._lease_seconds, - aihub_client=self._aihub_client, - clock=self._clock, - observability=self._observability, - ) - try: - task = asyncio.create_task(execution) - except RuntimeError: - execution.close() - return False - self._tasks[run_id] = task - task.add_done_callback( - lambda completed_task, current_run_id=run_id: self._task_done( - current_run_id, completed_task - ) - ) - return True - - async def stop(self) -> None: - if self._recovery_task is not None: - self._recovery_task.cancel() - await asyncio.gather(self._recovery_task, return_exceptions=True) - - interrupted_ids = list(self._tasks) - for task in self._tasks.values(): - task.cancel() - if self._tasks: - await asyncio.gather(*self._tasks.values(), return_exceptions=True) - await recover_interrupted_runs( - self._session_factory, - clock=self._clock, - run_ids=interrupted_ids, - observability=self._observability, - ) - - def _task_done(self, run_id: str, task: asyncio.Task[bool]) -> None: - self._tasks.pop(run_id, None) - if not task.cancelled(): - exc = task.exception() - if exc is not None: - logger.error("Scoring task for run %s raised unhandled exception", run_id) - - async def _recovery_loop(self) -> None: - while True: - try: - await recover_interrupted_runs( - self._session_factory, - clock=self._clock, - observability=self._observability, - ) - except Exception: - logger.exception("Scoring run recovery failed") - await asyncio.sleep(self._recovery_interval_seconds) diff --git a/old_repo/src/lead_scoring/main.py b/old_repo/src/lead_scoring/main.py deleted file mode 100644 index 7310abf..0000000 --- a/old_repo/src/lead_scoring/main.py +++ /dev/null @@ -1,286 +0,0 @@ -import time -import uuid -from collections.abc import AsyncIterator, Awaitable, Callable -from contextlib import asynccontextmanager - -import httpx -from fastapi import FastAPI, Request, Response, status -from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse -from starlette.exceptions import HTTPException as StarletteHTTPException - -from lead_scoring.aihub.client import AIHubClient, AIHubClientProtocol -from lead_scoring.api.v1.scoring_runs import router as scoring_runs_router -from lead_scoring.auth import match_service_token -from lead_scoring.config import Settings -from lead_scoring.db.session import ( - check_db_health, - create_engine_from_url, - create_session_factory, -) -from lead_scoring.execution import ExecutionRuntime -from lead_scoring.observability import Observability -from lead_scoring.retention import RetentionWorker -from lead_scoring.schemas.errors import ErrorResponse -from lead_scoring.schemas.health import HealthResponse -from lead_scoring.webhook.dispatcher import WebhookDispatcher - -STATUS_CODE_TO_ERROR_CODE: dict[int, str] = { - status.HTTP_400_BAD_REQUEST: "bad_request", - status.HTTP_401_UNAUTHORIZED: "invalid_token", - status.HTTP_403_FORBIDDEN: "forbidden", - status.HTTP_404_NOT_FOUND: "not_found", - status.HTTP_409_CONFLICT: "idempotency_payload_mismatch", - status.HTTP_422_UNPROCESSABLE_CONTENT: "validation_error", - status.HTTP_503_SERVICE_UNAVAILABLE: "service_unavailable", -} - - -def create_app( - settings: Settings | None = None, - aihub_client: AIHubClientProtocol | None = None, - webhook_http_client: httpx.AsyncClient | None = None, - observability: Observability | None = None, -) -> FastAPI: - app_settings = settings or Settings() - app_observability = observability or Observability(app_settings.alert_thresholds()) - - @asynccontextmanager - async def lifespan(app: FastAPI) -> AsyncIterator[None]: - app.state.settings = app_settings - engine = None - try: - engine = create_engine_from_url( - app_settings.database_url, - echo=app_settings.debug, - ) - session_factory = create_session_factory(engine) - app.state.engine = engine - app.state.session_factory = session_factory - resolved_aihub_client = ( - aihub_client - if aihub_client is not None - else AIHubClient( - base_url=app_settings.aihub_base_url, - observability=app_observability, - ) - ) - app.state.aihub_client = resolved_aihub_client - runtime = ExecutionRuntime( - session_factory, - app_settings.worker_lease_seconds, - app_settings.worker_recovery_interval_seconds, - aihub_client=resolved_aihub_client, - observability=app_observability, - ) - app.state.execution_runtime = runtime - runtime.start() - - dispatcher = WebhookDispatcher( - session_factory, - app_settings, - http_client=webhook_http_client, - observability=app_observability, - ) - app.state.webhook_dispatcher = dispatcher - dispatcher.start() - - retention_worker = RetentionWorker( - session_factory, - app_settings, - observability=app_observability, - ) - app.state.retention_worker = retention_worker - retention_worker.start() - - yield - finally: - retention_worker = getattr(app.state, "retention_worker", None) - if retention_worker is not None: - await retention_worker.stop() - dispatcher = getattr(app.state, "webhook_dispatcher", None) - if dispatcher is not None: - await dispatcher.stop() - runtime = getattr(app.state, "execution_runtime", None) - if runtime is not None: - await runtime.stop() - if engine is not None: - await engine.dispose() - - app = FastAPI( - title="Lead Scoring Service", - version=app_settings.app_version, - description="Lead Scoring service for AI Platform", - lifespan=lifespan, - ) - app.state.settings = app_settings - app.state.telemetry = app_observability - - @app.middleware("http") - async def service_auth_middleware( - request: Request, call_next: Callable[[Request], Awaitable[Response]] - ) -> Response: - if request.url.path.startswith("/v1/"): - auth_header = request.headers.get("authorization") - if not auth_header: - return JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content=ErrorResponse( - error_code="invalid_token", - detail="Invalid or missing service token", - ).model_dump(), - headers={"WWW-Authenticate": "Bearer"}, - ) - - parts = auth_header.strip().split(" ", 1) - if len(parts) != 2 or parts[0].casefold() != "bearer" or not parts[1].strip(): - return JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content=ErrorResponse( - error_code="invalid_token", - detail="Invalid or missing service token", - ).model_dump(), - headers={"WWW-Authenticate": "Bearer"}, - ) - - token = parts[1].strip() - current_settings = getattr(request.app.state, "settings", app_settings) - caller = match_service_token(token, current_settings.service_callers) - if caller is None: - return JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content=ErrorResponse( - error_code="invalid_token", - detail="Invalid or missing service token", - ).model_dump(), - headers={"WWW-Authenticate": "Bearer"}, - ) - - request.state.caller = caller - - return await call_next(request) - - @app.middleware("http") - async def correlation_and_metrics_middleware( - request: Request, call_next: Callable[[Request], Awaitable[Response]] - ) -> Response: - correlation_id = request.headers.get("x-request-id") - if ( - correlation_id is None - or len(correlation_id) > 128 - or any(char in correlation_id for char in "\r\n") - ): - correlation_id = str(uuid.uuid4()) - else: - try: - correlation_id = str(uuid.UUID(correlation_id)) - except ValueError: - correlation_id = str(uuid.uuid5(uuid.NAMESPACE_URL, correlation_id)) - request.state.correlation_id = correlation_id - started_at = time.perf_counter() - response: Response | None = None - try: - response = await call_next(request) - return response - finally: - if request.method == "POST" and request.url.path == "/v1/scoring-runs": - app_observability.observe( - "scoring_admission_latency_seconds", - time.perf_counter() - started_at, - run_id=correlation_id, - ) - app_observability.increment( - "scoring_admission_total", - run_id=correlation_id, - tags={"status": response.status_code if response else 500}, - ) - if response is not None: - response.headers["X-Request-ID"] = correlation_id - - @app.exception_handler(StarletteHTTPException) - async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: - error_code = STATUS_CODE_TO_ERROR_CODE.get(exc.status_code, "http_error") - detail = str(exc.detail) - if isinstance(exc.detail, dict): - error_code = str(exc.detail.get("error_code", error_code)) - detail = str(exc.detail.get("detail", detail)) - - headers = getattr(exc, "headers", None) or {} - return JSONResponse( - status_code=exc.status_code, - content=ErrorResponse(error_code=error_code, detail=detail).model_dump(), - headers=headers, - ) - - @app.exception_handler(RequestValidationError) - async def validation_exception_handler( - request: Request, exc: RequestValidationError - ) -> JSONResponse: - errors = exc.errors() - messages: list[str] = [] - for err in errors: - loc = " -> ".join(str(item) for item in err.get("loc", [])) - msg = err.get("msg", "Invalid value") - messages.append(f"{loc}: {msg}" if loc else msg) - - detail_str = "; ".join(messages) if messages else "Request validation failed" - return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - content=ErrorResponse( - error_code="validation_error", - detail=detail_str, - ).model_dump(), - ) - - app.include_router(scoring_runs_router) - - @app.get( - "/health", - response_model=HealthResponse, - responses={ - status.HTTP_200_OK: { - "model": HealthResponse, - "description": "Service and database healthy", - }, - status.HTTP_503_SERVICE_UNAVAILABLE: { - "model": HealthResponse, - "description": "Database or critical dependency unreachable or unconfigured", - }, - }, - tags=["System"], - summary="Service Health Check", - ) - async def health_check(request: Request, response: Response) -> HealthResponse: - settings = getattr(request.app.state, "settings", app_settings) - engine = getattr(request.app.state, "engine", None) - if engine is None: - response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE - return HealthResponse( - status="unhealthy", - version=settings.app_version, - database="unconfigured", - ) - - try: - is_healthy = await check_db_health(engine, timeout=settings.db_health_timeout_seconds) - except Exception: - is_healthy = False - - if not is_healthy: - response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE - return HealthResponse( - status="unhealthy", - version=settings.app_version, - database="unreachable", - ) - - return HealthResponse( - status="ok", - version=settings.app_version, - database="connected", - ) - - return app - - -app = create_app() diff --git a/old_repo/src/lead_scoring/observability.py b/old_repo/src/lead_scoring/observability.py deleted file mode 100644 index 16eef2c..0000000 --- a/old_repo/src/lead_scoring/observability.py +++ /dev/null @@ -1,233 +0,0 @@ -from __future__ import annotations - -import logging -import re -from collections.abc import Mapping, Sequence -from datetime import UTC, datetime -from typing import Any - -_REDACTED = "[REDACTED]" -_SECRET_PATTERN = re.compile(r"(?i)(bearer\s+)[^\s,;]+") -_EMAIL_PATTERN = re.compile(r"\b[\w.+-]+@[\w.-]+\.\w+\b") -_PHONE_PATTERN = re.compile(r"(? str: - """Remove credentials and bearer values from operator-visible text.""" - redacted = _SECRET_PATTERN.sub(r"\1" + _REDACTED, text) - redacted = _EMAIL_PATTERN.sub(_REDACTED, redacted) - redacted = _PHONE_PATTERN.sub(_REDACTED, redacted) - for secret in secrets: - if secret: - redacted = redacted.replace(secret, _REDACTED) - return redacted - - -def _is_sensitive_key(key: object) -> bool: - key_text = str(key).casefold().replace("-", "_") - if key_text in _SENSITIVE_KEY_NAMES: - return True - if key_text.endswith(_CREDENTIAL_KEY_SUFFIXES): - return True - return key_text.startswith(_PII_KEY_PREFIXES) and key_text.endswith(_PII_KEY_SUFFIXES) - - -def redact_payload(value: Any, secrets: Sequence[str] = ()) -> Any: - """Return a JSON-like value safe for logs, traces, and exception reports.""" - if isinstance(value, Mapping): - return { - key: _REDACTED if _is_sensitive_key(key) else redact_payload(item, secrets) - for key, item in value.items() - } - if isinstance(value, (list, tuple)): - return [redact_payload(item, secrets) for item in value] - if isinstance(value, set): - return [redact_payload(item, secrets) for item in sorted(value, key=str)] - if isinstance(value, str): - return redact_text(value, secrets) - if isinstance(value, (int, float, bool)) or value is None: - return value - model_dump = getattr(value, "model_dump", None) - if callable(model_dump): - return redact_payload(model_dump(mode="json"), secrets) - return redact_text(str(value), secrets) - - -class Observability: - """Small in-process telemetry sink for the pilot and operational drills.""" - - def __init__(self, alert_thresholds: Mapping[str, int] | None = None) -> None: - self._counters: dict[str, int] = {} - self._observations: dict[str, list[float]] = {} - self._events: list[dict[str, Any]] = [] - self._alerts: list[dict[str, Any]] = [] - self._alerted_metrics: set[str] = set() - self._alert_thresholds = { - metric: int(threshold) - for metric, threshold in (alert_thresholds or {}).items() - if int(threshold) > 0 - } - - def increment( - self, - metric: str, - amount: int = 1, - *, - run_id: str | None = None, - fields: Mapping[str, Any] | None = None, - tags: Mapping[str, Any] | None = None, - ) -> None: - self._counters[metric] = self._counters.get(metric, 0) + amount - self.record( - f"metric.{metric}", - run_id=run_id, - fields={"amount": amount, **(fields or {})}, - tags=tags, - ) - threshold = self._alert_thresholds.get(metric) - if ( - threshold is not None - and self._counters[metric] >= threshold - and metric not in self._alerted_metrics - ): - self._alerted_metrics.add(metric) - alert = { - "metric": metric, - "value": self._counters[metric], - "threshold": threshold, - "recorded_at": datetime.now(UTC).isoformat(), - } - self._alerts.append(alert) - logger.warning( - "Telemetry alert metric=%s value=%s threshold=%s", - metric, - alert["value"], - threshold, - ) - - def observe( - self, - metric: str, - value: float, - *, - run_id: str | None = None, - fields: Mapping[str, Any] | None = None, - tags: Mapping[str, Any] | None = None, - ) -> None: - self._observations.setdefault(metric, []).append(float(value)) - self.record( - f"metric.{metric}", - run_id=run_id, - fields={"value": value, **(fields or {})}, - tags=tags, - ) - - def record( - self, - event: str, - *, - run_id: str | None = None, - fields: Mapping[str, Any] | None = None, - tags: Mapping[str, Any] | None = None, - secrets: Sequence[str] = (), - ) -> None: - safe_run_id = redact_text(run_id, secrets) if run_id else None - safe_fields = redact_payload(fields or {}, secrets) - safe_tags = redact_payload(tags or {}, secrets) - self._events.append( - { - "event": event, - "run_id": safe_run_id, - "fields": safe_fields, - "tags": safe_tags, - "recorded_at": datetime.now(UTC).isoformat(), - } - ) - logger.debug( - "Telemetry event=%s run_id=%s fields=%s tags=%s", - event, - safe_run_id, - safe_fields, - safe_tags, - ) - - def record_exception( - self, - event: str, - exception: BaseException, - *, - run_id: str | None = None, - secrets: Sequence[str] = (), - fields: Mapping[str, Any] | None = None, - ) -> None: - # Exception messages can contain request bodies or credentials. Keep only the type. - self.record( - f"exception.{event}", - run_id=run_id, - fields={"exception_type": type(exception).__name__, **(fields or {})}, - secrets=secrets, - ) - - def snapshot(self) -> dict[str, Any]: - histograms: dict[str, dict[str, float | int]] = {} - for metric, values in self._observations.items(): - histograms[metric] = { - "count": len(values), - "sum": sum(values), - "last": values[-1], - "average": sum(values) / len(values), - } - return { - "counters": dict(self._counters), - "histograms": histograms, - "events": [dict(event) for event in self._events], - "alerts": [dict(alert) for alert in self._alerts], - } - - -DEFAULT_ALERT_THRESHOLDS = { - "scoring_stuck_runs_total": 1, - "aihub_failures_total": 3, - "outbox_dead_letter_total": 1, - "retention_failures_total": 1, -} - - -__all__ = [ - "DEFAULT_ALERT_THRESHOLDS", - "Observability", - "redact_payload", - "redact_text", -] diff --git a/old_repo/src/lead_scoring/retention.py b/old_repo/src/lead_scoring/retention.py deleted file mode 100644 index 60891b5..0000000 --- a/old_repo/src/lead_scoring/retention.py +++ /dev/null @@ -1,371 +0,0 @@ -import asyncio -import logging -from collections.abc import Callable -from dataclasses import dataclass -from datetime import UTC, datetime, timedelta -from typing import Any, cast - -from sqlalchemy import delete, func, or_, select -from sqlalchemy.engine import CursorResult -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker - -from lead_scoring.config import Settings -from lead_scoring.db.models.outbox_event import OutboxEvent -from lead_scoring.db.models.review_audit import ReviewAudit -from lead_scoring.db.models.scoring_run import ScoringRun -from lead_scoring.observability import Observability - -Clock = Callable[[], datetime] -logger = logging.getLogger(__name__) - -RETENTION_DEFAULT_DAYS = 90 - -ALLOWED_EFFECTIVE_CONFIG_KEYS = { - "version", - "base_score", - "rules", - "dynamic_rules", - "rule_weight", - "ai_weight", - "confidence", - "confidence_threshold", - "effort_profile", - "ai_model", - "ai_timeout_seconds", - "fallback_policy", - "hitl_policy_version", - "medium_extract_prompt", - "medium_score_prompt", - "education_demo_enabled", - "age_primary_min", - "age_primary_max", - "age_primary_points", - "age_secondary_min", - "age_secondary_max", - "age_secondary_points", - "recency_hot_max_days", - "recency_hot_points", - "recency_warm_max_days", - "recency_warm_points", - "recency_cool_max_days", - "recency_cool_points", - "recency_cold_max_days", - "recency_cold_points", - "source_referral_points", - "source_call_in_points", - "source_digital_points", - "source_campaign_points", - "source_other_points", - "completeness_threshold", -} - - -def utc_now() -> datetime: - return datetime.now(UTC) - - -def calculate_retention_cutoff( - now: datetime, retention_days: int = RETENTION_DEFAULT_DAYS -) -> datetime: - return now - timedelta(days=retention_days) - - -@dataclass(frozen=True) -class RetentionCleanupResult: - runs_redacted: int - audits_redacted: int - outbox_events_cleaned: int - cutoff: datetime - outbox_events_redacted: int = 0 - - -@dataclass(frozen=True) -class TenantPurgeResult: - tenant_id: str - runs_deleted: int - audits_deleted: int - outbox_events_deleted: int - purged_at: datetime - - -async def execute_retention_cleanup( - session_factory: async_sessionmaker[AsyncSession], - *, - cutoff: datetime | None = None, - retention_days: int = RETENTION_DEFAULT_DAYS, - clock: Clock = utc_now, - batch_size: int = 100, - observability: Observability | None = None, -) -> RetentionCleanupResult: - now = clock() - cutoff_dt = cutoff if cutoff is not None else calculate_retention_cutoff(now, retention_days) - - try: - async with session_factory() as session: - # 1. Redact a single batch of scoring_runs older than cutoff - candidate_runs_stmt = ( - select(ScoringRun) - .where( - ScoringRun.created_at <= cutoff_dt, - ScoringRun.redacted_at.is_(None), - ) - .limit(batch_size) - ) - runs = (await session.execute(candidate_runs_stmt)).scalars().all() - for run in runs: - run.snapshot_data = {} - run.payload_hash = "redacted" - run.reviewed_by = None - run.review_note = None - run.execution_trace = None - - # Sanitize ai_metadata: strictly allow only structured keys and redact detail - if run.ai_metadata is not None: - if "error_code" in run.ai_metadata: - run.ai_metadata = { - "error_code": str(run.ai_metadata["error_code"]), - "detail": "redacted", - "fallback_policy": str( - run.ai_metadata.get("fallback_policy", "rules_only_review") - ), - } - elif "model" in run.ai_metadata: - model = run.ai_metadata.get("model") - effort_profile = run.ai_metadata.get("effort_profile", "medium") - pass_count = run.ai_metadata.get("pass_count", 1) - run.ai_metadata = { - "model": str(model) if model else "redacted", - "effort_profile": str(effort_profile) if effort_profile else "medium", - "pass_count": pass_count - if isinstance(pass_count, int) - and not isinstance(pass_count, bool) - and pass_count >= 1 - else 1, - } - else: - run.ai_metadata = {} - - # Sanitize effective_config: retain only allowed structured configuration keys - if run.effective_config is not None: - run.effective_config = { - k: v - for k, v in run.effective_config.items() - if k in ALLOWED_EFFECTIVE_CONFIG_KEYS - } - - run.redacted_at = now - - runs_redacted = len(runs) - - # 2. Redact a single batch of review_audits older than cutoff - candidate_audits_stmt = ( - select(ReviewAudit) - .where( - ReviewAudit.created_at <= cutoff_dt, - ReviewAudit.redacted_at.is_(None), - ) - .limit(batch_size) - ) - audits = (await session.execute(candidate_audits_stmt)).scalars().all() - for audit in audits: - audit.actor_id = "redacted" - audit.note = None - audit.payload_hash = "redacted" - audit.redacted_at = now - - audits_redacted = len(audits) - - # 3. Clean a single batch of terminal outbox events older than cutoff - # Pending/delivering events are strictly preserved for delivery guarantees - candidate_events_stmt = ( - select(OutboxEvent.id) - .where( - OutboxEvent.occurred_at <= cutoff_dt, - OutboxEvent.delivery_status.in_(["delivered", "dead_letter"]), - ) - .limit(batch_size) - ) - event_ids = (await session.execute(candidate_events_stmt)).scalars().all() - if event_ids: - outbox_delete_stmt = delete(OutboxEvent).where(OutboxEvent.id.in_(event_ids)) - await session.execute(outbox_delete_stmt) - - outbox_events_cleaned = len(event_ids) - - # 4. Redact reviewer identity from pending/delivering reviewed events older than cutoff - pending_events_stmt = ( - select(OutboxEvent) - .where( - OutboxEvent.occurred_at <= cutoff_dt, - OutboxEvent.delivery_status.in_(["pending", "delivering"]), - OutboxEvent.event_type == "lead_scoring.run.reviewed", - ) - .limit(batch_size) - ) - pending_events = (await session.execute(pending_events_stmt)).scalars().all() - outbox_events_redacted = 0 - for ev in pending_events: - if isinstance(ev.payload, dict) and "data" in ev.payload: - data_obj = ev.payload.get("data") - if ( - isinstance(data_obj, dict) - and "reviewed_by" in data_obj - and data_obj["reviewed_by"] != "redacted" - ): - new_payload = dict(ev.payload) - new_data = dict(data_obj) - new_data["reviewed_by"] = "redacted" - new_payload["data"] = new_data - ev.payload = new_payload - outbox_events_redacted += 1 - - await session.commit() - - if observability is not None: - observability.increment( - "retention_redactions_total", - amount=runs_redacted + audits_redacted + outbox_events_redacted, - fields={"runs": runs_redacted, "audits": audits_redacted}, - ) - observability.increment( - "retention_events_cleaned_total", - amount=outbox_events_cleaned, - ) - - return RetentionCleanupResult( - runs_redacted=runs_redacted, - audits_redacted=audits_redacted, - outbox_events_cleaned=outbox_events_cleaned, - outbox_events_redacted=outbox_events_redacted, - cutoff=cutoff_dt, - ) - except Exception as exc: - # Sanitize log output: never include exc details that could contain raw payloads or secrets - logger.error("Retention cleanup failed for cutoff %s", cutoff_dt.isoformat()) - if observability is not None: - observability.increment("retention_failures_total") - observability.record_exception( - "retention_cleanup", - exc, - fields={"cutoff": cutoff_dt.isoformat()}, - ) - raise - - -async def purge_tenant_data( - session_factory: async_sessionmaker[AsyncSession], - tenant_id: str, - *, - clock: Clock = utc_now, - observability: Observability | None = None, -) -> TenantPurgeResult: - now = clock() - try: - async with session_factory() as session: - # Serializable isolation prevents concurrent scoring writes from escaping - # the tenant-wide purge transaction. - await session.connection(execution_options={"isolation_level": "SERIALIZABLE"}) - - # Delete events using a tenant/run subquery, avoiding an unbounded ID list. - tenant_run_ids = select(ScoringRun.id).where(ScoringRun.tenant_id == tenant_id) - outbox_conditions = [OutboxEvent.payload.op("->>")("tenant_id") == tenant_id] - outbox_conditions.append(OutboxEvent.run_id.in_(tenant_run_ids)) - outbox_delete_stmt = delete(OutboxEvent).where(or_(*outbox_conditions)) - outbox_res = await session.execute(outbox_delete_stmt) - outbox_events_deleted = cast(CursorResult[Any], outbox_res).rowcount or 0 - - # 3. Delete all review audits for the tenant. - audits_delete_stmt = delete(ReviewAudit).where(ReviewAudit.tenant_id == tenant_id) - audits_res = await session.execute(audits_delete_stmt) - audits_deleted = cast(CursorResult[Any], audits_res).rowcount or 0 - - runs_count_stmt = ( - select(func.count()) - .select_from(ScoringRun) - .where(ScoringRun.tenant_id == tenant_id) - ) - runs_deleted = int((await session.scalar(runs_count_stmt)) or 0) - await session.execute(delete(ScoringRun).where(ScoringRun.tenant_id == tenant_id)) - - await session.commit() - - return TenantPurgeResult( - tenant_id=tenant_id, - runs_deleted=runs_deleted, - audits_deleted=audits_deleted, - outbox_events_deleted=outbox_events_deleted, - purged_at=now, - ) - except Exception as exc: - # Sanitize log output - logger.error("Tenant data purge failed for tenant %s", tenant_id) - if observability is not None: - observability.increment("retention_failures_total") - observability.record_exception( - "tenant_purge", - exc, - fields={"tenant_ref": tenant_id}, - ) - raise - - -class RetentionWorker: - def __init__( - self, - session_factory: async_sessionmaker[AsyncSession], - settings: Settings, - *, - clock: Clock = utc_now, - observability: Observability | None = None, - ) -> None: - self._session_factory = session_factory - self._settings = settings - self._clock = clock - self._observability = observability - self._task: asyncio.Task[None] | None = None - self._running = False - - def start(self) -> None: - if self._task is None or self._task.done(): - self._running = True - self._task = asyncio.create_task(self._run_loop()) - - async def stop(self) -> None: - self._running = False - if self._task is not None: - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - self._task = None - - async def _run_loop(self) -> None: - while self._running: - try: - while self._running: - result = await execute_retention_cleanup( - self._session_factory, - retention_days=self._settings.retention_days, - clock=self._clock, - batch_size=self._settings.retention_batch_size, - observability=self._observability, - ) - # Drain until all batches for current run are processed - if ( - result.runs_redacted < self._settings.retention_batch_size - and result.audits_redacted < self._settings.retention_batch_size - and result.outbox_events_cleaned < self._settings.retention_batch_size - and result.outbox_events_redacted < self._settings.retention_batch_size - ): - break - except asyncio.CancelledError: - break - except Exception: - # Log sanitized failure without secrets - logger.error("Periodic retention worker execution error") - - try: - await asyncio.sleep(self._settings.retention_cleanup_interval_seconds) - except asyncio.CancelledError: - break diff --git a/old_repo/src/lead_scoring/schemas/__init__.py b/old_repo/src/lead_scoring/schemas/__init__.py deleted file mode 100644 index 1aed93c..0000000 --- a/old_repo/src/lead_scoring/schemas/__init__.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Pydantic schemas for Lead Scoring service.""" - -from lead_scoring.schemas.dynamic_rule import ( - DynamicRule, - DynamicRuleOperator, - DynamicRuleTarget, -) -from lead_scoring.schemas.errors import ( - ERROR_TAXONOMY, - ErrorResponse, - ErrorTaxonomy, - ErrorTaxonomyEntry, -) -from lead_scoring.schemas.health import HealthResponse -from lead_scoring.schemas.lineage import ScoringRunLineage -from lead_scoring.schemas.outcomes import ( - OUTCOME_TAXONOMY, - OutcomeTaxonomy, - OutcomeTaxonomyEntry, -) -from lead_scoring.schemas.review import ( - ApproveReviewRequest, - LeadScoringReviewedEvent, - OverrideReviewRequest, - ReEvaluateReviewRequest, - ReviewDecision, - ReviewerContext, - ReviewerRole, - ReviewRequest, - ReviewResponse, - ReviewRunData, -) -from lead_scoring.schemas.rule_breakdown import ( - DataCompleteness, - NormalizedFeatures, - RuleBreakdownItem, - RuleEvaluationStatus, -) -from lead_scoring.schemas.scoring_config import ( - EffortProfile, - FallbackPolicy, - RuleMetric, - ScoringConfig, - ScoringConfigVersionSelection, - ScoringMode, - ScoringRule, -) -from lead_scoring.schemas.scoring_run import ( - AiMetadataFailure, - AiMetadataSuccess, - CreateScoringRunRequest, - CreateScoringRunResponse, - ScoringRunResponse, - ScoringRunStatus, -) -from lead_scoring.schemas.snapshot import ( - CanonicalSnapshot, - ConversationEvidence, - HitlPolicy, - IdentityContext, - LeadProfile, - MessageEvidence, - MessageSenderType, - SnapshotScope, - TriggerMetadata, - TruncationMetadata, -) -from lead_scoring.schemas.webhook import ( - LeadScoringTerminalEvent, - TerminalRunData, - WebhookIngestErrorResponse, - WebhookIngestResponse, -) - -__all__ = [ - "AiMetadataFailure", - "AiMetadataSuccess", - "ApproveReviewRequest", - "CanonicalSnapshot", - "ConversationEvidence", - "CreateScoringRunRequest", - "CreateScoringRunResponse", - "DataCompleteness", - "DynamicRule", - "DynamicRuleOperator", - "DynamicRuleTarget", - "EffortProfile", - "ErrorResponse", - "ErrorTaxonomy", - "ErrorTaxonomyEntry", - "ERROR_TAXONOMY", - "FallbackPolicy", - "HealthResponse", - "HitlPolicy", - "IdentityContext", - "LeadProfile", - "LeadScoringReviewedEvent", - "LeadScoringTerminalEvent", - "MessageEvidence", - "MessageSenderType", - "NormalizedFeatures", - "OverrideReviewRequest", - "OUTCOME_TAXONOMY", - "OutcomeTaxonomy", - "OutcomeTaxonomyEntry", - "ReEvaluateReviewRequest", - "ReviewDecision", - "ReviewRequest", - "ReviewResponse", - "ReviewRunData", - "ReviewerContext", - "ReviewerRole", - "RuleBreakdownItem", - "RuleEvaluationStatus", - "RuleMetric", - "ScoringConfig", - "ScoringConfigVersionSelection", - "ScoringMode", - "ScoringRunLineage", - "ScoringRule", - "ScoringRunResponse", - "ScoringRunStatus", - "SnapshotScope", - "TerminalRunData", - "TriggerMetadata", - "TruncationMetadata", - "WebhookIngestErrorResponse", - "WebhookIngestResponse", -] diff --git a/old_repo/src/lead_scoring/schemas/dynamic_rule.py b/old_repo/src/lead_scoring/schemas/dynamic_rule.py deleted file mode 100644 index 1dad34d..0000000 --- a/old_repo/src/lead_scoring/schemas/dynamic_rule.py +++ /dev/null @@ -1,57 +0,0 @@ -from enum import StrEnum -from typing import Any - -from pydantic import BaseModel, ConfigDict, Field, model_validator - - -class DynamicRuleOperator(StrEnum): - EQ = "eq" - NEQ = "neq" - GT = "gt" - GTE = "gte" - LT = "lt" - LTE = "lte" - CONTAINS = "contains" - IN = "in" - - -class DynamicRuleTarget(StrEnum): - STUDENT_AGE = "student_age" - LEAD_SOURCE = "lead_source" - COURSE_INTEREST = "course_interest" - STUDY_AREA = "study_area" - CONTACT_RECENCY_DAYS = "contact_message_recency_days" - CONVERSATION_COUNT = "conversation_count" - MESSAGE_COUNT = "message_count" - CUSTOM_FIELD = "custom_field" - - -class DynamicRule(BaseModel): - """A configurable dynamic scoring rule that evaluates snapshot features.""" - - model_config = ConfigDict(frozen=True) - - id: str = Field(..., min_length=1, max_length=64, description="Unique identifier for the rule") - name: str = Field(..., min_length=1, max_length=128, description="Human-readable rule name") - target: DynamicRuleTarget = Field( - default=DynamicRuleTarget.CUSTOM_FIELD, - description="Target feature or custom_field to evaluate", - ) - custom_field_key: str | None = Field( - default=None, - max_length=64, - description="Key inside lead_profile.custom_fields if target is custom_field", - ) - operator: DynamicRuleOperator = Field(..., description="Comparison operator") - value: Any = Field(..., description="Target comparison value (number, string, or list)") - points: int = Field( - ..., ge=-100, le=100, description="Points awarded when matched (-100 to 100)" - ) - enabled: bool = Field(default=True, description="Whether this rule is active") - - @model_validator(mode="after") - def validate_target_custom_field_key(self) -> "DynamicRule": - if self.target == DynamicRuleTarget.CUSTOM_FIELD: - if not self.custom_field_key or not self.custom_field_key.strip(): - raise ValueError("custom_field_key is required when target is 'custom_field'") - return self diff --git a/old_repo/src/lead_scoring/schemas/errors.py b/old_repo/src/lead_scoring/schemas/errors.py deleted file mode 100644 index f50f2ec..0000000 --- a/old_repo/src/lead_scoring/schemas/errors.py +++ /dev/null @@ -1,275 +0,0 @@ -from typing import Literal - -from pydantic import BaseModel, ConfigDict, Field, RootModel - - -class ErrorResponse(BaseModel): - model_config = ConfigDict(extra="ignore") - - error_code: str = Field(..., description="Machine-readable error code") - detail: str = Field(..., description="Human-readable error description") - - -class ErrorTaxonomyEntry(BaseModel): - """Khai báo machine-readable cho lifecycle và retry của public error code.""" - - model_config = ConfigDict(extra="forbid") - - error_code: str = Field(..., min_length=1, max_length=64) - lifecycle: Literal["admission", "polling", "execution", "review", "webhook_ingest", "writeback"] - owner: Literal["lead_scoring", "aihub", "dotb_omni"] - transport: Literal["http", "async_run", "webhook", "writeback"] - http_status: int | None = Field(default=None, ge=400, le=599) - retryable: bool - fallback_allowed: bool - description: str = Field(..., min_length=1, max_length=255) - - -class ErrorTaxonomy(RootModel[list[ErrorTaxonomyEntry]]): - """Wrapper schema cho artifact error taxonomy đã phát hành.""" - - -ERROR_TAXONOMY: tuple[ErrorTaxonomyEntry, ...] = ( - ErrorTaxonomyEntry( - error_code="invalid_token", - lifecycle="admission", - owner="lead_scoring", - transport="http", - http_status=401, - retryable=False, - fallback_allowed=False, - description="Missing or invalid service authentication token.", - ), - ErrorTaxonomyEntry( - error_code="forbidden", - lifecycle="admission", - owner="lead_scoring", - transport="http", - http_status=403, - retryable=False, - fallback_allowed=False, - description="Authenticated caller is not authorized for the requested tenant or operation.", - ), - ErrorTaxonomyEntry( - error_code="not_found", - lifecycle="polling", - owner="lead_scoring", - transport="http", - http_status=404, - retryable=False, - fallback_allowed=False, - description="Requested scoring run is not available in the tenant scope.", - ), - ErrorTaxonomyEntry( - error_code="idempotency_payload_mismatch", - lifecycle="admission", - owner="lead_scoring", - transport="http", - http_status=409, - retryable=False, - fallback_allowed=False, - description="An idempotency key was reused with a different normalized request.", - ), - ErrorTaxonomyEntry( - error_code="stale_run_version", - lifecycle="review", - owner="lead_scoring", - transport="http", - http_status=409, - retryable=False, - fallback_allowed=False, - description="Review expected version does not match the current run version.", - ), - ErrorTaxonomyEntry( - error_code="run_not_reviewable", - lifecycle="review", - owner="lead_scoring", - transport="http", - http_status=409, - retryable=False, - fallback_allowed=False, - description="The current run state does not accept a review command.", - ), - ErrorTaxonomyEntry( - error_code="validation_error", - lifecycle="admission", - owner="lead_scoring", - transport="http", - http_status=422, - retryable=False, - fallback_allowed=False, - description="Request headers, path parameters, or body failed contract validation.", - ), - ErrorTaxonomyEntry( - error_code="credential_in_payload", - lifecycle="admission", - owner="lead_scoring", - transport="http", - http_status=422, - retryable=False, - fallback_allowed=False, - description="A raw tenant credential was found in a persistent request field.", - ), - ErrorTaxonomyEntry( - error_code="scoring_config_not_found", - lifecycle="admission", - owner="lead_scoring", - transport="http", - http_status=422, - retryable=False, - fallback_allowed=False, - description="The requested immutable scoring configuration version is unavailable.", - ), - ErrorTaxonomyEntry( - error_code="hitl_policy_mismatch", - lifecycle="admission", - owner="lead_scoring", - transport="http", - http_status=422, - retryable=False, - fallback_allowed=False, - description="Snapshot HITL policy does not match the selected scoring configuration.", - ), - ErrorTaxonomyEntry( - error_code="worker_lease_too_short", - lifecycle="admission", - owner="lead_scoring", - transport="http", - http_status=422, - retryable=False, - fallback_allowed=False, - description="Configured worker lease cannot cover the selected AI profile timeout.", - ), - ErrorTaxonomyEntry( - error_code="missing_tenant_vkey", - lifecycle="review", - owner="lead_scoring", - transport="http", - http_status=422, - retryable=False, - fallback_allowed=False, - description="Re-evaluate requires a tenant V-Key for the new execution attempt.", - ), - ErrorTaxonomyEntry( - error_code="subject_ref_mismatch", - lifecycle="review", - owner="lead_scoring", - transport="http", - http_status=422, - retryable=False, - fallback_allowed=False, - description="The re-evaluation snapshot belongs to a different subject.", - ), - ErrorTaxonomyEntry( - error_code="scoring_admission_disabled", - lifecycle="admission", - owner="lead_scoring", - transport="http", - http_status=503, - retryable=True, - fallback_allowed=False, - description="The operational kill switch currently blocks new scoring initiation.", - ), - ErrorTaxonomyEntry( - error_code="worker_interrupted", - lifecycle="execution", - owner="lead_scoring", - transport="async_run", - retryable=True, - fallback_allowed=False, - description="The execution worker lost the in-memory attempt before terminal completion.", - ), - ErrorTaxonomyEntry( - error_code="aihub_auth_failed", - lifecycle="execution", - owner="aihub", - transport="async_run", - http_status=401, - retryable=False, - fallback_allowed=True, - description="AIHub rejected tenant credential authentication.", - ), - ErrorTaxonomyEntry( - error_code="aihub_quota_exceeded", - lifecycle="execution", - owner="aihub", - transport="async_run", - http_status=429, - retryable=False, - fallback_allowed=True, - description="AIHub quota or rate limit was exceeded.", - ), - ErrorTaxonomyEntry( - error_code="aihub_timeout", - lifecycle="execution", - owner="aihub", - transport="async_run", - retryable=False, - fallback_allowed=True, - description="The bounded AIHub request timed out.", - ), - ErrorTaxonomyEntry( - error_code="aihub_server_error", - lifecycle="execution", - owner="aihub", - transport="async_run", - retryable=False, - fallback_allowed=True, - description="AIHub returned a server-side failure.", - ), - ErrorTaxonomyEntry( - error_code="aihub_invalid_output", - lifecycle="execution", - owner="aihub", - transport="async_run", - retryable=False, - fallback_allowed=True, - description="AIHub output did not conform to the required bounded result schema.", - ), - ErrorTaxonomyEntry( - error_code="aihub_network_error", - lifecycle="execution", - owner="aihub", - transport="async_run", - retryable=False, - fallback_allowed=True, - description="The bounded AIHub request could not reach the service.", - ), - ErrorTaxonomyEntry( - error_code="aihub_client_error", - lifecycle="execution", - owner="aihub", - transport="async_run", - retryable=False, - fallback_allowed=True, - description="AIHub returned a non-retryable client response.", - ), - ErrorTaxonomyEntry( - error_code="aihub_unknown_error", - lifecycle="execution", - owner="aihub", - transport="async_run", - retryable=False, - fallback_allowed=True, - description="AIHub returned an unexpected normalized failure.", - ), - ErrorTaxonomyEntry( - error_code="subscriber_tenant_unavailable", - lifecycle="webhook_ingest", - owner="dotb_omni", - transport="webhook", - http_status=404, - retryable=False, - fallback_allowed=False, - description="Webhook destination is not available for the resolved tenant.", - ), - ErrorTaxonomyEntry( - error_code="marker_block_malformed", - lifecycle="writeback", - owner="dotb_omni", - transport="writeback", - retryable=False, - fallback_allowed=False, - description="EMS description contains an orphan or multiple Marker Blocks.", - ), -) diff --git a/old_repo/src/lead_scoring/schemas/health.py b/old_repo/src/lead_scoring/schemas/health.py deleted file mode 100644 index 0144d1e..0000000 --- a/old_repo/src/lead_scoring/schemas/health.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing import Literal - -from pydantic import BaseModel, ConfigDict - -from lead_scoring import __version__ - - -class HealthResponse(BaseModel): - model_config = ConfigDict(extra="forbid") - - status: Literal["ok", "unhealthy"] = "ok" - version: str = __version__ - database: Literal["connected", "unreachable", "unconfigured"] = "connected" diff --git a/old_repo/src/lead_scoring/schemas/lineage.py b/old_repo/src/lead_scoring/schemas/lineage.py deleted file mode 100644 index e6ed404..0000000 --- a/old_repo/src/lead_scoring/schemas/lineage.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing import Literal - -from pydantic import BaseModel, ConfigDict, Field - - -class ScoringRunLineage(BaseModel): - """Metadata ổn định mô tả quan hệ parent/child của scoring run.""" - - model_config = ConfigDict(extra="forbid") - - run_id: str = Field(..., min_length=1, max_length=36) - parent_run_id: str | None = Field(default=None, max_length=36) - relationship: Literal["root", "re_evaluation_child"] diff --git a/old_repo/src/lead_scoring/schemas/outcomes.py b/old_repo/src/lead_scoring/schemas/outcomes.py deleted file mode 100644 index 078b235..0000000 --- a/old_repo/src/lead_scoring/schemas/outcomes.py +++ /dev/null @@ -1,100 +0,0 @@ -from typing import Literal - -from pydantic import BaseModel, ConfigDict, Field, RootModel - - -class OutcomeTaxonomyEntry(BaseModel): - """Khai báo machine-readable cho các outcome đã phát hành.""" - - model_config = ConfigDict(extra="forbid") - - outcome_code: str = Field(..., min_length=1, max_length=64) - lifecycle: Literal["run", "review", "webhook_ingest"] - terminal: bool - description: str = Field(..., min_length=1, max_length=255) - - -class OutcomeTaxonomy(RootModel[list[OutcomeTaxonomyEntry]]): - """Wrapper schema cho artifact outcome taxonomy đã phát hành.""" - - -OUTCOME_TAXONOMY: tuple[OutcomeTaxonomyEntry, ...] = ( - OutcomeTaxonomyEntry( - outcome_code="pending", - lifecycle="run", - terminal=False, - description="Run đã được persist và đang chờ execution worker.", - ), - OutcomeTaxonomyEntry( - outcome_code="running", - lifecycle="run", - terminal=False, - description="Execution worker đã claim run và đang xử lý.", - ), - OutcomeTaxonomyEntry( - outcome_code="completed", - lifecycle="run", - terminal=True, - description="Run hoàn tất với calculated score hợp lệ.", - ), - OutcomeTaxonomyEntry( - outcome_code="review_required", - lifecycle="run", - terminal=True, - description="Run có kết quả nhưng bắt buộc Human-In-The-Loop review.", - ), - OutcomeTaxonomyEntry( - outcome_code="failed", - lifecycle="run", - terminal=True, - description="Run thất bại với machine-readable error outcome.", - ), - OutcomeTaxonomyEntry( - outcome_code="superseded", - lifecycle="run", - terminal=True, - description="Run cũ đã được thay thế bởi child run qua re-evaluation.", - ), - OutcomeTaxonomyEntry( - outcome_code="approve", - lifecycle="review", - terminal=True, - description="Reviewer chấp thuận calculated score làm effective score.", - ), - OutcomeTaxonomyEntry( - outcome_code="override", - lifecycle="review", - terminal=True, - description="Reviewer đặt effective score numeric từ 0 đến 100.", - ), - OutcomeTaxonomyEntry( - outcome_code="re_evaluate", - lifecycle="review", - terminal=True, - description="Reviewer tạo child run và supersede parent run.", - ), - OutcomeTaxonomyEntry( - outcome_code="accepted", - lifecycle="webhook_ingest", - terminal=True, - description="Omni đã nhận event để xử lý writeback bất đồng bộ.", - ), - OutcomeTaxonomyEntry( - outcome_code="duplicate", - lifecycle="webhook_ingest", - terminal=True, - description="Event hoặc run đã được phản ánh trước đó.", - ), - OutcomeTaxonomyEntry( - outcome_code="stale", - lifecycle="webhook_ingest", - terminal=True, - description="Run mới hơn đã được phản ánh cho ordering key.", - ), - OutcomeTaxonomyEntry( - outcome_code="ordering_conflict", - lifecycle="webhook_ingest", - terminal=True, - description="Hai run khác nhau có cùng run_created_at và không được tự ghi đè.", - ), -) diff --git a/old_repo/src/lead_scoring/schemas/review.py b/old_repo/src/lead_scoring/schemas/review.py deleted file mode 100644 index 18fecf8..0000000 --- a/old_repo/src/lead_scoring/schemas/review.py +++ /dev/null @@ -1,146 +0,0 @@ -from datetime import datetime -from enum import StrEnum -from typing import Annotated, Literal -from uuid import UUID - -from pydantic import BaseModel, ConfigDict, Field, StrictInt, model_validator - -from lead_scoring.schemas.scoring_run import ScoringRunStatus -from lead_scoring.schemas.snapshot import CanonicalSnapshot - -REVIEW_EVENT_TYPE = "lead_scoring.run.reviewed" -REVIEW_EVENT_VERSION = "1.0.0" - - -class ReviewDecision(StrEnum): - APPROVE = "approve" - OVERRIDE = "override" - RE_EVALUATE = "re_evaluate" - - -class ReviewerRole(StrEnum): - ADMINISTRATOR = "administrator" - AGENT = "agent" - - -class ReviewerContext(BaseModel): - model_config = ConfigDict(extra="forbid") - - actor_id: str = Field( - ..., min_length=1, max_length=255, description="Actor identifier of the reviewer" - ) - role: ReviewerRole = Field( - ..., - description="Reviewer role supplied by the caller for audit metadata", - ) - current_assignee_id: str | None = Field( - default=None, - max_length=255, - description="Caller-supplied assignment context retained for compatibility", - ) - - -class BaseReviewRequest(BaseModel): - model_config = ConfigDict(extra="forbid") - - expected_run_version: StrictInt = Field( - ..., ge=1, description="Expected run version for optimistic concurrency" - ) - reviewer: ReviewerContext = Field( - ..., description="Caller-provided reviewer authorization context" - ) - note: str | None = Field( - default=None, max_length=2000, description="Optional reviewer note or reason" - ) - - -class ApproveReviewRequest(BaseReviewRequest): - decision: Literal[ReviewDecision.APPROVE] = Field( - default=ReviewDecision.APPROVE, description="Approve calculated score" - ) - - -class OverrideReviewRequest(BaseReviewRequest): - decision: Literal[ReviewDecision.OVERRIDE] = Field( - default=ReviewDecision.OVERRIDE, description="Override score" - ) - override_score: StrictInt = Field( - ..., ge=0, le=100, description="Human override score between 0 and 100" - ) - - -class ReEvaluateReviewRequest(BaseReviewRequest): - decision: Literal[ReviewDecision.RE_EVALUATE] = Field( - default=ReviewDecision.RE_EVALUATE, - description="Re-evaluate with new snapshot and create child run", - ) - snapshot: CanonicalSnapshot = Field( - ..., description="New Canonical Lead Snapshot containing updated evidence" - ) - - -ReviewRequest = Annotated[ - ApproveReviewRequest | OverrideReviewRequest | ReEvaluateReviewRequest, - Field(discriminator="decision"), -] - - -class ReviewResponse(BaseModel): - model_config = ConfigDict(extra="forbid") - - run_id: str = Field(..., max_length=36, description="Scoring run ID") - tenant_id: str = Field(..., max_length=255, description="Tenant identifier") - subject_ref: str = Field(..., max_length=255, description="EMS Lead reference") - status: ScoringRunStatus = Field( - ..., description="Updated run status (completed or superseded)" - ) - decision: ReviewDecision = Field(..., description="Review decision applied") - calculated_score: int | None = Field( - default=None, ge=0, le=100, description="Original calculated score" - ) - effective_score: int | None = Field( - default=None, ge=0, le=100, description="Effective score after review" - ) - version: int = Field(..., ge=1, description="New run version after review") - reviewed_by: str = Field(..., max_length=255, description="Actor ID who reviewed") - reviewed_at: datetime = Field(..., description="Timestamp of review") - note: str | None = Field(default=None, max_length=2000, description="Reviewer note") - child_run_id: str | None = Field( - default=None, - max_length=36, - description="Server-generated child scoring run ID when decision is re_evaluate", - ) - - -class ReviewRunData(BaseModel): - model_config = ConfigDict(extra="forbid") - - status: str - decision: ReviewDecision - calculated_score: int | None = Field(default=None, ge=0, le=100) - effective_score: int | None = Field(default=None, ge=0, le=100) - version: int = Field(..., ge=1) - reviewed_by: str = Field(..., min_length=1, max_length=255) - - -class LeadScoringReviewedEvent(BaseModel): - model_config = ConfigDict(extra="forbid") - - event_id: UUID - event_type: Literal["lead_scoring.run.reviewed"] - event_version: Literal["1.0.0"] - tenant_id: str = Field(min_length=1, max_length=255) - subject_ref: str = Field(min_length=1, max_length=255) - scoring_config_version: str = Field(min_length=1, max_length=64) - run_id: UUID - run_created_at: datetime - occurred_at: datetime - data: ReviewRunData - - @model_validator(mode="after") - def validate_timestamps(self) -> "LeadScoringReviewedEvent": - if self.run_created_at.tzinfo is None or self.occurred_at.tzinfo is None: - raise ValueError("Webhook timestamps must include a UTC offset") - if self.occurred_at < self.run_created_at: - raise ValueError("occurred_at cannot precede run_created_at") - return self diff --git a/old_repo/src/lead_scoring/schemas/rule_breakdown.py b/old_repo/src/lead_scoring/schemas/rule_breakdown.py deleted file mode 100644 index df08884..0000000 --- a/old_repo/src/lead_scoring/schemas/rule_breakdown.py +++ /dev/null @@ -1,41 +0,0 @@ -from enum import StrEnum - -from pydantic import BaseModel, ConfigDict, Field - - -class RuleEvaluationStatus(StrEnum): - MATCHED = "matched" - NOT_MATCHED = "not_matched" - SKIPPED_MISSING_DATA = "skipped_missing_data" - - -class RuleBreakdownItem(BaseModel): - model_config = ConfigDict(frozen=True) - - rule_name: str - status: RuleEvaluationStatus - points_awarded: int - detail: str - - -class NormalizedFeatures(BaseModel): - model_config = ConfigDict(frozen=True) - - student_age: int | None = None - contact_message_recency_days: float | None = None - contact_message_recency_hours: float | None = None - lead_source: str | None = None - course_interest: str | None = None - conversation_count: int = 0 - message_count: int = 0 - conversation_turn_count: int = 0 - customer_message_count: int = 0 - customer_initiated_return: bool = False - - -class DataCompleteness(BaseModel): - model_config = ConfigDict(frozen=True) - - present_fields: list[str] = Field(default_factory=list) - missing_fields: list[str] = Field(default_factory=list) - completeness_ratio: float = 1.0 diff --git a/old_repo/src/lead_scoring/schemas/scoring_config.py b/old_repo/src/lead_scoring/schemas/scoring_config.py deleted file mode 100644 index 84f9b0d..0000000 --- a/old_repo/src/lead_scoring/schemas/scoring_config.py +++ /dev/null @@ -1,170 +0,0 @@ -from enum import StrEnum - -from pydantic import BaseModel, ConfigDict, Field, model_validator - -from lead_scoring.schemas.dynamic_rule import DynamicRule - - -class RuleMetric(StrEnum): - PROFILE_FIELD_COUNT = "profile_field_count" - CONVERSATION_COUNT = "conversation_count" - MESSAGE_COUNT = "message_count" - - -class BehavioralMetric(StrEnum): - MESSAGE_COUNT = "message_count" - CONVERSATION_COUNT = "conversation_count" - CONVERSATION_TURN_COUNT = "conversation_turn_count" - CUSTOMER_MESSAGE_COUNT = "customer_message_count" - CUSTOMER_INITIATED_RETURN = "customer_initiated_return" - - -class EffortProfile(StrEnum): - LIGHT = "light" - MEDIUM = "medium" - HEAVY = "heavy" - - -class FallbackPolicy(StrEnum): - RULES_ONLY_REVIEW = "rules_only_review" - FAIL_RUN = "fail_run" - - -class ScoringMode(StrEnum): - LIGHT_AI = "light_ai" - MEDIUM_AI = "medium_ai" - HEAVY_AI = "heavy_ai" - RULES_ONLY_FALLBACK = "rules_only_fallback" - RULE_ONLY = "rule_only" - - -class ScoringRule(BaseModel): - model_config = ConfigDict(frozen=True) - - metric: RuleMetric - minimum: int = Field(..., ge=0) - points: int = Field(..., ge=-100, le=100) - - -DEFAULT_MEDIUM_EXTRACT_PROMPT = ( - "Bạn là trợ lý AI phân tích hội thoại để đánh giá tiềm năng học viên. " - "Hãy trích xuất bằng chứng có cấu trúc về nhu cầu khóa học, mức độ quan tâm, " - "thời điểm đăng ký và rào cản. Trả lời bằng tiếng Việt và không tự bịa dữ kiện." -) - -DEFAULT_MEDIUM_SCORE_PROMPT = ( - "Bạn là chuyên gia chấm điểm Lead giáo dục từ 0 đến 100. " - "Dựa trên bằng chứng đã trích xuất, hãy đánh giá khả năng đăng ký, độ tin cậy, " - "lý do và các yếu tố tích cực hoặc tiêu cực. " - "Lưu ý: Không cộng lại điểm các customer rules hoặc metrics đã tính ở phần quy tắc riêng. " - "Trả lời bằng tiếng Việt và không tự bịa dữ kiện." -) - - -class ScoringConfig(BaseModel): - model_config = ConfigDict(frozen=True) - - version: str = Field(..., min_length=1, max_length=64) - base_score: int = Field(default=0, ge=0, le=100) - confidence: float = Field(..., ge=0.0, le=1.0) - confidence_threshold: float = Field(..., ge=0.0, le=1.0) - hitl_policy_version: str = Field(..., min_length=1, max_length=64) - rules: tuple[ScoringRule, ...] = () - dynamic_rules: tuple[DynamicRule, ...] = Field( - default=(), - description="Operator-configured dynamic rules evaluated against lead features", - ) - selected_behavioral_metrics: tuple[BehavioralMetric, ...] = Field( - default=( - BehavioralMetric.MESSAGE_COUNT, - BehavioralMetric.CONVERSATION_COUNT, - BehavioralMetric.CONVERSATION_TURN_COUNT, - BehavioralMetric.CUSTOMER_MESSAGE_COUNT, - BehavioralMetric.CUSTOMER_INITIATED_RETURN, - ), - description=( - "System behavioral metrics calculated deterministically and supplied to AI context" - ), - ) - - rule_weight: float = Field(default=0.4, ge=0.0, le=1.0) - ai_weight: float = Field(default=0.6, ge=0.0, le=1.0) - effort_profile: EffortProfile = Field(default=EffortProfile.MEDIUM) - ai_model: str = Field(default="openai/gpt-4o-mini", min_length=1, max_length=64) - fallback_policy: FallbackPolicy = Field(default=FallbackPolicy.RULES_ONLY_REVIEW) - ai_timeout_seconds: float = Field(default=10.0, gt=0) - - # Configurable Medium AI system prompts - medium_extract_prompt: str = Field( - default=DEFAULT_MEDIUM_EXTRACT_PROMPT, - min_length=1, - max_length=4000, - description="Editable system prompt for Medium AI evidence extraction pass", - ) - medium_score_prompt: str = Field( - default=DEFAULT_MEDIUM_SCORE_PROMPT, - min_length=1, - max_length=4000, - description="Editable system prompt for Medium AI lead scoring pass", - ) - - education_demo_enabled: bool = Field(default=False) - - # Fixed Education rule catalog with operator-editable values. - age_primary_min: int = Field(default=3, ge=0, le=100) - age_primary_max: int = Field(default=15, ge=0, le=100) - age_primary_points: int = Field(default=40, ge=0, le=100) - age_secondary_min: int = Field(default=16, ge=0, le=100) - age_secondary_max: int = Field(default=18, ge=0, le=100) - age_secondary_points: int = Field(default=20, ge=0, le=100) - - recency_hot_max_days: float = Field(default=3, ge=0) - recency_hot_points: int = Field(default=40, ge=0, le=100) - recency_warm_max_days: float = Field(default=7, ge=0) - recency_warm_points: int = Field(default=25, ge=0, le=100) - recency_cool_max_days: float = Field(default=14, ge=0) - recency_cool_points: int = Field(default=15, ge=0, le=100) - recency_cold_max_days: float = Field(default=30, ge=0) - recency_cold_points: int = Field(default=5, ge=0, le=100) - - source_referral_points: int = Field(default=20, ge=0, le=100) - source_call_in_points: int = Field(default=15, ge=0, le=100) - source_digital_points: int = Field(default=10, ge=0, le=100) - source_campaign_points: int = Field(default=8, ge=0, le=100) - source_other_points: int = Field(default=5, ge=0, le=100) - completeness_threshold: float = Field(default=0.5, ge=0.0, le=1.0) - - @model_validator(mode="after") - def validate_education_bands(self) -> "ScoringConfig": - if not ( - self.age_primary_min - <= self.age_primary_max - < self.age_secondary_min - <= self.age_secondary_max - ): - raise ValueError("Education age bands must be ordered and non-overlapping") - if not ( - self.recency_hot_max_days - <= self.recency_warm_max_days - <= self.recency_cool_max_days - <= self.recency_cold_max_days - ): - raise ValueError("Education recency bands must be ordered") - return self - - @model_validator(mode="after") - def validate_weights(self) -> "ScoringConfig": - if round(self.rule_weight + self.ai_weight, 6) != 1.0: - raise ValueError( - f"Sum of rule_weight ({self.rule_weight}) and ai_weight ({self.ai_weight}) " - "must equal 1.0" - ) - return self - - -class ScoringConfigVersionSelection(BaseModel): - """Reference used by callers to select an immutable scoring config version.""" - - model_config = ConfigDict(extra="forbid") - - version: str = Field(..., min_length=1, max_length=64) diff --git a/old_repo/src/lead_scoring/schemas/scoring_run.py b/old_repo/src/lead_scoring/schemas/scoring_run.py deleted file mode 100644 index c45ba33..0000000 --- a/old_repo/src/lead_scoring/schemas/scoring_run.py +++ /dev/null @@ -1,159 +0,0 @@ -from datetime import datetime -from enum import StrEnum - -from pydantic import BaseModel, ConfigDict, Field, model_validator - -from lead_scoring.schemas.dynamic_rule import DynamicRule -from lead_scoring.schemas.rule_breakdown import RuleBreakdownItem -from lead_scoring.schemas.scoring_config import ( - EffortProfile, - FallbackPolicy, - ScoringConfig, - ScoringMode, -) -from lead_scoring.schemas.snapshot import CanonicalSnapshot - - -class ScoringRunStatus(StrEnum): - PENDING = "pending" - RUNNING = "running" - COMPLETED = "completed" - REVIEW_REQUIRED = "review_required" - FAILED = "failed" - SUPERSEDED = "superseded" - - -class CreateScoringRunRequest(BaseModel): - model_config = ConfigDict(extra="ignore") - - tenant_id: str = Field(..., min_length=1, max_length=255, description="Tenant identifier") - subject_ref: str = Field( - ..., min_length=1, max_length=255, description="EMS Lead reference or identifier" - ) - scoring_config_version: str = Field( - default="1.0.0", min_length=1, max_length=64, description="Scoring config version" - ) - rule_overrides: list[DynamicRule] = Field( - default_factory=list, - max_length=30, - description="Inline dynamic rule overrides (maximum 30 rules)", - ) - snapshot: CanonicalSnapshot = Field( - ..., description="Canonical Lead Snapshot containing evidence" - ) - - @model_validator(mode="after") - def validate_request_invariants(self) -> "CreateScoringRunRequest": - if self.subject_ref != self.snapshot.lead_profile.ems_lead_id: - raise ValueError("Request subject_ref must match snapshot lead_profile ems_lead_id") - - rule_ids: list[str] = [] - for rule in self.rule_overrides: - if rule.id in rule_ids: - raise ValueError(f"Duplicate dynamic rule ID '{rule.id}' found in rule_overrides") - rule_ids.append(rule.id) - - if isinstance(rule.value, str): - if len(rule.value) > 1000: - raise ValueError( - f"Dynamic rule '{rule.id}' value string exceeds " - "maximum allowed size (1000 chars)" - ) - elif isinstance(rule.value, (list, tuple, set)): - if len(rule.value) > 100: - raise ValueError( - f"Dynamic rule '{rule.id}' value list exceeds " - "maximum allowed length (100 items)" - ) - for item in rule.value: - if isinstance(item, str) and len(item) > 1000: - raise ValueError( - f"Dynamic rule '{rule.id}' item string in value list " - "exceeds maximum size (1000 chars)" - ) - - return self - - -class CreateScoringRunResponse(BaseModel): - model_config = ConfigDict(extra="ignore") - - run_id: str = Field(..., max_length=36, description="Server-generated unique scoring run ID") - status: ScoringRunStatus = Field( - default=ScoringRunStatus.PENDING, description="Initial run status" - ) - status_url: str = Field(..., description="URL to inspect scoring run status") - tenant_id: str = Field(..., max_length=255, description="Tenant identifier") - subject_ref: str = Field(..., max_length=255, description="EMS Lead reference") - scoring_config_version: str = Field( - ..., max_length=64, description="Scoring config version applied" - ) - created_at: datetime = Field(..., description="Creation timestamp") - - -class AiMetadataSuccess(BaseModel): - model_config = ConfigDict(extra="forbid") - - model: str = Field(..., min_length=1, max_length=64, description="AI model used") - effort_profile: EffortProfile = Field(..., description="AI effort profile applied") - pass_count: int = Field(..., ge=1, description="Number of passes executed") - - -class AiMetadataFailure(BaseModel): - model_config = ConfigDict(extra="forbid") - - error_code: str = Field( - ..., min_length=1, max_length=64, description="Machine-readable AI error code" - ) - detail: str = Field(..., min_length=1, max_length=255, description="AI error detail") - fallback_policy: FallbackPolicy = Field(..., description="Fallback policy triggered") - - -class ScoringRunResponse(BaseModel): - model_config = ConfigDict(extra="forbid") - - run_id: str = Field(..., max_length=36, description="Unique scoring run ID") - tenant_id: str = Field(..., max_length=255, description="Tenant identifier") - subject_ref: str = Field(..., max_length=255, description="EMS Lead reference") - scoring_config_version: str = Field(..., max_length=64, description="Scoring config version") - parent_run_id: str | None = Field( - default=None, - max_length=36, - description="Parent scoring run ID if created via re-evaluation", - ) - status: ScoringRunStatus = Field(..., description="Current scoring run status") - rule_score: int | None = Field(..., ge=0, le=100) - ai_score: int | None = Field(..., ge=0, le=100) - final_score: int | None = Field(..., ge=0, le=100) - effective_score: int | None = Field(..., ge=0, le=100) - scoring_mode: ScoringMode | None = Field(...) - degraded: bool | None = Field(...) - ai_metadata: AiMetadataSuccess | AiMetadataFailure | None = Field(...) - confidence: float | None = Field(..., ge=0.0, le=1.0) - confidence_threshold: float | None = Field(..., ge=0.0, le=1.0) - breakdown: list[RuleBreakdownItem] | None = Field( - default=None, description="Rule evaluation breakdown" - ) - effective_config: ScoringConfig | None = Field(...) - error_code: str | None = Field(..., max_length=64) - retryable: bool | None = Field(...) - version: int = Field(..., ge=1, description="Run version for optimistic concurrency") - started_at: datetime | None = Field(...) - completed_at: datetime | None = Field(...) - category: str | None = Field( - default=None, - max_length=64, - description="Public lead tier / category derived deterministically from score", - ) - explanation: str | None = Field( - default=None, - max_length=1000, - description="Concise public explanation using AI reasoning where available or rule summary", - ) - recommended_action: str | None = Field( - default=None, - max_length=255, - description="Deterministic recommended next action based on score", - ) - created_at: datetime = Field(..., description="Creation timestamp") - updated_at: datetime = Field(..., description="Last updated timestamp") diff --git a/old_repo/src/lead_scoring/schemas/snapshot.py b/old_repo/src/lead_scoring/schemas/snapshot.py deleted file mode 100644 index c7265cf..0000000 --- a/old_repo/src/lead_scoring/schemas/snapshot.py +++ /dev/null @@ -1,139 +0,0 @@ -from datetime import UTC, datetime, timedelta -from enum import StrEnum -from typing import Any, Literal - -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator - - -class SnapshotScope(StrEnum): - TRIGGER_ONLY = "trigger_only" - LINKED_CONTACT_HISTORY = "linked_contact_history" - MULTI_SOURCE_HISTORY = "multi_source_history" - - -type MessageSenderType = Literal["contact", "agent", "bot"] - - -class MessageEvidence(BaseModel): - model_config = ConfigDict(extra="ignore") - - message_id: str = Field(..., min_length=1, max_length=255) - sender_type: MessageSenderType = Field(...) - content: str = Field(..., min_length=1) - sent_at: datetime = Field(..., description="Timezone-aware timestamp of when message was sent") - - @field_validator("sent_at") - @classmethod - def normalize_sent_at_utc(cls, v: datetime) -> datetime: - if v.tzinfo is None or v.tzinfo.utcoffset(v) is None: - raise ValueError("Message sent_at must be timezone-aware") - return v.astimezone(UTC) - - -class ConversationEvidence(BaseModel): - model_config = ConfigDict(extra="ignore") - - conversation_id: str = Field(..., min_length=1, max_length=255) - channel: str | None = Field(default=None, max_length=64) - assigned_agent_id: str | None = Field(default=None, max_length=255) - messages: list[MessageEvidence] = Field(default_factory=list) - - -class LeadProfile(BaseModel): - model_config = ConfigDict(extra="ignore") - - ems_lead_id: str = Field(..., min_length=1, max_length=255) - name: str | None = Field(default=None, max_length=255) - phone: str | None = Field(default=None, max_length=64) - email: str | None = Field(default=None, max_length=255) - status: str | None = Field(default=None, max_length=64) - custom_fields: dict[str, Any] = Field(default_factory=dict) - - -class IdentityContext(BaseModel): - model_config = ConfigDict(extra="ignore") - - match_quality: Literal["direct", "phone_and_email", "phone_and_name", "phone_only"] | None = ( - None - ) - contact_id: str | None = Field(default=None, max_length=255) - details: dict[str, Any] = Field(default_factory=dict) - - -class TriggerMetadata(BaseModel): - model_config = ConfigDict(extra="ignore") - - trigger_type: Literal["manual_omni", "captain_tool", "dev_console"] = "manual_omni" - trigger_conversation_id: str | None = Field(default=None, max_length=255) - actor_id: str | None = Field(default=None, max_length=255) - - -class HitlPolicy(BaseModel): - model_config = ConfigDict(extra="ignore") - - confidence_threshold: float = Field(default=0.80, ge=0.0, le=1.0) - policy_version: str = Field(default="1.0.0", max_length=64) - - -class TruncationMetadata(BaseModel): - model_config = ConfigDict(extra="ignore") - - truncated: bool = False - dropped_conversations_count: int = Field(default=0, ge=0) - dropped_messages_count: int = Field(default=0, ge=0) - reason: str | None = Field(default=None, max_length=255) - - -class CanonicalSnapshot(BaseModel): - model_config = ConfigDict(extra="ignore") - - version: str = Field(default="1.0.0", max_length=64) - scope: SnapshotScope = SnapshotScope.LINKED_CONTACT_HISTORY - captured_at: datetime = Field( - ..., - description="Timezone-aware timestamp when snapshot evidence was frozen", - ) - lead_profile: LeadProfile - conversations: list[ConversationEvidence] = Field(default_factory=list) - identity_context: IdentityContext | None = None - trigger_metadata: TriggerMetadata | None = None - hitl_policy: HitlPolicy | None = None - truncation: TruncationMetadata | None = None - - @field_validator("captured_at") - @classmethod - def normalize_captured_at_utc(cls, v: datetime) -> datetime: - if v.tzinfo is None or v.tzinfo.utcoffset(v) is None: - raise ValueError("Snapshot captured_at must be timezone-aware") - return v.astimezone(UTC) - - @model_validator(mode="after") - def validate_structural_contract(self) -> "CanonicalSnapshot": - if len(self.conversations) > 10: - raise ValueError( - f"Conversations count ({len(self.conversations)}) exceeds maximum cap of 10" - ) - - total_messages = sum(len(c.messages) for c in self.conversations) - if total_messages > 50: - raise ValueError(f"Total messages count ({total_messages}) exceeds maximum cap of 50") - - total_chars = sum(len(m.content) for c in self.conversations for m in c.messages) - if total_chars > 20000: - raise ValueError( - f"Total conversation characters ({total_chars}) exceeds maximum cap of 20000" - ) - - # Retention and timeline verification - captured_at_utc = self.captured_at - retention_window = timedelta(days=90) - - for conv in self.conversations: - for msg in conv.messages: - msg_sent_utc = msg.sent_at - if msg_sent_utc > captured_at_utc: - raise ValueError("Message sent_at cannot be in future relative to captured_at") - if (captured_at_utc - msg_sent_utc) > retention_window: - raise ValueError("Message sent_at exceeds 90-day retention window") - - return self diff --git a/old_repo/src/lead_scoring/schemas/webhook.py b/old_repo/src/lead_scoring/schemas/webhook.py deleted file mode 100644 index b04b9ab..0000000 --- a/old_repo/src/lead_scoring/schemas/webhook.py +++ /dev/null @@ -1,91 +0,0 @@ -from datetime import datetime -from enum import StrEnum -from typing import Literal -from uuid import UUID - -from pydantic import BaseModel, ConfigDict, Field, model_validator - -from lead_scoring.schemas.scoring_run import ScoringRunStatus - -WEBHOOK_EVENT_TYPE = "lead_scoring.run.terminal" -WEBHOOK_EVENT_VERSION = "1.0.0" - - -class OutboxDeliveryStatus(StrEnum): - PENDING = "pending" - DELIVERING = "delivering" - DELIVERED = "delivered" - DEAD_LETTER = "dead_letter" - - -class TerminalRunData(BaseModel): - model_config = ConfigDict(extra="forbid") - - status: Literal[ - ScoringRunStatus.COMPLETED, - ScoringRunStatus.FAILED, - ] - rule_score: int | None = Field(default=None, ge=0, le=100) - ai_score: int | None = Field(default=None, ge=0, le=100) - final_score: int | None = Field(default=None, ge=0, le=100) - effective_score: int | None = Field(default=None, ge=0, le=100) - confidence: float | None = Field(default=None, ge=0.0, le=1.0) - category: str | None = Field(default=None, min_length=1, max_length=32) - explanation: str | None = Field(default=None, max_length=2000) - recommended_action: str | None = Field(default=None, max_length=2000) - scoring_mode: str | None = Field(default=None, max_length=32) - degraded: bool | None = None - error_code: str | None = Field(default=None, min_length=1, max_length=64) - retryable: bool | None = None - - @model_validator(mode="after") - def validate_terminal_outcome(self) -> "TerminalRunData": - if self.status == ScoringRunStatus.FAILED: - if self.error_code is None or self.retryable is None: - raise ValueError("Failed terminal events require error_code and retryable") - elif ( - self.status == ScoringRunStatus.COMPLETED - and not self.degraded - and (self.error_code is not None or self.retryable is not None) - ): - raise ValueError( - "Completed non-degraded terminal events cannot include failure metadata" - ) - return self - - -class LeadScoringTerminalEvent(BaseModel): - model_config = ConfigDict(extra="forbid") - - event_id: UUID - event_type: Literal["lead_scoring.run.terminal"] - event_version: Literal["1.0.0"] - tenant_id: str = Field(min_length=1, max_length=255) - subject_ref: str = Field(min_length=1, max_length=255) - scoring_config_version: str = Field(min_length=1, max_length=64) - run_id: UUID - run_created_at: datetime - occurred_at: datetime - data: TerminalRunData - - @model_validator(mode="after") - def validate_timestamps(self) -> "LeadScoringTerminalEvent": - if self.run_created_at.tzinfo is None or self.occurred_at.tzinfo is None: - raise ValueError("Webhook timestamps must include a UTC offset") - if self.occurred_at < self.run_created_at: - raise ValueError("occurred_at cannot precede run_created_at") - return self - - -class WebhookIngestResponse(BaseModel): - model_config = ConfigDict(extra="forbid") - - status: Literal["accepted", "duplicate", "stale", "ordering_conflict"] - event_id: UUID - - -class WebhookIngestErrorResponse(BaseModel): - model_config = ConfigDict(extra="forbid") - - error_code: Literal["subscriber_tenant_unavailable"] - detail: Literal["Webhook destination is not available"] diff --git a/old_repo/src/lead_scoring/scoring.py b/old_repo/src/lead_scoring/scoring.py deleted file mode 100644 index bc72a57..0000000 --- a/old_repo/src/lead_scoring/scoring.py +++ /dev/null @@ -1,445 +0,0 @@ -from collections.abc import Collection -from typing import Any - -from lead_scoring.dynamic_rules import evaluate_dynamic_rules_collection -from lead_scoring.schemas.rule_breakdown import ( - DataCompleteness, - NormalizedFeatures, - RuleBreakdownItem, - RuleEvaluationStatus, -) -from lead_scoring.schemas.scoring_config import ( - BehavioralMetric, - EffortProfile, - FallbackPolicy, - RuleMetric, - ScoringConfig, - ScoringMode, - ScoringRule, -) -from lead_scoring.schemas.snapshot import CanonicalSnapshot - -__all__ = [ - "AI_LEASE_MARGIN_SECONDS", - "PROFILE_PASS_COUNTS", - "PROFILE_PASS_NAMES", - "BehavioralMetric", - "DataCompleteness", - "EffortProfile", - "FallbackPolicy", - "NormalizedFeatures", - "RuleBreakdownItem", - "RuleEvaluationStatus", - "RuleMetric", - "ScoringConfig", - "ScoringMode", - "ScoringRule", - "calculate_min_worker_lease_seconds", - "combine_confidence", - "combine_final_score", - "compute_behavioral_metrics", - "derive_outcome_fields", - "select_behavioral_metrics", - "evaluate_education_rules", - "evaluate_rule_score", - "extract_normalized_features", -] - -PROFILE_PASS_COUNTS: dict[EffortProfile, int] = { - EffortProfile.LIGHT: 1, - EffortProfile.MEDIUM: 2, - EffortProfile.HEAVY: 3, -} - -PROFILE_PASS_NAMES: dict[EffortProfile, str] = { - EffortProfile.LIGHT: "one-pass", - EffortProfile.MEDIUM: "two-pass", - EffortProfile.HEAVY: "three-pass", -} - -AI_LEASE_MARGIN_SECONDS: float = 5.0 - - -def calculate_min_worker_lease_seconds( - config: ScoringConfig, margin_seconds: float = AI_LEASE_MARGIN_SECONDS -) -> float | None: - pass_count = PROFILE_PASS_COUNTS.get(config.effort_profile) - if pass_count is None: - return None - return pass_count * config.ai_timeout_seconds + margin_seconds - - -def extract_normalized_features(snapshot: CanonicalSnapshot) -> NormalizedFeatures: - custom_fields = snapshot.lead_profile.custom_fields or {} - - # 1. student_age from custom_fields - student_age: int | None = None - raw_age = custom_fields.get("student_age") - if raw_age is not None: - try: - student_age = int(raw_age) - except (ValueError, TypeError): - student_age = None - - # 2. contact_message_recency MUST derive from newest sender_type=contact sent_at - contact_msgs = [ - msg - for conv in snapshot.conversations - for msg in conv.messages - if msg.sender_type == "contact" - ] - recency_days: float | None = None - recency_hours: float | None = None - if contact_msgs: - newest_sent_at = max(m.sent_at for m in contact_msgs) - delta_sec = max(0.0, (snapshot.captured_at - newest_sent_at).total_seconds()) - recency_days = round(delta_sec / 86400.0, 2) - recency_hours = round(delta_sec / 3600.0, 2) - - # 3. lead_source from custom_fields - raw_source = custom_fields.get("lead_source") - if raw_source is None and "source" in custom_fields: - raw_source = custom_fields["source"] - lead_source: str | None = ( - str(raw_source).strip() if raw_source is not None and str(raw_source).strip() else None - ) - - # 4. course_interest from custom_fields - raw_course = ( - custom_fields.get("course_interest") - or custom_fields.get("course") - or custom_fields.get("program") - or custom_fields.get("target_course") - ) - course_interest: str | None = ( - str(raw_course).strip() if raw_course is not None and str(raw_course).strip() else None - ) - - convs = snapshot.conversations - conversation_count = len(convs) - message_count = sum(len(c.messages) for c in convs) - customer_message_count = sum(1 for c in convs for m in c.messages if m.sender_type == "contact") - conversation_turn_count = sum( - 1 - + sum( - 1 - for i in range(1, len(c.messages)) - if c.messages[i].sender_type != c.messages[i - 1].sender_type - ) - if c.messages - else 0 - for c in convs - ) - customer_initiated_return = bool( - len(convs) > 1 - and any(c.messages and c.messages[0].sender_type == "contact" for c in convs[1:]) - ) - - return NormalizedFeatures( - student_age=student_age, - contact_message_recency_days=recency_days, - contact_message_recency_hours=recency_hours, - lead_source=lead_source, - course_interest=course_interest, - conversation_count=conversation_count, - message_count=message_count, - conversation_turn_count=conversation_turn_count, - customer_message_count=customer_message_count, - customer_initiated_return=customer_initiated_return, - ) - - -def select_behavioral_metrics( - features: NormalizedFeatures, - selected: Collection[BehavioralMetric | str] | None = None, -) -> dict[str, Any]: - all_metrics: dict[str, Any] = { - metric.value: getattr(features, metric.value) for metric in BehavioralMetric - } - if selected is None: - return all_metrics - selected_set = {str(metric) for metric in selected} - return {name: value for name, value in all_metrics.items() if name in selected_set} - - -def compute_behavioral_metrics( - snapshot: CanonicalSnapshot, - selected: Collection[BehavioralMetric | str] | None = None, -) -> dict[str, Any]: - return select_behavioral_metrics(extract_normalized_features(snapshot), selected) - - -def evaluate_education_rules( - snapshot: CanonicalSnapshot, - config: ScoringConfig, -) -> tuple[int, list[RuleBreakdownItem], NormalizedFeatures, DataCompleteness]: - features = extract_normalized_features(snapshot) - if not config.education_demo_enabled: - metrics = _snapshot_metrics(snapshot) - score = config.base_score - breakdown: list[RuleBreakdownItem] = [] - for rule in config.rules: - matched = metrics[rule.metric] >= rule.minimum - points = rule.points if matched else 0 - if matched: - score += points - breakdown.append( - RuleBreakdownItem( - rule_name=rule.metric.value, - status=( - RuleEvaluationStatus.MATCHED - if matched - else RuleEvaluationStatus.NOT_MATCHED - ), - points_awarded=points, - detail=( - f"Metric {rule.metric.value}={metrics[rule.metric]} " - f"(ngưỡng {rule.minimum}, +{points})" - if matched - else f"Metric {rule.metric.value}={metrics[rule.metric]} " - f"< ngưỡng {rule.minimum} (0)" - ), - ) - ) - if config.dynamic_rules: - dynamic_breakdowns = evaluate_dynamic_rules_collection( - config.dynamic_rules, snapshot, features - ) - breakdown.extend(dynamic_breakdowns) - score += sum(b.points_awarded for b in dynamic_breakdowns) - return ( - min(100, max(0, score)), - breakdown, - features, - DataCompleteness(completeness_ratio=1.0), - ) - - breakdown: list[RuleBreakdownItem] = [] - - if features.student_age is None: - breakdown.append( - RuleBreakdownItem( - rule_name="student_age", - status=RuleEvaluationStatus.SKIPPED_MISSING_DATA, - points_awarded=0, - detail="Chưa có tuổi học viên trong thông tin Lead", - ) - ) - elif config.age_primary_min <= features.student_age <= config.age_primary_max: - breakdown.append( - RuleBreakdownItem( - rule_name="student_age", - status=RuleEvaluationStatus.MATCHED, - points_awarded=config.age_primary_points, - detail=( - f"Tuổi học viên {features.student_age} thuộc nhóm chính " - f"[{config.age_primary_min}, {config.age_primary_max}] " - f"(+{config.age_primary_points})" - ), - ) - ) - elif config.age_secondary_min <= features.student_age <= config.age_secondary_max: - breakdown.append( - RuleBreakdownItem( - rule_name="student_age", - status=RuleEvaluationStatus.MATCHED, - points_awarded=config.age_secondary_points, - detail=( - f"Tuổi học viên {features.student_age} thuộc nhóm phụ " - f"[{config.age_secondary_min}, {config.age_secondary_max}] " - f"(+{config.age_secondary_points})" - ), - ) - ) - else: - breakdown.append( - RuleBreakdownItem( - rule_name="student_age", - status=RuleEvaluationStatus.NOT_MATCHED, - points_awarded=0, - detail="Tuổi học viên nằm ngoài các nhóm đã cấu hình (0)", - ) - ) - - if features.contact_message_recency_days is None: - breakdown.append( - RuleBreakdownItem( - rule_name="contact_message_recency", - status=RuleEvaluationStatus.SKIPPED_MISSING_DATA, - points_awarded=0, - detail="Chưa có tin nhắn nào từ phụ huynh trong hội thoại", - ) - ) - else: - days = features.contact_message_recency_days - recency_bands = ( - (config.recency_hot_max_days, config.recency_hot_points), - (config.recency_warm_max_days, config.recency_warm_points), - (config.recency_cool_max_days, config.recency_cool_points), - (config.recency_cold_max_days, config.recency_cold_points), - ) - matched_band = next( - ((maximum, points) for maximum, points in recency_bands if days <= maximum), - None, - ) - if matched_band is None: - breakdown.append( - RuleBreakdownItem( - rule_name="contact_message_recency", - status=RuleEvaluationStatus.NOT_MATCHED, - points_awarded=0, - detail=f"Tin nhắn gần nhất đã cách {days:.1f} ngày, vượt ngưỡng cấu hình (0)", - ) - ) - else: - maximum, points = matched_band - breakdown.append( - RuleBreakdownItem( - rule_name="contact_message_recency", - status=RuleEvaluationStatus.MATCHED, - points_awarded=points, - detail=( - f"Tin nhắn gần nhất cách đây {days:.1f} ngày, nằm trong " - f"<= {maximum:g}d band (+{points})" - ), - ) - ) - - if features.lead_source is None: - breakdown.append( - RuleBreakdownItem( - rule_name="lead_source", - status=RuleEvaluationStatus.SKIPPED_MISSING_DATA, - points_awarded=0, - detail="Chưa có nguồn của Lead trong thông tin đầu vào", - ) - ) - else: - source = features.lead_source.strip().lower().replace("_", "-") - if source in ("referral", "walk-in", "walkin"): - label, points = "Referral/Walk-in", config.source_referral_points - elif source in ("call-in", "callin", "hotline", "phone"): - label, points = "Call-in", config.source_call_in_points - elif source in ("digital", "website", "facebook", "zalo", "web", "online"): - label, points = "Digital", config.source_digital_points - elif source in ("campaign", "ads", "advertising", "event"): - label, points = "Campaign", config.source_campaign_points - else: - label, points = "Other", config.source_other_points - breakdown.append( - RuleBreakdownItem( - rule_name="lead_source", - status=RuleEvaluationStatus.MATCHED, - points_awarded=points, - detail=f"Nguồn Lead '{features.lead_source}' thuộc nhóm {label} (+{points})", - ) - ) - - # Data completeness: exactly four groups: age, course_interest, customer_message, source - has_age = features.student_age is not None - has_course = features.course_interest is not None - has_customer_msg = features.contact_message_recency_days is not None - has_source = features.lead_source is not None - - four_groups = [ - ("age", has_age), - ("course_interest", has_course), - ("customer_message", has_customer_msg), - ("source", has_source), - ] - present_fields = [name for name, is_present in four_groups if is_present] - missing_fields = [name for name, is_present in four_groups if not is_present] - completeness_ratio = round(len(present_fields) / 4.0, 2) - completeness = DataCompleteness( - present_fields=present_fields, - missing_fields=missing_fields, - completeness_ratio=completeness_ratio, - ) - - if config.dynamic_rules: - dynamic_breakdowns = evaluate_dynamic_rules_collection( - config.dynamic_rules, snapshot, features - ) - breakdown.extend(dynamic_breakdowns) - - total_points = sum(item.points_awarded for item in breakdown) - rule_score = min(100, max(0, config.base_score + total_points)) - - return rule_score, breakdown, features, completeness - - -def evaluate_rule_score(snapshot: CanonicalSnapshot, config: ScoringConfig) -> int: - score, _, _, _ = evaluate_education_rules(snapshot, config) - return score - - -def combine_final_score( - rule_score: int, - ai_score: int, - rule_weight: float, - ai_weight: float, -) -> int: - combined = round(rule_score * rule_weight + ai_score * ai_weight) - return min(100, max(0, int(combined))) - - -def combine_confidence( - rule_confidence: float, - ai_confidence: float, - rule_weight: float, - ai_weight: float, -) -> float: - combined = rule_confidence * rule_weight + ai_confidence * ai_weight - return min(1.0, max(0.0, round(combined, 4))) - - -def derive_outcome_fields( - status: str, - effective_score: int | None, - final_score: int | None, - rule_score: int | None, - ai_reasoning: str | None = None, - error_code: str | None = None, -) -> tuple[str | None, str | None, str | None]: - if status in ("pending", "running"): - return None, None, None - - if status == "failed": - category = "cold" - explanation = f"Không thể chấm điểm: {error_code}" if error_code else "Không thể chấm điểm." - recommended_action = "Kiểm tra lỗi và thử chấm điểm lại." - return category, explanation, recommended_action - - score = ( - effective_score - if effective_score is not None - else (final_score if final_score is not None else rule_score) - ) - - if score is None: - return None, None, None - - if score >= 76: - category = "hot" - recommended_action = "Liên hệ với Lead trong 24 giờ để xác nhận bước đăng ký tiếp theo." - elif score >= 26: - category = "warm" - recommended_action = "Gửi thông tin khóa học phù hợp và lên lịch chăm sóc tiếp theo." - else: - category = "cold" - recommended_action = "Đưa Lead vào luồng chăm sóc tự động và chờ thêm bằng chứng." - - explanation = f"Lead đạt {score}/100 sau khi kết hợp bộ quy tắc và phân tích hội thoại bằng AI." - return category, explanation, recommended_action - - -def _snapshot_metrics(snapshot: CanonicalSnapshot) -> dict[RuleMetric, int]: - profile = snapshot.lead_profile - profile_values: tuple[Any, ...] = (profile.name, profile.phone, profile.email, profile.status) - return { - RuleMetric.PROFILE_FIELD_COUNT: sum(value is not None for value in profile_values), - RuleMetric.CONVERSATION_COUNT: len(snapshot.conversations), - RuleMetric.MESSAGE_COUNT: sum( - len(conversation.messages) for conversation in snapshot.conversations - ), - } diff --git a/old_repo/src/lead_scoring/webhook/__init__.py b/old_repo/src/lead_scoring/webhook/__init__.py deleted file mode 100644 index a08bb19..0000000 --- a/old_repo/src/lead_scoring/webhook/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Webhook package for Lead Scoring event delivery and signing.""" diff --git a/old_repo/src/lead_scoring/webhook/dispatcher.py b/old_repo/src/lead_scoring/webhook/dispatcher.py deleted file mode 100644 index 80600d2..0000000 --- a/old_repo/src/lead_scoring/webhook/dispatcher.py +++ /dev/null @@ -1,517 +0,0 @@ -import asyncio -import logging -import urllib.parse -import uuid -from collections.abc import Callable, Sequence -from dataclasses import dataclass -from datetime import UTC, datetime, timedelta -from enum import StrEnum - -import httpx -from sqlalchemy import and_, or_, select, update -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker - -from lead_scoring.config import Settings -from lead_scoring.db.models.outbox_event import OutboxEvent -from lead_scoring.observability import Observability -from lead_scoring.schemas.webhook import ( - OutboxDeliveryStatus, - WebhookIngestResponse, -) -from lead_scoring.webhook.signing import ( - WEBHOOK_ENDPOINT_PATH, - canonical_json, - sanitize_secret, - sign_webhook, -) - -Clock = Callable[[], datetime] -logger = logging.getLogger(__name__) - - -def utc_now() -> datetime: - return datetime.now(UTC) - - -class DeliveryClassification(StrEnum): - DELIVERED = "delivered" - RETRYABLE = "retryable" - DEAD_LETTER = "dead_letter" - - -@dataclass(frozen=True) -class DeliveryResult: - classification: DeliveryClassification - http_status: int | None = None - error_code: str | None = None - detail: str | None = None - ingest_status: str | None = None - - -def calculate_backoff( - attempt_count: int, - base_seconds: float = 2.0, - max_seconds: float = 60.0, - multiplier: float = 2.0, -) -> float: - if attempt_count <= 0: - return 0.0 - delay = base_seconds * (multiplier ** max(0, attempt_count - 1)) - return min(delay, max_seconds) - - -def resolve_webhook_target_url(base_or_full_url: str) -> str: - parsed = urllib.parse.urlsplit(base_or_full_url) - if not parsed.scheme or not parsed.netloc: - raise ValueError(f"Invalid webhook URL '{base_or_full_url}': missing scheme or host") - if parsed.path not in ("", "/", WEBHOOK_ENDPOINT_PATH): - raise ValueError( - f"webhook URL path must be empty, '/', or '{WEBHOOK_ENDPOINT_PATH}', " - f"got '{parsed.path}'" - ) - return f"{parsed.scheme}://{parsed.netloc}{WEBHOOK_ENDPOINT_PATH}" - - -async def claim_outbox_events( - session: AsyncSession, - batch_size: int, - lease_seconds: float, - now: datetime, - max_attempts: int = 5, - observability: Observability | None = None, -) -> Sequence[OutboxEvent]: - lease_expires_at = now + timedelta(seconds=lease_seconds) - claim_token = str(uuid.uuid4()) - - # Terminalize expired delivering events that reached max attempts across crashes/timeouts - expired_exhausted_stmt = ( - update(OutboxEvent) - .where( - OutboxEvent.delivery_status == OutboxDeliveryStatus.DELIVERING.value, - OutboxEvent.lease_expires_at.is_not(None), - OutboxEvent.lease_expires_at <= now, - OutboxEvent.attempt_count >= max_attempts, - ) - .values( - delivery_status=OutboxDeliveryStatus.DEAD_LETTER.value, - next_attempt_at=None, - claim_token=None, - lease_expires_at=None, - last_error_code="retries_exhausted", - last_error_detail=( - f"Max retry limit ({max_attempts}) reached after process crash or lease expiry" - ), - ) - .returning(OutboxEvent.id) - ) - expired_ids = list((await session.execute(expired_exhausted_stmt)).scalars()) - if observability is not None and expired_ids: - observability.increment( - "outbox_dead_letter_total", - amount=len(expired_ids), - fields={"reason": "retries_exhausted"}, - ) - - candidate_subquery = ( - select(OutboxEvent.id) - .where( - or_( - and_( - OutboxEvent.delivery_status == OutboxDeliveryStatus.PENDING.value, - or_( - OutboxEvent.next_attempt_at.is_(None), - OutboxEvent.next_attempt_at <= now, - ), - OutboxEvent.attempt_count < max_attempts, - ), - and_( - OutboxEvent.delivery_status == OutboxDeliveryStatus.DELIVERING.value, - OutboxEvent.lease_expires_at.is_not(None), - OutboxEvent.lease_expires_at <= now, - OutboxEvent.attempt_count < max_attempts, - ), - ) - ) - .order_by(OutboxEvent.occurred_at.asc()) - .limit(batch_size) - .with_for_update(skip_locked=True) - .scalar_subquery() - ) - - stmt = ( - update(OutboxEvent) - .where(OutboxEvent.id.in_(candidate_subquery)) - .values( - delivery_status=OutboxDeliveryStatus.DELIVERING.value, - attempt_count=OutboxEvent.attempt_count + 1, - claim_token=claim_token, - lease_expires_at=lease_expires_at, - last_attempt_at=now, - ) - .returning(OutboxEvent) - ) - result = await session.execute(stmt) - return result.scalars().all() - - -async def dispatch_single_event( - event: OutboxEvent, - target_url: str, - signing_key_id: str, - signing_secret: str, - timeout_seconds: float, - http_client: httpx.AsyncClient, - now: datetime, -) -> DeliveryResult: - body_str = canonical_json(event.payload) - body_bytes = body_str.encode("utf-8") - timestamp_str = str(int(now.timestamp())) - event_id_str = str(event.id) - - signature = sign_webhook( - secret=signing_secret, - event_id=event_id_str, - timestamp=timestamp_str, - body=body_bytes, - ) - - headers = { - "Content-Type": "application/json", - "X-DotB-Webhook-Id": event_id_str, - "X-DotB-Webhook-Key-Id": signing_key_id, - "X-DotB-Webhook-Timestamp": timestamp_str, - "X-DotB-Webhook-Signature": signature, - } - - try: - response = await http_client.post( - target_url, - content=body_bytes, - headers=headers, - timeout=timeout_seconds, - ) - except httpx.TimeoutException: - return DeliveryResult( - classification=DeliveryClassification.RETRYABLE, - error_code="delivery_timeout", - detail="Webhook delivery timed out", - ) - except httpx.ConnectError: - return DeliveryResult( - classification=DeliveryClassification.RETRYABLE, - error_code="delivery_connection_error", - detail="Webhook connection failed", - ) - except httpx.RequestError as exc: - return DeliveryResult( - classification=DeliveryClassification.RETRYABLE, - error_code="delivery_network_error", - detail=sanitize_secret(f"Request error: {exc.__class__.__name__}", signing_secret), - ) - except Exception as exc: - return DeliveryResult( - classification=DeliveryClassification.RETRYABLE, - error_code="delivery_unknown_error", - detail=sanitize_secret(f"Unexpected error: {exc.__class__.__name__}", signing_secret), - ) - - status_code = response.status_code - if status_code == 200: - try: - resp_data = response.json() - ingest_resp = WebhookIngestResponse.model_validate(resp_data) - return DeliveryResult( - classification=DeliveryClassification.DELIVERED, - http_status=status_code, - ingest_status=ingest_resp.status, - ) - except Exception: - return DeliveryResult( - classification=DeliveryClassification.DEAD_LETTER, - error_code="invalid_receiver_response", - http_status=status_code, - detail=( - "Receiver returned HTTP 200 but body does not match " - "WebhookIngestResponse schema" - ), - ) - elif 200 < status_code < 300: - return DeliveryResult( - classification=DeliveryClassification.DEAD_LETTER, - error_code="invalid_receiver_response", - http_status=status_code, - detail=( - f"Receiver returned HTTP {status_code}; only HTTP 200 is valid ingestion response" - ), - ) - elif status_code == 400: - return DeliveryResult( - classification=DeliveryClassification.DEAD_LETTER, - error_code="receiver_bad_request", - http_status=status_code, - detail=sanitize_secret(response.text[:500], signing_secret), - ) - elif status_code == 401: - return DeliveryResult( - classification=DeliveryClassification.DEAD_LETTER, - error_code="receiver_unauthorized", - http_status=status_code, - detail=sanitize_secret(response.text[:500], signing_secret), - ) - elif status_code == 429: - return DeliveryResult( - classification=DeliveryClassification.RETRYABLE, - error_code="receiver_rate_limited", - http_status=status_code, - detail=sanitize_secret(response.text[:500], signing_secret), - ) - elif status_code >= 500: - return DeliveryResult( - classification=DeliveryClassification.RETRYABLE, - error_code="receiver_server_error", - http_status=status_code, - detail=sanitize_secret(response.text[:500], signing_secret), - ) - else: - return DeliveryResult( - classification=DeliveryClassification.DEAD_LETTER, - error_code="receiver_client_error", - http_status=status_code, - detail=sanitize_secret(response.text[:500], signing_secret), - ) - - -async def record_delivery_result( - session: AsyncSession, - event: OutboxEvent, - result: DeliveryResult, - max_attempts: int, - backoff_base_seconds: float, - backoff_max_seconds: float, - now: datetime, - claim_token: str | None = None, -) -> bool: - token_to_match = claim_token if claim_token is not None else event.claim_token - claim_condition = ( - OutboxEvent.claim_token == token_to_match - if token_to_match is not None - else OutboxEvent.claim_token.is_(None) - ) - fence = and_( - OutboxEvent.id == event.id, - OutboxEvent.delivery_status == OutboxDeliveryStatus.DELIVERING.value, - claim_condition, - ) - - if result.classification == DeliveryClassification.DELIVERED: - stmt = ( - update(OutboxEvent) - .where(fence) - .values( - delivery_status=OutboxDeliveryStatus.DELIVERED.value, - delivered_at=now, - next_attempt_at=None, - claim_token=None, - lease_expires_at=None, - last_http_status=result.http_status, - last_error_code=None, - last_error_detail=None, - ) - .returning(OutboxEvent.id) - ) - elif result.classification == DeliveryClassification.DEAD_LETTER: - stmt = ( - update(OutboxEvent) - .where(fence) - .values( - delivery_status=OutboxDeliveryStatus.DEAD_LETTER.value, - next_attempt_at=None, - claim_token=None, - lease_expires_at=None, - last_http_status=result.http_status, - last_error_code=result.error_code, - last_error_detail=result.detail, - ) - .returning(OutboxEvent.id) - ) - else: - # event.attempt_count was already incremented at claim time - if event.attempt_count >= max_attempts: - stmt = ( - update(OutboxEvent) - .where(fence) - .values( - delivery_status=OutboxDeliveryStatus.DEAD_LETTER.value, - next_attempt_at=None, - claim_token=None, - lease_expires_at=None, - last_http_status=result.http_status, - last_error_code="retries_exhausted", - last_error_detail=result.detail or f"Max retry limit ({max_attempts}) reached", - ) - .returning(OutboxEvent.id) - ) - else: - delay = calculate_backoff( - event.attempt_count, - base_seconds=backoff_base_seconds, - max_seconds=backoff_max_seconds, - ) - next_attempt_at = now + timedelta(seconds=delay) - stmt = ( - update(OutboxEvent) - .where(fence) - .values( - delivery_status=OutboxDeliveryStatus.PENDING.value, - next_attempt_at=next_attempt_at, - claim_token=None, - lease_expires_at=None, - last_http_status=result.http_status, - last_error_code=result.error_code, - last_error_detail=result.detail, - ) - .returning(OutboxEvent.id) - ) - res = await session.execute(stmt) - updated_id = res.scalar_one_or_none() - if updated_id is None: - logger.warning( - "Stale delivery result ignored for outbox event %s: " - "claim token mismatch or lease expired", - event.id, - ) - return False - return True - - -class WebhookDispatcher: - def __init__( - self, - session_factory: async_sessionmaker[AsyncSession], - settings: Settings, - *, - http_client: httpx.AsyncClient | None = None, - clock: Clock = utc_now, - observability: Observability | None = None, - ) -> None: - self._session_factory = session_factory - self._settings = settings - self._http_client = http_client - self._owns_http_client = http_client is None - self._clock = clock - self._observability = observability - self._task: asyncio.Task[None] | None = None - self._stopped = False - - async def _get_client(self) -> httpx.AsyncClient: - if self._http_client is None: - self._http_client = httpx.AsyncClient(timeout=self._settings.webhook_timeout_seconds) - return self._http_client - - async def dispatch_batch(self, batch_size: int | None = None) -> int: - if not self._settings.webhook_subscriber_url or not self._settings.webhook_signing_secret: - return 0 - - target_url = resolve_webhook_target_url(self._settings.webhook_subscriber_url) - signing_secret = self._settings.webhook_signing_secret.get_secret_value() - signing_key_id = self._settings.webhook_signing_key_id - resolved_batch_size = batch_size or self._settings.webhook_batch_size - lease_seconds = self._settings.webhook_lease_seconds - - client = await self._get_client() - processed_count = 0 - - for _ in range(resolved_batch_size): - now = self._clock() - async with self._session_factory() as session: - claimed_events = await claim_outbox_events( - session=session, - batch_size=1, - lease_seconds=lease_seconds, - now=now, - max_attempts=self._settings.webhook_max_attempts, - observability=self._observability, - ) - await session.commit() - - if not claimed_events: - break - - event = claimed_events[0] - attempt_now = self._clock() - result = await dispatch_single_event( - event=event, - target_url=target_url, - signing_key_id=signing_key_id, - signing_secret=signing_secret, - timeout_seconds=self._settings.webhook_timeout_seconds, - http_client=client, - now=attempt_now, - ) - async with self._session_factory() as session: - recorded = await record_delivery_result( - session=session, - event=event, - result=result, - max_attempts=self._settings.webhook_max_attempts, - backoff_base_seconds=self._settings.webhook_backoff_base_seconds, - backoff_max_seconds=self._settings.webhook_backoff_max_seconds, - now=attempt_now, - claim_token=event.claim_token, - ) - await session.commit() - - if self._observability is not None and recorded: - self._observability.increment( - "outbox_deliveries_total", - run_id=event.run_id, - tags={"classification": result.classification.value}, - ) - if result.classification == DeliveryClassification.RETRYABLE: - self._observability.increment( - "outbox_retries_total", - run_id=event.run_id, - tags={"error_code": result.error_code or "unknown"}, - ) - elif result.classification == DeliveryClassification.DEAD_LETTER: - self._observability.increment( - "outbox_dead_letter_total", - run_id=event.run_id, - tags={"error_code": result.error_code or "unknown"}, - ) - - processed_count += 1 - - return processed_count - - def start(self) -> None: - if self._task is None and self._settings.webhook_subscriber_url: - self._stopped = False - self._task = asyncio.create_task(self._loop()) - - async def stop(self) -> None: - self._stopped = True - if self._task is not None: - self._task.cancel() - await asyncio.gather(self._task, return_exceptions=True) - self._task = None - if self._owns_http_client and self._http_client is not None: - await self._http_client.aclose() - self._http_client = None - - async def _loop(self) -> None: - while not self._stopped: - try: - processed = await self.dispatch_batch() - if processed > 0: - continue - except asyncio.CancelledError: - break - except Exception as exc: - logger.exception("Webhook dispatch loop iteration failed") - if self._observability is not None: - self._observability.record_exception("webhook_dispatch_loop", exc) - try: - await asyncio.sleep(self._settings.webhook_dispatch_interval_seconds) - except asyncio.CancelledError: - break diff --git a/old_repo/src/lead_scoring/webhook/signing.py b/old_repo/src/lead_scoring/webhook/signing.py deleted file mode 100644 index a7c8b81..0000000 --- a/old_repo/src/lead_scoring/webhook/signing.py +++ /dev/null @@ -1,33 +0,0 @@ -import hashlib -import hmac -import json -from typing import Any - -WEBHOOK_METHOD = "POST" -WEBHOOK_ENDPOINT_PATH = "/api/v1/internal/lead-scoring/events" -WEBHOOK_REPLAY_WINDOW_SECONDS = 300 -WEBHOOK_SIGNATURE_ALGORITHM = "HMAC-SHA256" -WEBHOOK_SIGNATURE_VERSION = "v1" -WEBHOOK_CANONICAL_INPUT = ".." -TEST_SIGNING_SECRET = "whsec_test_contract_v1_not_for_production" - - -def canonical_json(value: Any) -> str: - return json.dumps(value, indent=2, sort_keys=True) + "\n" - - -def signing_input(event_id: str, timestamp: str, body: bytes) -> bytes: - return event_id.encode() + b"." + timestamp.encode() + b"." + body - - -def sign_webhook(secret: str, event_id: str, timestamp: str, body: bytes) -> str: - digest = hmac.new( - secret.encode(), signing_input(event_id, timestamp, body), hashlib.sha256 - ).hexdigest() - return f"v1={digest}" - - -def sanitize_secret(text: str, secret: str | None) -> str: - if not secret: - return text - return text.replace(secret, "[REDACTED]") diff --git a/old_repo/stack.md b/old_repo/stack.md deleted file mode 100644 index 63966bd..0000000 --- a/old_repo/stack.md +++ /dev/null @@ -1,113 +0,0 @@ -# Technology Stack - -> This document records technology decisions, default candidates, deferred options, and trade-offs. It does not override the approved Lead Scoring Customer Pilot specification or released executable contracts. - -## Project Context and Boundaries - -- Purpose: Build the first Python-based Lead Scoring service in AI Platform for a controlled Customer Pilot. -- Shape: Workspace containing specifications, documentation, and the initialized baseline for the Lead Scoring service (`src/lead_scoring/`). -- Material constraints: - - DotB EMS remains the CRM source of truth. - - Omni owns EMS integration, identity resolution, conversation enrichment, Lead Scoring configuration, and HITL UI. - - Lead Scoring owns scoring logic, versioned configuration, scoring-run history, and domain events. - - AIHub owns model access, V-Key validation, quota, credit, and LLM audit. - - Global runtimes and the shared `mcpls` configuration remain outside project bootstrap scope. - -## Active Decisions - -### Service runtime and HTTP framework - -- Status: Implemented baseline in `src/lead_scoring/`. -- Primary decision owner: Lead Scoring service. -- Technology: Python 3.13, `uv`, FastAPI, and Pydantic. -- Rationale: The service is Python-first, Pydantic provides executable validation, and FastAPI derives the API contract from the same models. -- Baseline implementation: `src/lead_scoring/` package, `GET /health` endpoint, async lifespan managing database engine and session factory. - -### API contract and generated client - -- Status: Implemented baseline CLI for OpenAPI generation. -- Primary decision owner: Lead Scoring API contract. -- Technology: Pydantic models as the API source of truth, FastAPI-generated OpenAPI, CLI generator with `--check` stale detection. -- Rationale: One executable backend contract avoids handwritten DTO and frontend client drift. -- Constraints: Generated artifacts (`openapi.json`) are ignored derived artifacts produced deterministically by `uv run generate-openapi` and verified for drift via `--check`. Never edit manually. - -### Shared executable Contract Pack - -- Status: Selected pre-release baseline for the Customer Pilot. -- Primary decision owner: AI Platform, with DotB-Omni review and consumer validation. -- Technology: Versioned committed directory at `contracts/lead-scoring-contract-pack/v1/`, generated from Pydantic models, OpenAPI and shared JSON fixtures. -- Rationale: One pinned artifact corpus lets provider and consumer repositories validate the same exact wire shapes without maintaining competing payload definitions. -- Versioning: Consumers pin `contract_pack_version` together with the AI Platform Git commit. Additive changes preserve the API major and existing required fields/enums; breaking changes publish a new major directory and retain the old pack. -- Verification: `uv run generate-contract-pack --validate-fixtures` validates positive and negative provider fixtures, while `uv run generate-contract-pack --check` detects missing, stale or unexpected artifacts. - -### Persistence engine and migration tooling - -- Status: Adopted and implemented baseline. -- Primary decision owner: Lead Scoring persistence and delivery boundary. -- Technology: PostgreSQL 16+, SQLAlchemy 2 async (`asyncpg`), Alembic async migration lifecycle. -- Rationale: Production requires PostgreSQL-native features (transactional outbox, jsonb, concurrency safety). SQLite fallback is strictly prohibited to prevent dialect divergence. -- Verification: Database integration tests execute real Alembic `upgrade head` and `downgrade base` lifecycle against disposable PostgreSQL 16 containers via Docker testcontainers. SQLite and ambient database fallbacks are strictly prohibited. - -### Verification and repository command surface - -- Status: Implemented baseline command surface. -- Primary decision owner: Root-level generation and verification workflow. -- Selected baseline: `uv` lockfile, Ruff, Pyright, `ty` for fast feedback, pytest (with `pytest-asyncio` and `testcontainers[postgres]`). -- Rationale: A small command surface serves local development, agents, hooks, and CI without adding a task orchestrator. -- Constraints: Pyright remains the authoritative type-checking reference. `ty` is non-authoritative fast feedback. - -### AI integration and credential boundary - -- Status: Adopted by the approved specification. -- Primary decision owner: Lead Scoring and AIHub service boundary. -- Technology: Bounded fixed-pass scoring pipelines; caller-provided `X-Tenant-VKey` held in memory by Lead Scoring and forwarded to AIHub as a Bearer token. -- Rationale: AIHub remains the single owner of model access and quota while Lead Scoring avoids persistent raw V-Key storage and an extra resolver service. -- Constraints: Raw V-Keys must not enter databases, queues, caches, logs, crash dumps, or browser code. Autonomous tools and unbounded agent loops are outside the pilot. - -### Pilot durability and event delivery - -- Status: Selected pattern; persistence baseline adopted with PostgreSQL. -- Primary decision owner: Lead Scoring persistence and delivery boundary. -- Technology: Durable scoring runs plus transactional outbox and registered webhook delivery on PostgreSQL; no dedicated message broker for the pilot. -- Rationale: This preserves run durability and retryable delivery without adding Kafka, RabbitMQ, or another operational subsystem before the workload requires one. -- Open work: Implement retry worker, outbox table schema, and webhook delivery on the adopted PostgreSQL persistence baseline. - -### Pilot scoring execution topology - -- Status: Adopted and implemented for rule-only execution. -- Primary decision owner: Lead Scoring runtime lifecycle. -- Technology: In-process `asyncio` tasks with PostgreSQL conditional claims and expiring run leases. -- Rationale: This is the smallest topology that keeps the raw tenant V-Key in the live attempt only, prevents duplicate claims, and lets database recovery terminalize orphaned work without adding a broker. -- Constraints: A process or task loss cannot resume the attempt because the V-Key is not durable. Recovery commits `worker_interrupted` with `retryable=true` and a terminal outbox event; retry is a new admission action. - -### Frontend toolchain - -- Status: Selected for the internal Dev Console; not implemented. -- Primary decision owner: AI Platform internal console, with Omni owning customer-facing configuration and HITL UI. -- Technology: Svelte 5, TypeScript, Bun, Vite, Vitest, `@sveltejs/vite-plugin-svelte`, `svelte-check`, `@sveltejs/mcp`, ESLint, and Prettier Svelte tooling. -- Rationale: This provides a small client for generated OpenAPI types without adding SvelteKit before routing, SSR, or deployment requires it. -- Constraints: The console must not duplicate Omni business UI. It uses a backend-only internal test V-Key separate from production Omni tenant credentials; raw V-Key never reaches browser code. Add a Svelte language server only if `mcpls` must serve `.svelte` files semantically. - -### Pilot trigger boundary - -- Status: Adopted by the approved specification. -- Production initiation: Manual Omni action and Captain `create_and_score_ems_lead` tool invocation. -- Internal initiation: AI Platform Dev Console using a separate backend-only test V-Key. -- Deferred: Generic automatic input-event triggers such as message-created, conversation-resolved, or EMS lifecycle events. -- Constraint: Completion events from Lead Scoring to Omni remain required and are distinct from deferred input triggers. - -## Evaluated Options - -- TypeScript 7 native compiler and LSP: `candidate` - Run project-locally as `tsc --lsp --stdio` or `tsgo --lsp --stdio`. Do not change global TypeScript, Node, or `mcpls` defaults before a project-local pilot. -- `typescript-language-server`: `current` - Existing `mcpls` backend for TypeScript and JavaScript. Its push-only diagnostics behavior does not satisfy the current `mcpls` pull-diagnostics path; upgrading the wrapper alone is not the fix. -- `svelte-language-server`: `deferred` - Add only if `mcpls` must provide semantic LSP support for `.svelte`. -- SvelteKit: `deferred` - Add only when production routing, SSR, or deployment requires it. -- `oasdiff`: `deferred` - Adopt as a blocking compatibility check after the first reviewed and versioned OpenAPI baseline exists. -- Schemathesis: `deferred` - Adopt when the real ASGI app, declared error responses, deterministic authentication, and external-service overrides exist. -- Dedicated broker such as Kafka or RabbitMQ: `deferred` - Transactional outbox and registered webhooks are sufficient for the Customer Pilot boundary. -- Monorepo orchestrator, second formatter or linter, generic agent framework, or custom build system: `rejected` - No current requirement justifies the maintenance and duplicate source-of-truth cost. - -## Unresolved Decisions - -- Production deployment and service-to-service security: Pilot caller authentication is the approved per-app opaque Bearer service-token pattern; define hosting, network exposure, per-environment secret delivery, rotation/revocation, and operational ownership before Customer Pilot deployment. -- TypeScript 7 adoption: Decide whether TS7 is the project compiler only or also the `mcpls` language-server backend after a project-local pilot verifies build, typecheck, diagnostics, and compatibility with the selected Svelte tooling. diff --git a/old_repo/tests/conftest.py b/old_repo/tests/conftest.py deleted file mode 100644 index 5867eac..0000000 --- a/old_repo/tests/conftest.py +++ /dev/null @@ -1,57 +0,0 @@ -from collections.abc import Generator - -import pytest - -# Attempt import of testcontainers -postgres_container_cls = None -try: - try: - from testcontainers.community.postgres import PostgresContainer as _PostgresContainer - - postgres_container_cls = _PostgresContainer - except ImportError: - from testcontainers.postgres import PostgresContainer as _PostgresContainer - - postgres_container_cls = _PostgresContainer -except ImportError: - postgres_container_cls = None - - -@pytest.fixture(scope="session") -def postgres_container_url() -> Generator[str]: - """Provides a disposable PostgreSQL 16 container URL strictly via Docker testcontainers. - - Scoped to session to avoid duplicating container startup across tests. - No ambient admin or SQLite fallbacks. - """ - if postgres_container_cls is None: - raise RuntimeError( - "testcontainers package is required for PostgreSQL integration tests. " - "SQLite/localhost fallbacks are strictly prohibited." - ) - - try: - container = postgres_container_cls("postgres:16-alpine") - container.start() - host = container.get_container_host_ip() - port = container.get_exposed_port(5432) - user = container.username - password = container.password - dbname = container.dbname - url = f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{dbname}" - try: - yield url - finally: - container.stop() - except Exception as exc: - raise RuntimeError( - f"Failed to start PostgreSQL testcontainer: {exc}. " - "Docker daemon must be running and accessible for integration tests. " - "SQLite/localhost fallbacks are prohibited." - ) from exc - - -@pytest.fixture -def temp_postgres_db(postgres_container_url: str) -> str: - """Yields disposable PostgreSQL container database URL.""" - return postgres_container_url diff --git a/old_repo/tests/e2e/api/lead-scoring-local.postman_collection.json b/old_repo/tests/e2e/api/lead-scoring-local.postman_collection.json deleted file mode 100644 index 5d8579a..0000000 --- a/old_repo/tests/e2e/api/lead-scoring-local.postman_collection.json +++ /dev/null @@ -1,823 +0,0 @@ -{ - "info": { - "_postman_id": "d8d8b12c-6e26-4a2c-9e6d-1cba8e9e6c41", - "name": "Lead Scoring API - Local Demo & Testing", - "description": "Bộ Postman Collection đầy đủ các kịch bản demo cho Lead Scoring Service:\n1. Chấm điểm Lead chuẩn (Happy Path - Hot Lead)\n2. Xem chi tiết tính toán AI & Quy tắc (Trace)\n3. Quy trình duyệt con người khi thiếu dữ liệu (HITL Review Required & Approve)\n4. Điều chỉnh điểm số thủ công (HITL Override)\n5. Kiểm soát biên & Chặn lỗi (422 Insufficient Evidence, 409 Version Conflict, 401 Invalid Caller)", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "item": [ - { - "name": "00. Hệ thống", - "item": [ - { - "name": "01 Kiểm tra dịch vụ (Health Check)", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{base_url}}/health", - "host": [ - "{{base_url}}" - ], - "path": [ - "health" - ] - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Health endpoint trả về HTTP 200', function () {", - " pm.response.to.have.status(200);", - "});", - "const body = pm.response.json();", - "pm.test('Database đã kết nối', function () {", - " pm.expect(body.status).to.eql('ok');", - " pm.expect(body.database).to.eql('connected');", - "});" - ] - } - } - ] - } - ] - }, - { - "name": "01. Luồng chấm điểm Lead chuẩn (Hot Lead)", - "item": [ - { - "name": "02 Tiếp nhận chấm điểm (Admit Run - Hot Lead)", - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{service_token}}", - "type": "text" - }, - { - "key": "X-Tenant-VKey", - "value": "{{tenant_vkey}}", - "type": "text" - }, - { - "key": "X-DotB-App", - "value": "{{dotb_app}}", - "type": "text" - }, - { - "key": "X-DotB-Feature", - "value": "{{dotb_feature}}", - "type": "text" - }, - { - "key": "Idempotency-Key", - "value": "{{admission_idempotency_key}}", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"scoring_config_version\": \"{{scoring_config_version}}\",\n \"tenant_id\": \"{{tenant_id}}\",\n \"subject_ref\": \"ems-lead-001\",\n \"snapshot\": {\n \"version\": \"1.0.0\",\n \"scope\": \"linked_contact_history\",\n \"captured_at\": \"2026-09-04T06:00:00Z\",\n \"lead_profile\": {\n \"ems_lead_id\": \"ems-lead-001\",\n \"name\": \"Nguyễn Minh Anh\",\n \"custom_fields\": {\n \"student_age\": 12,\n \"course_interest\": \"Tiếng Anh giao tiếp thiếu nhi\",\n \"lead_source\": \"Digital\",\n \"study_area\": \"Quận 3, TP.HCM\"\n }\n },\n \"conversations\": [\n {\n \"conversation_id\": \"conv-001\",\n \"channel\": \"zalo\",\n \"assigned_agent_id\": \"agent-10\",\n \"messages\": [\n {\n \"message_id\": \"msg-001\",\n \"sender_type\": \"contact\",\n \"sent_at\": \"2026-09-04T05:00:00Z\",\n \"content\": \"Chào em, chị muốn tìm lớp tiếng Anh giao tiếp cho bé 12 tuổi. Bé đang học trường quốc tế và hơi ngại nói trước lớp.\"\n },\n {\n \"message_id\": \"msg-002\",\n \"sender_type\": \"agent\",\n \"sent_at\": \"2026-09-04T05:05:00Z\",\n \"content\": \"Dạ em hiểu rồi ạ. Với độ tuổi của bé, lớp giao tiếp theo nhóm nhỏ tối đa 8 bạn sẽ giúp bé tự tin hơn. Chị muốn tìm lịch học vào ngày nào ạ?\"\n },\n {\n \"message_id\": \"msg-003\",\n \"sender_type\": \"contact\",\n \"sent_at\": \"2026-09-04T05:15:00Z\",\n \"content\": \"Chị ưu tiên học cuối tuần. Nếu phù hợp chị muốn cho bé học thử sáng thứ Bảy tuần này luôn nhé.\"\n }\n ]\n }\n ],\n \"trigger_metadata\": {\n \"trigger_type\": \"manual_omni\",\n \"trigger_conversation_id\": \"conv-001\",\n \"actor_id\": \"agent-10\"\n }\n }\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{base_url}}/v1/scoring-runs", - "host": [ - "{{base_url}}" - ], - "path": [ - "v1", - "scoring-runs" - ] - } - }, - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "exec": [ - "pm.environment.set('admission_idempotency_key', 'postman-' + pm.variables.replaceIn('{{$guid}}'));", - "pm.environment.unset('run_id');", - "pm.environment.set('run_version', '1');", - "pm.environment.set('poll_attempt', '0');", - "pm.environment.unset('terminal_status');" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Tiếp nhận thành công HTTP 202', function () {", - " pm.response.to.have.status(202);", - "});", - "const body = pm.response.json();", - "pm.test('Trả về mã phiên run_id và trạng thái pending', function () {", - " pm.expect(body.run_id).to.be.a('string').and.not.empty;", - " pm.expect(body.status).to.eql('pending');", - " pm.expect(body.status_url).to.be.a('string').and.not.empty;", - "});", - "pm.environment.set('run_id', body.run_id);", - "pm.environment.set('run_version', '1');" - ] - } - } - ] - }, - { - "name": "03 Theo dõi kết quả chấm điểm (Poll until Terminal)", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{service_token}}", - "type": "text" - }, - { - "key": "X-Tenant-Id", - "value": "{{tenant_id}}", - "type": "text" - } - ], - "url": { - "raw": "{{base_url}}/v1/scoring-runs/{{run_id}}", - "host": [ - "{{base_url}}" - ], - "path": [ - "v1", - "scoring-runs", - "{{run_id}}" - ] - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Polling trả về HTTP 200', function () {", - " pm.response.to.have.status(200);", - "});", - "const body = pm.response.json();", - "const terminalStatuses = ['completed', 'review_required', 'failed', 'superseded'];", - "const validStatuses = ['pending', 'running'].concat(terminalStatuses);", - "pm.test('Trạng thái hợp lệ', function () {", - " pm.expect(validStatuses).to.include(body.status);", - "});", - "if (body.version) {", - " pm.environment.set('run_version', String(body.version));", - "}", - "if (terminalStatuses.includes(body.status)) {", - " pm.environment.set('terminal_status', body.status);", - " pm.test('Đã hoàn thành phiên chấm điểm (' + body.status + ')', function () {", - " pm.expect(terminalStatuses).to.include(body.status);", - " });", - " if (body.status === 'completed') {", - " pm.test('Có điểm số và phân loại rõ ràng', function () {", - " pm.expect(body.final_score).to.be.within(0, 100);", - " pm.expect(body.category).to.be.oneOf(['hot', 'warm', 'cold']);", - " pm.expect(body.explanation).to.be.a('string').and.not.empty;", - " pm.expect(body.recommended_action).to.be.a('string').and.not.empty;", - " });", - " }", - "} else {", - " const attempt = Number(pm.environment.get('poll_attempt') || '0') + 1;", - " const maxAttempts = Number(pm.environment.get('max_poll_attempts') || '30');", - " pm.environment.set('poll_attempt', String(attempt));", - " if (attempt < maxAttempts) {", - " setTimeout(function () {", - " pm.execution.setNextRequest('03 Theo dõi kết quả chấm điểm (Poll until Terminal)');", - " }, 1000);", - " } else {", - " pm.test('Hết thời gian chờ phiên xử lý', function () {", - " pm.expect(body.status).to.be.oneOf(terminalStatuses);", - " });", - " pm.execution.setNextRequest(null);", - " }", - "}" - ] - } - } - ] - }, - { - "name": "04 Xem chi tiết quá trình tính toán (Internal Trace)", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{service_token}}", - "type": "text" - }, - { - "key": "X-Tenant-Id", - "value": "{{tenant_id}}", - "type": "text" - } - ], - "url": { - "raw": "{{base_url}}/v1/scoring-runs/{{run_id}}/trace", - "host": [ - "{{base_url}}" - ], - "path": [ - "v1", - "scoring-runs", - "{{run_id}}", - "trace" - ] - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Trace trả về HTTP 200', function () {", - " pm.response.to.have.status(200);", - "});", - "const body = pm.response.json();", - "pm.test('Có đủ dữ liệu phân tích quy tắc và AI', function () {", - " pm.expect(body.rule_breakdown).to.be.an('array');", - " pm.expect(body.data_completeness).to.be.an('object');", - " pm.expect(body.calculation).to.be.an('object');", - " pm.expect(body.calculation.formula).to.be.a('string');", - "});" - ] - } - } - ] - } - ] - }, - { - "name": "02. Luồng duyệt con người (HITL - Cần duyệt & Phê duyệt)", - "item": [ - { - "name": "05 Tiếp nhận Lead thiếu thông tin (Tạo trạng thái Cần duyệt)", - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{service_token}}", - "type": "text" - }, - { - "key": "X-Tenant-VKey", - "value": "{{tenant_vkey}}", - "type": "text" - }, - { - "key": "X-DotB-App", - "value": "{{dotb_app}}", - "type": "text" - }, - { - "key": "X-DotB-Feature", - "value": "{{dotb_feature}}", - "type": "text" - }, - { - "key": "Idempotency-Key", - "value": "postman-low-comp-{{$guid}}", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"scoring_config_version\": \"{{scoring_config_version}}\",\n \"tenant_id\": \"{{tenant_id}}\",\n \"subject_ref\": \"ems-lead-002\",\n \"snapshot\": {\n \"version\": \"1.0.0\",\n \"scope\": \"linked_contact_history\",\n \"captured_at\": \"2026-09-04T06:00:00Z\",\n \"lead_profile\": {\n \"ems_lead_id\": \"ems-lead-002\",\n \"name\": \"Khách Vãng Lai\",\n \"custom_fields\": {}\n },\n \"conversations\": [\n {\n \"conversation_id\": \"conv-002\",\n \"channel\": \"zalo\",\n \"assigned_agent_id\": \"agent-10\",\n \"messages\": [\n {\n \"message_id\": \"msg-002-1\",\n \"sender_type\": \"contact\",\n \"sent_at\": \"2026-09-04T05:00:00Z\",\n \"content\": \"Alo tư vấn học phí giúp tôi với\"\n }\n ]\n }\n ],\n \"trigger_metadata\": {\n \"trigger_type\": \"manual_omni\",\n \"trigger_conversation_id\": \"conv-002\",\n \"actor_id\": \"agent-10\"\n }\n }\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{base_url}}/v1/scoring-runs", - "host": [ - "{{base_url}}" - ], - "path": [ - "v1", - "scoring-runs" - ] - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Tiếp nhận thành công HTTP 202', function () {", - " pm.response.to.have.status(202);", - "});", - "const body = pm.response.json();", - "pm.environment.set('review_run_id', body.run_id);", - "pm.environment.set('review_run_version', '1');" - ] - } - } - ] - }, - { - "name": "06 Chờ phiên đạt trạng thái Cần duyệt (review_required)", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{service_token}}", - "type": "text" - }, - { - "key": "X-Tenant-Id", - "value": "{{tenant_id}}", - "type": "text" - } - ], - "url": { - "raw": "{{base_url}}/v1/scoring-runs/{{review_run_id}}", - "host": [ - "{{base_url}}" - ], - "path": [ - "v1", - "scoring-runs", - "{{review_run_id}}" - ] - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Polling trả về HTTP 200', function () {", - " pm.response.to.have.status(200);", - "});", - "const body = pm.response.json();", - "if (body.status === 'review_required') {", - " pm.test('Đã đạt trạng thái CẦN DUYỆT (review_required) do độ đầy đủ < 50%', function () {", - " pm.expect(body.status).to.eql('review_required');", - " });", - " if (body.version) pm.environment.set('review_run_version', String(body.version));", - "} else if (body.status === 'pending' || body.status === 'running') {", - " setTimeout(function () {", - " pm.execution.setNextRequest('06 Chờ phiên đạt trạng thái Cần duyệt (review_required)');", - " }, 1000);", - "}" - ] - } - } - ] - }, - { - "name": "07 Phê duyệt kết quả (HITL Approve Review)", - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{service_token}}", - "type": "text" - }, - { - "key": "X-Tenant-Id", - "value": "{{tenant_id}}", - "type": "text" - }, - { - "key": "Idempotency-Key", - "value": "review-approve-{{$guid}}", - "type": "text" - }, - { - "key": "X-DotB-App", - "value": "{{dotb_app}}", - "type": "text" - }, - { - "key": "X-DotB-Feature", - "value": "{{dotb_feature}}", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"decision\": \"approve\",\n \"expected_run_version\": {{review_run_version}},\n \"note\": \"Tư vấn viên đã gọi điện bổ sung thông tin và duyệt giữ điểm tính toán.\",\n \"reviewer\": {\n \"actor_id\": \"agent-10\",\n \"role\": \"agent\",\n \"current_assignee_id\": \"agent-10\"\n }\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{base_url}}/v1/scoring-runs/{{review_run_id}}/reviews", - "host": [ - "{{base_url}}" - ], - "path": [ - "v1", - "scoring-runs", - "{{review_run_id}}", - "reviews" - ] - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Phê duyệt thành công HTTP 200', function () {", - " pm.response.to.have.status(200);", - "});", - "const body = pm.response.json();", - "pm.test('Trạng thái chuyển sang completed và gán điểm hiệu lực', function () {", - " pm.expect(body.status).to.eql('completed');", - " pm.expect(body.decision).to.eql('approve');", - " pm.expect(body.effective_score).to.eql(body.calculated_score);", - " pm.expect(body.version).to.eql(Number(pm.environment.get('review_run_version')) + 1);", - "});", - "if (body.version) pm.environment.set('review_run_version', String(body.version));" - ] - } - } - ] - }, - { - "name": "08 Điều chỉnh điểm thủ công (HITL Override Score)", - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{service_token}}", - "type": "text" - }, - { - "key": "X-Tenant-Id", - "value": "{{tenant_id}}", - "type": "text" - }, - { - "key": "Idempotency-Key", - "value": "review-override-{{$guid}}", - "type": "text" - }, - { - "key": "X-DotB-App", - "value": "{{dotb_app}}", - "type": "text" - }, - { - "key": "X-DotB-Feature", - "value": "{{dotb_feature}}", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"decision\": \"override\",\n \"expected_run_version\": {{review_run_version}},\n \"override_score\": 88,\n \"note\": \"Khách đã đóng cọc trực tiếp tại cơ sở, nâng điểm lên 88.\",\n \"reviewer\": {\n \"actor_id\": \"agent-10\",\n \"role\": \"agent\",\n \"current_assignee_id\": \"agent-10\"\n }\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{base_url}}/v1/scoring-runs/{{review_run_id}}/reviews", - "host": [ - "{{base_url}}" - ], - "path": [ - "v1", - "scoring-runs", - "{{review_run_id}}", - "reviews" - ] - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Điều chỉnh điểm thành công HTTP 200', function () {", - " pm.response.to.have.status(200);", - "});", - "const body = pm.response.json();", - "pm.test('Điểm hiệu lực cập nhật theo điểm ghi đè', function () {", - " pm.expect(body.decision).to.eql('override');", - " pm.expect(body.effective_score).to.eql(88);", - "});" - ] - } - } - ] - } - ] - }, - { - "name": "03. Kiểm soát biên & Chặn lỗi (Guardrails & Errors)", - "item": [ - { - "name": "09 Chặn thiếu bằng chứng chấm điểm (HTTP 422)", - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{service_token}}", - "type": "text" - }, - { - "key": "X-Tenant-VKey", - "value": "{{tenant_vkey}}", - "type": "text" - }, - { - "key": "X-DotB-App", - "value": "{{dotb_app}}", - "type": "text" - }, - { - "key": "X-DotB-Feature", - "value": "{{dotb_feature}}", - "type": "text" - }, - { - "key": "Idempotency-Key", - "value": "err-evidence-{{$guid}}", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"scoring_config_version\": \"{{scoring_config_version}}\",\n \"tenant_id\": \"{{tenant_id}}\",\n \"subject_ref\": \"ems-lead-empty\",\n \"snapshot\": {\n \"version\": \"1.0.0\",\n \"scope\": \"linked_contact_history\",\n \"captured_at\": \"2026-09-04T06:00:00Z\",\n \"lead_profile\": {\n \"ems_lead_id\": \"ems-lead-empty\",\n \"name\": \"Lead Rỗng\",\n \"custom_fields\": {}\n },\n \"conversations\": []\n }\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{base_url}}/v1/scoring-runs", - "host": [ - "{{base_url}}" - ], - "path": [ - "v1", - "scoring-runs" - ] - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Bị chặn ở cửa tiếp nhận HTTP 422', function () {", - " pm.response.to.have.status(422);", - "});", - "const body = pm.response.json();", - "pm.test('Mã lỗi là insufficient_scoring_evidence', function () {", - " pm.expect(body.error_code).to.eql('insufficient_scoring_evidence');", - "});" - ] - } - } - ] - }, - { - "name": "10 Chặn xung đột phiên bản duyệt cũ (HTTP 409)", - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{service_token}}", - "type": "text" - }, - { - "key": "X-Tenant-Id", - "value": "{{tenant_id}}", - "type": "text" - }, - { - "key": "Idempotency-Key", - "value": "err-conflict-{{$guid}}", - "type": "text" - }, - { - "key": "X-DotB-App", - "value": "{{dotb_app}}", - "type": "text" - }, - { - "key": "X-DotB-Feature", - "value": "{{dotb_feature}}", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"decision\": \"approve\",\n \"expected_run_version\": 999,\n \"note\": \"Duyệt với phiên bản cũ sai lệch.\",\n \"reviewer\": {\n \"actor_id\": \"agent-10\",\n \"role\": \"agent\",\n \"current_assignee_id\": \"agent-10\"\n }\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{base_url}}/v1/scoring-runs/{{review_run_id}}/reviews", - "host": [ - "{{base_url}}" - ], - "path": [ - "v1", - "scoring-runs", - "{{review_run_id}}", - "reviews" - ] - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Bị chặn xung đột phiên bản HTTP 409', function () {", - " pm.response.to.have.status(409);", - "});", - "const body = pm.response.json();", - "pm.test('Mã lỗi là stale_run_version', function () {", - " pm.expect(body.error_code).to.eql('stale_run_version');", - "});" - ] - } - } - ] - }, - { - "name": "11 Chặn caller không hợp lệ (HTTP 401)", - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer invalid-service-token", - "type": "text" - }, - { - "key": "X-Tenant-Id", - "value": "{{tenant_id}}", - "type": "text" - }, - { - "key": "Idempotency-Key", - "value": "err-forbidden-{{$guid}}", - "type": "text" - }, - { - "key": "X-DotB-App", - "value": "{{dotb_app}}", - "type": "text" - }, - { - "key": "X-DotB-Feature", - "value": "{{dotb_feature}}", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"decision\": \"approve\",\n \"expected_run_version\": 1,\n \"note\": \"Tư vấn viên lạ cố tình duyệt.\",\n \"reviewer\": {\n \"actor_id\": \"unassigned-agent-99\",\n \"role\": \"agent\",\n \"current_assignee_id\": \"agent-10\"\n }\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{base_url}}/v1/scoring-runs/{{review_run_id}}/reviews", - "host": [ - "{{base_url}}" - ], - "path": [ - "v1", - "scoring-runs", - "{{review_run_id}}", - "reviews" - ] - } - }, - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "pm.test('Bị chặn xác thực caller HTTP 401', function () {", - " pm.response.to.have.status(401);", - "});", - "const body = pm.response.json();", - "pm.test('Mã lỗi là invalid_token', function () {", - " pm.expect(body.error_code).to.eql('invalid_token');", - "});" - ] - } - } - ] - } - ] - } - ], - "variable": [ - { - "key": "run_id", - "value": "" - }, - { - "key": "review_run_id", - "value": "" - }, - { - "key": "run_version", - "value": "1" - }, - { - "key": "review_run_version", - "value": "1" - }, - { - "key": "admission_idempotency_key", - "value": "" - }, - { - "key": "poll_attempt", - "value": "0" - }, - { - "key": "terminal_status", - "value": "" - } - ] -} diff --git a/old_repo/tests/e2e/api/lead-scoring-local.postman_environment.json b/old_repo/tests/e2e/api/lead-scoring-local.postman_environment.json deleted file mode 100644 index 96847ef..0000000 --- a/old_repo/tests/e2e/api/lead-scoring-local.postman_environment.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "id": "f5b8d6ce-3be6-4c51-8c5f-4d6a3f7b33b0", - "name": "Lead Scoring - Local Demo", - "values": [ - { - "key": "base_url", - "value": "http://localhost:8000", - "type": "default", - "enabled": true - }, - { - "key": "service_token", - "value": "local-test-token", - "type": "secret", - "enabled": true - }, - { - "key": "tenant_vkey", - "value": "test-vkey", - "type": "secret", - "enabled": true - }, - { - "key": "tenant_id", - "value": "omni-account-42", - "type": "default", - "enabled": true - }, - { - "key": "scoring_config_version", - "value": "education-demo-v1", - "type": "default", - "enabled": true - }, - { - "key": "dotb_app", - "value": "postman", - "type": "default", - "enabled": true - }, - { - "key": "dotb_feature", - "value": "lead-scoring", - "type": "default", - "enabled": true - }, - { - "key": "max_poll_attempts", - "value": "30", - "type": "default", - "enabled": true - }, - { - "key": "run_id", - "value": "", - "type": "default", - "enabled": true - }, - { - "key": "review_run_id", - "value": "", - "type": "default", - "enabled": true - }, - { - "key": "run_version", - "value": "1", - "type": "default", - "enabled": true - }, - { - "key": "review_run_version", - "value": "1", - "type": "default", - "enabled": true - }, - { - "key": "admission_idempotency_key", - "value": "", - "type": "default", - "enabled": true - }, - { - "key": "poll_attempt", - "value": "0", - "type": "default", - "enabled": true - }, - { - "key": "terminal_status", - "value": "", - "type": "default", - "enabled": true - } - ], - "_postman_variable_scope": "environment", - "_postman_exported_at": "2026-09-04T06:00:00.000Z", - "_postman_exported_using": "Postman" -} diff --git a/old_repo/tests/test_aihub_client.py b/old_repo/tests/test_aihub_client.py deleted file mode 100644 index 482d8ed..0000000 --- a/old_repo/tests/test_aihub_client.py +++ /dev/null @@ -1,1595 +0,0 @@ -import asyncio -import json -from typing import Any - -import httpx -import pytest -from pydantic import ValidationError - -from lead_scoring.aihub.client import ( - CHAT_COMPLETIONS_PATH, - DEFAULT_AI_MODEL, - AIHubClient, - run_heavy_ai_pipeline, - run_light_ai_pipeline, - run_medium_ai_pipeline, - to_strict_json_schema, -) -from lead_scoring.aihub.errors import ( - AIHubAuthError, - AIHubError, - AIHubInvalidOutputError, - AIHubQuotaError, - AIHubServerError, - AIHubTimeoutError, - normalize_aihub_error, -) -from lead_scoring.aihub.schemas import ( - AiCritiqueResult, - AiScoringResult, - EvidenceExtractionResult, - HeavyAiResult, - LightAiResult, - MediumAiResult, -) -from lead_scoring.schemas.snapshot import CanonicalSnapshot - - -def make_test_snapshot() -> CanonicalSnapshot: - return CanonicalSnapshot.model_validate( - { - "captured_at": "2026-08-27T12:00:00Z", - "lead_profile": { - "ems_lead_id": "lead-101", - "name": "Tran Van B", - "phone": "+84912345678", - "email": "tvb@example.com", - "status": "New", - }, - "conversations": [ - { - "conversation_id": "conv-101", - "messages": [ - { - "message_id": "msg-101", - "sender_type": "contact", - "content": ( - "Toi quan tam toi khoa hoc AI Platform cho doanh nghiep 50 nguoi" - ), - "sent_at": "2026-08-27T11:30:00Z", - } - ], - } - ], - "identity_context": { - "match_quality": "direct", - "contact_id": "cnt-101", - }, - "trigger_metadata": { - "trigger_type": "manual_omni", - "trigger_conversation_id": "conv-101", - "actor_id": "user-1", - }, - "hitl_policy": { - "confidence_threshold": 0.80, - "policy_version": "1.0.0", - }, - } - ) - - -def test_evidence_extraction_result_validation(): - valid_data = { - "summary": "Customer is looking for enterprise AI course for 50 people", - "intent": "high", - "key_signals": ["50 people enterprise", "immediate interest"], - "budget_signal": "enterprise budget", - "authority_signal": "decision maker", - "need_signal": "AI training for staff", - "timeline_signal": "next quarter", - } - result = EvidenceExtractionResult.model_validate(valid_data) - assert result.intent == "high" - assert len(result.key_signals) == 2 - assert result.budget_signal == "enterprise budget" - - # Summary is required - with pytest.raises(ValidationError): - EvidenceExtractionResult.model_validate({"intent": "high"}) - - # Extra fields are forbidden - with pytest.raises(ValidationError): - EvidenceExtractionResult.model_validate({**valid_data, "unauthorized_extra_field": "val"}) - - # Type coercion is rejected (strict=True) - with pytest.raises(ValidationError): - EvidenceExtractionResult.model_validate({**valid_data, "key_signals": "not_a_list"}) - - # Individual list item max_length (100 chars) - with pytest.raises(ValidationError): - EvidenceExtractionResult.model_validate({**valid_data, "key_signals": ["a" * 101]}) - - -def test_ai_scoring_result_validation(): - valid_data = { - "score": 85, - "confidence": 0.92, - "reasoning": "High budget and clear timeline for 50 people.", - "positive_factors": ["large team size", "clear requirements"], - "negative_factors": [], - } - result = AiScoringResult.model_validate(valid_data) - assert result.score == 85 - assert result.confidence == 0.92 - assert len(result.positive_factors) == 2 - - # Bounds validation - with pytest.raises(ValidationError): - AiScoringResult.model_validate({**valid_data, "score": 105}) - - with pytest.raises(ValidationError): - AiScoringResult.model_validate({**valid_data, "confidence": 1.5}) - - # Extra fields are forbidden - with pytest.raises(ValidationError): - AiScoringResult.model_validate({**valid_data, "hallucinated_field": True}) - - # String-to-number coercion is rejected (strict=True) - with pytest.raises(ValidationError): - AiScoringResult.model_validate({**valid_data, "score": "85"}) - - with pytest.raises(ValidationError): - AiScoringResult.model_validate({**valid_data, "confidence": "0.92"}) - - # Individual list item max_length (100 chars) - with pytest.raises(ValidationError): - AiScoringResult.model_validate({**valid_data, "positive_factors": ["b" * 101]}) - - with pytest.raises(ValidationError): - AiScoringResult.model_validate({**valid_data, "negative_factors": ["c" * 101]}) - - -@pytest.mark.asyncio -async def test_aihub_client_two_pass_chat_completions_happy_path(): - captured_requests: list[dict[str, Any]] = [] - - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == CHAT_COMPLETIONS_PATH - body = json.loads(request.content.decode("utf-8")) - captured_requests.append( - { - "url": str(request.url), - "headers": dict(request.headers), - "body": body, - } - ) - - response_format = body.get("response_format", {}) - schema_name = response_format.get("json_schema", {}).get("name") - - if schema_name == "evidence_extraction": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "summary": "Enterprise lead looking for AI course", - "intent": "high", - "key_signals": ["enterprise"], - } - ) - } - } - ] - }, - ) - elif schema_name == "lead_scoring": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 88, - "confidence": 0.95, - "reasoning": "Strong intent and clear enterprise scale.", - "positive_factors": ["enterprise scale"], - "negative_factors": [], - } - ) - } - } - ] - }, - ) - return httpx.Response(400) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - snapshot = make_test_snapshot() - - result = await run_medium_ai_pipeline( - client, - snapshot, - vkey="test-vkey-secret-123", - run_id="run-uuid-456", - model=DEFAULT_AI_MODEL, - timeout_seconds=5.0, - app="dotb_omni", - feature="lead-scoring", - ) - - assert isinstance(result, MediumAiResult) - assert result.ai_score == 88 - assert result.confidence == 0.95 - assert result.evidence.intent == "high" - assert result.scoring.score == 88 - assert result.metadata["pass_count"] == 2 - assert result.metadata["model"] == DEFAULT_AI_MODEL - - # Verify exactly 2 calls occurred to POST /v1/chat/completions - assert len(captured_requests) == 2 - for req in captured_requests: - assert req["url"] == "https://aihub.internal/v1/chat/completions" - assert req["headers"]["authorization"] == "Bearer test-vkey-secret-123" - assert req["headers"]["x-request-id"] == "run-uuid-456" - assert req["headers"]["x-bf-dim-app"] == "dotb_omni" - assert req["headers"]["x-bf-dim-feature"] == "lead-scoring" - assert req["body"]["model"] == DEFAULT_AI_MODEL - assert req["body"]["stream"] is False - assert req["body"]["response_format"]["type"] == "json_schema" - assert req["body"]["response_format"]["json_schema"]["strict"] is True - - # Pass 1 sent canonical snapshot - pass1_body = captured_requests[0]["body"] - assert pass1_body["response_format"]["json_schema"]["name"] == "evidence_extraction" - assert "lead_profile" in pass1_body["messages"][1]["content"] - - # Pass 2 sent ONLY validated evidence without raw profile or conversations - pass2_body = captured_requests[1]["body"] - assert pass2_body["response_format"]["json_schema"]["name"] == "lead_scoring" - pass2_user_content = json.loads(pass2_body["messages"][1]["content"]) - assert "evidence" in pass2_user_content - assert pass2_user_content["evidence"]["summary"] == "Enterprise lead looking for AI course" - assert "lead_profile" not in pass2_user_content - assert "identity_context" not in pass2_user_content - assert "conversations" not in pass2_user_content - - -@pytest.mark.asyncio -async def test_aihub_client_rejects_empty_choices(): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"choices": []}) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - with pytest.raises(AIHubInvalidOutputError): - await client.extract_evidence( - make_test_snapshot(), - vkey="valid-vkey", - run_id="run-1", - ) - - -@pytest.mark.asyncio -async def test_aihub_client_rejects_model_refusal(): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "refusal": "I cannot fulfill this request due to safety policy.", - "content": None, - } - } - ] - }, - ) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - with pytest.raises(AIHubInvalidOutputError): - await client.extract_evidence( - make_test_snapshot(), - vkey="valid-vkey", - run_id="run-1", - ) - - -@pytest.mark.asyncio -async def test_aihub_client_rejects_null_or_empty_content(): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - json={"choices": [{"message": {"content": None}}]}, - ) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - with pytest.raises(AIHubInvalidOutputError): - await client.extract_evidence( - make_test_snapshot(), - vkey="valid-vkey", - run_id="run-1", - ) - - -@pytest.mark.asyncio -async def test_aihub_client_rejects_extra_fields_in_pass1_and_pass2(): - def handler_pass1(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "summary": "Valid summary", - "extra_unauthorized_key": "injected", - } - ) - } - } - ] - }, - ) - - transport = httpx.MockTransport(handler_pass1) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - with pytest.raises(AIHubInvalidOutputError) as exc_info: - await client.extract_evidence( - make_test_snapshot(), - vkey="valid-vkey", - run_id="run-1", - ) - assert exc_info.value.error_code == "aihub_invalid_output" - - def handler_pass2(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 80, - "confidence": 0.9, - "reasoning": "Valid", - "extra_field": "bad", - } - ) - } - } - ] - }, - ) - - transport2 = httpx.MockTransport(handler_pass2) - async with httpx.AsyncClient(transport=transport2) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - valid_evidence = EvidenceExtractionResult(summary="Valid") - with pytest.raises(AIHubInvalidOutputError) as exc_info: - await client.score_lead( - valid_evidence, - vkey="valid-vkey", - run_id="run-1", - ) - assert exc_info.value.error_code == "aihub_invalid_output" - - -@pytest.mark.asyncio -async def test_aihub_client_rejects_type_coercion_in_pass1_and_pass2(): - def handler_pass2_coercion(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": "80", # String instead of int - "confidence": "0.9", # String instead of float - "reasoning": "Valid reasoning", - } - ) - } - } - ] - }, - ) - - transport = httpx.MockTransport(handler_pass2_coercion) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - valid_evidence = EvidenceExtractionResult(summary="Valid") - with pytest.raises(AIHubInvalidOutputError) as exc_info: - await client.score_lead( - valid_evidence, - vkey="valid-vkey", - run_id="run-1", - ) - assert exc_info.value.error_code == "aihub_invalid_output" - - -@pytest.mark.asyncio -async def test_aihub_client_normalizes_401_403_auth_errors(): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(401, json={"error": "Invalid V-Key credential"}) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - with pytest.raises(AIHubAuthError) as exc_info: - await client.extract_evidence( - make_test_snapshot(), - vkey="bad-vkey", - run_id="run-1", - ) - err = exc_info.value - assert err.error_code == "aihub_auth_failed" - assert err.status_code == 401 - assert err.retryable is False - assert err.fallback_allowed is True - - -@pytest.mark.asyncio -async def test_aihub_client_normalizes_429_quota_error(): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(429, json={"error": "Tenant monthly credit exhausted"}) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - with pytest.raises(AIHubQuotaError) as exc_info: - await client.extract_evidence( - make_test_snapshot(), - vkey="valid-vkey", - run_id="run-1", - ) - err = exc_info.value - assert err.error_code == "aihub_quota_exceeded" - assert err.status_code == 429 - assert err.retryable is False - assert err.fallback_allowed is True - - -@pytest.mark.asyncio -async def test_aihub_client_normalizes_timeout(): - def handler(request: httpx.Request) -> httpx.Response: - raise httpx.ReadTimeout("Request timed out after 10s") - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - with pytest.raises(AIHubTimeoutError) as exc_info: - await client.extract_evidence( - make_test_snapshot(), - vkey="valid-vkey", - run_id="run-1", - ) - err = exc_info.value - assert err.error_code == "aihub_timeout" - assert err.retryable is False - assert err.fallback_allowed is True - - -@pytest.mark.asyncio -async def test_aihub_client_normalizes_server_error_5xx(): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(502, text="Bad Gateway") - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - with pytest.raises(AIHubServerError) as exc_info: - await client.extract_evidence( - make_test_snapshot(), - vkey="valid-vkey", - run_id="run-1", - ) - err = exc_info.value - assert err.error_code == "aihub_server_error" - assert err.status_code == 502 - assert err.retryable is False - assert err.fallback_allowed is True - - -@pytest.mark.asyncio -async def test_aihub_client_normalizes_malformed_json(): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - json={"choices": [{"message": {"content": "Not a JSON {malformed"}}]}, - ) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - with pytest.raises(AIHubInvalidOutputError) as exc_info: - await client.extract_evidence( - make_test_snapshot(), - vkey="valid-vkey", - run_id="run-1", - ) - err = exc_info.value - assert err.error_code == "aihub_invalid_output" - assert err.retryable is False - assert err.fallback_allowed is True - - -@pytest.mark.asyncio -async def test_run_medium_ai_pipeline_pass_1_failure_prevents_pass_2(): - call_count = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - return httpx.Response(500, json={"error": "Internal AIHub Error"}) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - with pytest.raises(AIHubServerError): - await run_medium_ai_pipeline( - client, - make_test_snapshot(), - vkey="vkey-val", - run_id="run-1", - ) - # Exactly 1 call was attempted (Pass 1 failed, Pass 2 was NOT called) - assert call_count == 1 - - -@pytest.mark.asyncio -async def test_run_medium_ai_pipeline_pass_2_failure_discards_partial_results(): - call_count = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - body = json.loads(request.content.decode("utf-8")) - schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") - if schema_name == "evidence_extraction": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - {"summary": "Extracted evidence", "intent": "high"} - ) - } - } - ] - }, - ) - elif schema_name == "lead_scoring": - return httpx.Response(503, json={"error": "Service Unavailable"}) - return httpx.Response(404) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - with pytest.raises(AIHubServerError) as exc_info: - await run_medium_ai_pipeline( - client, - make_test_snapshot(), - vkey="vkey-val", - run_id="run-1", - ) - assert exc_info.value.error_code == "aihub_server_error" - assert call_count == 2 - - -def test_vkey_sentinel_never_leaks_in_normalized_error_or_string_repr(): - sentinel_vkey = "sentinel-vkey-super-secret-999" - - # Simulate an HTTP error where the request header contains the secret - request = httpx.Request( - "POST", - "https://aihub.internal/v1/chat/completions", - headers={"Authorization": f"Bearer {sentinel_vkey}", "x-tenant-vkey": sentinel_vkey}, - ) - response = httpx.Response(401, request=request, json={"detail": "Unauthorized"}) - http_error = httpx.HTTPStatusError("401 Unauthorized", request=request, response=response) - - normalized = normalize_aihub_error(http_error) - normalized_str = str(normalized) - normalized_repr = repr(normalized) - - assert sentinel_vkey not in normalized_str - assert sentinel_vkey not in normalized_repr - assert sentinel_vkey not in normalized.detail - assert normalized.error_code == "aihub_auth_failed" - - -@pytest.mark.asyncio -async def test_aihub_client_uses_injected_base_url(): - captured_urls: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - captured_urls.append(str(request.url)) - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - {"summary": "Injected base URL evidence", "intent": "high"} - ) - } - } - ] - }, - ) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - injected_url = "https://custom-gateway.corp.dotb.vn/aihub-api" - client = AIHubClient(base_url=injected_url, http_client=http_client) - await client.extract_evidence( - make_test_snapshot(), - vkey="test-vkey", - run_id="run-base-url", - ) - assert len(captured_urls) == 1 - assert captured_urls[0] == f"{injected_url}/v1/chat/completions" - - -@pytest.mark.asyncio -async def test_aihub_client_rejects_oversized_response_body(): - # 65 KB response body exceeds 64 KB limit - oversized_summary = "A" * (65 * 1024) - - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps({"summary": oversized_summary, "intent": "high"}) - } - } - ] - }, - ) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - with pytest.raises(AIHubInvalidOutputError) as exc_info: - await client.extract_evidence( - make_test_snapshot(), - vkey="valid-vkey", - run_id="run-oversized", - ) - assert exc_info.value.error_code == "aihub_invalid_output" - assert "exceeds limit" in str(exc_info.value) - - -class CustomStreamingByteStream(httpx.AsyncByteStream): - def __init__(self, chunks: list[bytes]): - self.chunks = chunks - self.consumed_chunks_count = 0 - - async def __aiter__(self): - for chunk in self.chunks: - self.consumed_chunks_count += 1 - yield chunk - - -@pytest.mark.asyncio -async def test_aihub_client_streaming_aborts_early_without_consuming_entire_stream(): - # 5 chunks of 32 KB = 160 KB total (> 64 KB limit) - chunks = [b"x" * (32 * 1024) for _ in range(5)] - stream = CustomStreamingByteStream(chunks) - - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, stream=stream) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - with pytest.raises(AIHubInvalidOutputError) as exc_info: - await client.extract_evidence( - make_test_snapshot(), - vkey="valid-vkey", - run_id="run-streaming-cap", - ) - assert exc_info.value.error_code == "aihub_invalid_output" - assert "exceeded maximum limit" in str(exc_info.value) - # Aborted after chunk 3 (96 KB > 64 KB), so chunks 4 and 5 were NEVER consumed! - assert stream.consumed_chunks_count == 3 - assert stream.consumed_chunks_count < len(chunks) - - -@pytest.mark.asyncio -async def test_aihub_client_rejects_oversized_content_length_header(): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - headers={"Content-Length": "70000"}, - json={"choices": []}, - ) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - with pytest.raises(AIHubInvalidOutputError) as exc_info: - await client.extract_evidence( - make_test_snapshot(), - vkey="valid-vkey", - run_id="run-content-length", - ) - assert exc_info.value.error_code == "aihub_invalid_output" - assert "exceeds limit" in str(exc_info.value) - - -def test_aihub_client_validates_base_url_shapes(): - # 1. Missing scheme or netloc - with pytest.raises(ValueError, match="missing scheme or host"): - AIHubClient(base_url="invalid-no-scheme") - - # 2. User credentials in base_url - with pytest.raises(ValueError, match="user credentials"): - AIHubClient(base_url="https://user:password@aihub.internal") - - # 3. Query params in base_url - with pytest.raises(ValueError, match="query parameters"): - AIHubClient(base_url="https://aihub.internal?v=1") - - # 4. Fragments in base_url - with pytest.raises(ValueError, match="fragments"): - AIHubClient(base_url="https://aihub.internal#section") - - -def test_ai_critique_result_validation(): - valid_data = { - "critique_summary": "Lead has solid budget and authority signals but unclear timeline.", - "risks": ["Timeline not stated", "Implementation dependency on vendor"], - "strengths": ["Enterprise scale", "Decision maker direct contact"], - "cross_check_notes": "Needs match enterprise scale mentioned in previous interactions.", - } - result = AiCritiqueResult.model_validate(valid_data) - assert result.critique_summary.startswith("Lead has solid budget") - assert len(result.risks) == 2 - assert len(result.strengths) == 2 - assert result.cross_check_notes is not None - - # Required field missing - with pytest.raises(ValidationError): - AiCritiqueResult.model_validate({"risks": ["Risk 1"]}) - - # Extra fields forbidden - with pytest.raises(ValidationError): - AiCritiqueResult.model_validate({**valid_data, "unauthorized_extra": "rejected"}) - - # Strict type enforcement - with pytest.raises(ValidationError): - AiCritiqueResult.model_validate({**valid_data, "risks": "not_a_list"}) - - -def test_light_and_heavy_ai_result_models(): - scoring_result = AiScoringResult( - score=85, - confidence=0.90, - reasoning="Good potential", - positive_factors=["Strong need"], - negative_factors=[], - ) - evidence_result = EvidenceExtractionResult(summary="Evidence summary") - critique_result = AiCritiqueResult( - critique_summary="Critique summary", - risks=[], - strengths=["Strong need"], - ) - - light_result = LightAiResult( - ai_score=85, - confidence=0.90, - scoring=scoring_result, - metadata={"model": "gpt-4o-mini", "pass_count": 1, "effort_profile": "light"}, - ) - assert light_result.ai_score == 85 - assert light_result.metadata["pass_count"] == 1 - - heavy_result = HeavyAiResult( - ai_score=85, - confidence=0.90, - evidence=evidence_result, - critique=critique_result, - scoring=scoring_result, - metadata={"model": "gpt-4o-mini", "pass_count": 3, "effort_profile": "heavy"}, - ) - assert heavy_result.ai_score == 85 - assert heavy_result.critique.critique_summary == "Critique summary" - assert heavy_result.metadata["pass_count"] == 3 - - -@pytest.mark.asyncio -async def test_aihub_client_analyze_and_score_lead_light_pass(): - captured_requests: list[dict[str, Any]] = [] - - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == CHAT_COMPLETIONS_PATH - assert request.headers.get("Authorization") == "Bearer vkey-light-1" - assert request.headers.get("x-request-id") == "run-light-1" - body = json.loads(request.content.decode("utf-8")) - captured_requests.append(body) - schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") - assert schema_name == "light_lead_scoring" - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 75, - "confidence": 0.88, - "reasoning": "Single pass light scoring.", - "positive_factors": ["Direct contact"], - "negative_factors": [], - } - ) - } - } - ] - }, - ) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - result = await client.analyze_and_score_lead( - make_test_snapshot(), - vkey="vkey-light-1", - run_id="run-light-1", - model="custom-model", - timeout_seconds=5.0, - ) - assert result.score == 75 - assert result.confidence == 0.88 - assert len(captured_requests) == 1 - assert captured_requests[0]["model"] == "custom-model" - - -@pytest.mark.asyncio -async def test_aihub_client_critique_evidence_heavy_pass(): - captured_requests: list[dict[str, Any]] = [] - - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == CHAT_COMPLETIONS_PATH - assert request.headers.get("Authorization") == "Bearer vkey-heavy-1" - assert request.headers.get("x-request-id") == "run-heavy-1" - body = json.loads(request.content.decode("utf-8")) - captured_requests.append(body) - schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") - assert schema_name == "evidence_critique" - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "critique_summary": "Critique evaluation completed.", - "risks": ["Budget unclear"], - "strengths": ["Clear need"], - "cross_check_notes": "Cross-check consistent.", - } - ) - } - } - ] - }, - ) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - evidence = EvidenceExtractionResult(summary="Evidence summary", intent="high") - result = await client.critique_evidence( - make_test_snapshot(), - evidence, - vkey="vkey-heavy-1", - run_id="run-heavy-1", - model="custom-model", - timeout_seconds=5.0, - ) - assert result.critique_summary == "Critique evaluation completed." - assert result.risks == ["Budget unclear"] - assert len(captured_requests) == 1 - user_content = captured_requests[0]["messages"][1]["content"] - assert "snapshot" in user_content - assert "evidence" in user_content - - -@pytest.mark.asyncio -async def test_aihub_client_score_lead_heavy_pass(): - captured_requests: list[dict[str, Any]] = [] - - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == CHAT_COMPLETIONS_PATH - assert request.headers.get("Authorization") == "Bearer vkey-heavy-1" - assert request.headers.get("x-request-id") == "run-heavy-1" - body = json.loads(request.content.decode("utf-8")) - captured_requests.append(body) - schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") - assert schema_name == "heavy_lead_scoring" - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 92, - "confidence": 0.94, - "reasoning": "Synthesized evidence and critique.", - "positive_factors": ["High demand", "Validated strength"], - "negative_factors": ["Minor risk"], - } - ) - } - } - ] - }, - ) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - evidence = EvidenceExtractionResult(summary="Evidence summary", intent="high") - critique = AiCritiqueResult( - critique_summary="Critique summary", - risks=["Minor risk"], - strengths=["Validated strength"], - ) - result = await client.score_lead_heavy( - evidence, - critique, - vkey="vkey-heavy-1", - run_id="run-heavy-1", - model="custom-model", - timeout_seconds=5.0, - ) - assert result.score == 92 - assert result.confidence == 0.94 - assert len(captured_requests) == 1 - # Validate Pass 3 payload does NOT contain raw snapshot fields - user_content = captured_requests[0]["messages"][1]["content"] - assert "lead_profile" not in user_content - assert "conversations" not in user_content - assert "evidence" in user_content - assert "critique" in user_content - - -@pytest.mark.asyncio -async def test_run_light_ai_pipeline_single_pass_success(): - call_count = 0 - captured_headers: list[dict[str, str]] = [] - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - captured_headers.append(dict(request.headers)) - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 78, - "confidence": 0.86, - "reasoning": "Light pipeline completed.", - "positive_factors": ["Fast response"], - "negative_factors": [], - } - ) - } - } - ] - }, - ) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - result = await run_light_ai_pipeline( - client, - make_test_snapshot(), - vkey="vkey-light", - run_id="run-light", - model="openai/gpt-4o-mini", - app="dotb_omni", - feature="lead-scoring", - ) - assert result.ai_score == 78 - assert result.confidence == 0.86 - assert result.metadata["pass_count"] == 1 - assert result.metadata["effort_profile"] == "light" - assert call_count == 1 - assert len(captured_headers) == 1 - assert captured_headers[0]["x-bf-dim-app"] == "dotb_omni" - assert captured_headers[0]["x-bf-dim-feature"] == "lead-scoring" - assert captured_headers[0]["x-request-id"] == "run-light" - - -@pytest.mark.asyncio -async def test_run_heavy_ai_pipeline_three_pass_success(): - call_count = 0 - passed_schemas: list[str] = [] - captured_headers: list[dict[str, str]] = [] - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - captured_headers.append(dict(request.headers)) - body = json.loads(request.content.decode("utf-8")) - schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") - passed_schemas.append(schema_name) - - if schema_name == "evidence_extraction": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "summary": "Pass 1 evidence extraction", - "intent": "high", - "key_signals": ["Signal 1"], - } - ) - } - } - ] - }, - ) - elif schema_name == "evidence_critique": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "critique_summary": "Pass 2 critique", - "risks": ["Risk 1"], - "strengths": ["Strength 1"], - } - ) - } - } - ] - }, - ) - elif schema_name == "heavy_lead_scoring": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 95, - "confidence": 0.96, - "reasoning": "Pass 3 heavy scoring complete.", - "positive_factors": ["Strength 1"], - "negative_factors": ["Risk 1"], - } - ) - } - } - ] - }, - ) - return httpx.Response(404) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - result = await run_heavy_ai_pipeline( - client, - make_test_snapshot(), - vkey="vkey-heavy", - run_id="run-heavy", - model="openai/gpt-4o-mini", - app="dotb_omni", - feature="lead-scoring", - ) - assert result.ai_score == 95 - assert result.confidence == 0.96 - assert result.metadata["pass_count"] == 3 - assert result.metadata["effort_profile"] == "heavy" - assert call_count == 3 - assert passed_schemas == ["evidence_extraction", "evidence_critique", "heavy_lead_scoring"] - assert len(captured_headers) == 3 - for h in captured_headers: - assert h["x-bf-dim-app"] == "dotb_omni" - assert h["x-bf-dim-feature"] == "lead-scoring" - assert h["x-request-id"] == "run-heavy" - - -@pytest.mark.asyncio -async def test_run_heavy_ai_pipeline_pass_1_failure_prevents_subsequent_passes(): - call_count = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - return httpx.Response(500, json={"error": "Pass 1 failed"}) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - with pytest.raises(AIHubServerError): - await run_heavy_ai_pipeline( - client, - make_test_snapshot(), - vkey="vkey-heavy", - run_id="run-heavy", - ) - assert call_count == 1 - - -@pytest.mark.asyncio -async def test_run_heavy_ai_pipeline_pass_2_failure_prevents_pass_3(): - call_count = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - body = json.loads(request.content.decode("utf-8")) - schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") - if schema_name == "evidence_extraction": - return httpx.Response( - 200, - json={"choices": [{"message": {"content": json.dumps({"summary": "Pass 1 ok"})}}]}, - ) - elif schema_name == "evidence_critique": - return httpx.Response(429, json={"error": "Rate limit in Pass 2"}) - return httpx.Response(404) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - with pytest.raises(AIHubQuotaError): - await run_heavy_ai_pipeline( - client, - make_test_snapshot(), - vkey="vkey-heavy", - run_id="run-heavy", - ) - assert call_count == 2 - - -@pytest.mark.asyncio -async def test_run_heavy_ai_pipeline_pass_3_failure_discards_partial_results(): - call_count = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - body = json.loads(request.content.decode("utf-8")) - schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") - if schema_name == "evidence_extraction": - return httpx.Response( - 200, - json={"choices": [{"message": {"content": json.dumps({"summary": "Pass 1 ok"})}}]}, - ) - elif schema_name == "evidence_critique": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "critique_summary": "Pass 2 ok", - "risks": [], - "strengths": [], - } - ) - } - } - ] - }, - ) - elif schema_name == "heavy_lead_scoring": - return httpx.Response(502, text="Bad Gateway on Pass 3") - return httpx.Response(404) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - with pytest.raises(AIHubServerError): - await run_heavy_ai_pipeline( - client, - make_test_snapshot(), - vkey="vkey-heavy", - run_id="run-heavy", - ) - assert call_count == 3 - - -def test_to_strict_json_schema_recursively_requires_all_properties_and_forbids_additional(): - def assert_no_default_keywords(node: Any) -> None: - if isinstance(node, dict): - assert "default" not in node, f"Found 'default' keyword in strict schema node: {node}" - for v in node.values(): - assert_no_default_keywords(v) - elif isinstance(node, list): - for item in node: - assert_no_default_keywords(item) - - # 1. EvidenceExtractionResult - schema_evidence = to_strict_json_schema(EvidenceExtractionResult) - assert set(schema_evidence["required"]) == set(schema_evidence["properties"].keys()) - assert schema_evidence["additionalProperties"] is False - assert_no_default_keywords(schema_evidence) - - # 2. AiCritiqueResult - schema_critique = to_strict_json_schema(AiCritiqueResult) - assert set(schema_critique["required"]) == set(schema_critique["properties"].keys()) - assert schema_critique["additionalProperties"] is False - assert_no_default_keywords(schema_critique) - - # 3. AiScoringResult - schema_scoring = to_strict_json_schema(AiScoringResult) - assert set(schema_scoring["required"]) == set(schema_scoring["properties"].keys()) - assert schema_scoring["additionalProperties"] is False - assert_no_default_keywords(schema_scoring) - - -class SlowDripAsyncStream(httpx.AsyncByteStream): - def __init__(self, chunks: list[bytes], delay: float = 0.05): - self.chunks = chunks - self.delay = delay - - async def __aiter__(self): - for chunk in self.chunks: - await asyncio.sleep(self.delay) - yield chunk - - -@pytest.mark.asyncio -async def test_aihub_client_wall_clock_timeout_aborts_slow_drip_stream(): - # 10 chunks of 100 bytes with 0.05s delay each = 0.5s total time - # With timeout_seconds = 0.1s, hard wall-clock timeout MUST abort - chunks = [b"x" * 100 for _ in range(10)] - stream = SlowDripAsyncStream(chunks, delay=0.05) - - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, stream=stream) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - with pytest.raises(AIHubTimeoutError) as exc_info: - await client.extract_evidence( - make_test_snapshot(), - vkey="valid-vkey", - run_id="run-slow-drip", - timeout_seconds=0.1, - ) - assert exc_info.value.error_code == "aihub_timeout" - - -@pytest.mark.asyncio -async def test_aihub_client_trust_env_false_by_default(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv("HTTP_PROXY", "http://untrusted-proxy.local:8080") - monkeypatch.setenv("HTTPS_PROXY", "http://untrusted-proxy.local:8080") - - # When no http_client is passed, internal httpx.AsyncClient is created with trust_env=False - client = AIHubClient(base_url="https://aihub.internal") - # Direct connection without proxy routing fails with network error/timeout - with pytest.raises(AIHubError) as exc_info: - await client.extract_evidence( - make_test_snapshot(), - vkey="valid-vkey", - run_id="run-trust-env", - timeout_seconds=0.1, - ) - # The error must be normalized and not route to the fake proxy - assert exc_info.value.error_code in ("aihub_network_error", "aihub_timeout") - - -def test_reduce_snapshot_bounds_oversized_custom_fields_and_details(): - from lead_scoring.aihub.client import reduce_snapshot_for_prompt - - # Create oversized custom fields (> 1.2MB with 200 keys and huge strings) - large_custom_fields = {f"field_{i:03d}": "X" * 10000 for i in range(150)} - large_details = {f"detail_{i:03d}": "Y" * 5000 for i in range(50)} - - raw_snapshot_data = { - "captured_at": "2026-08-27T12:00:00Z", - "lead_profile": { - "ems_lead_id": "lead-oversized-1", - "name": "Nguyen Van C", - "custom_fields": large_custom_fields, - }, - "conversations": [ - { - "conversation_id": "conv-1", - "messages": [ - { - "message_id": "msg-1", - "sender_type": "contact", - "content": "Can tu van khoa hoc AI", - "sent_at": "2026-08-27T11:59:00Z", - } - ], - } - ], - "identity_context": { - "match_quality": "direct", - "contact_id": "cnt-1", - "details": large_details, - }, - "truncation": { - "truncated": True, - "dropped_conversations_count": 2, - "dropped_messages_count": 5, - "reason": "Omni structural caps reached", - }, - } - - snapshot = CanonicalSnapshot.model_validate(raw_snapshot_data) - - # Validate that the original snapshot still holds the full data - assert len(snapshot.lead_profile.custom_fields) == 150 - assert len(snapshot.identity_context.details) == 50 # type: ignore - - # Reduce snapshot for prompt serialization - reduced = reduce_snapshot_for_prompt(snapshot) - - # Validate that reduced prompt content is strictly bounded - reduced_custom_fields = reduced["lead_profile"]["custom_fields"] - assert len(reduced_custom_fields) <= 50 - assert "_truncated_lineage" in reduced_custom_fields - - # Total serialized size of custom_fields is well within token bounds (< 6KB) - serialized_cf = json.dumps(reduced_custom_fields) - assert len(serialized_cf) < 6000 - - # Details in identity_context are also bounded - reduced_details = reduced["identity_context"]["details"] - assert len(reduced_details) <= 50 - assert "_truncated_lineage" in reduced_details - assert len(json.dumps(reduced_details)) < 6000 - - # Truncation lineage is preserved - assert reduced["truncation_lineage"]["truncated"] is True - assert reduced["truncation_lineage"]["dropped_conversations_count"] == 2 - - # Original snapshot object in memory is NOT mutated - assert len(snapshot.lead_profile.custom_fields) == 150 - assert len(snapshot.lead_profile.custom_fields["field_000"]) == 10000 - - -@pytest.mark.asyncio -async def test_pipelines_forward_customer_rules_and_behavioral_metrics(): - # Test light, medium, and heavy pipeline execution with customer_rules and behavioral_metrics - customer_rules = [{"field": "age", "operator": "gte", "value": 18, "points": 20}] - behavioral_metrics = {"message_count": 5, "conversation_count": 2, "conversation_turn_count": 3} - captured_payloads: list[dict[str, Any]] = [] - - def handler(request: httpx.Request) -> httpx.Response: - body = json.loads(request.content.decode("utf-8")) - captured_payloads.append(body) - schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") - if schema_name == "light_lead_scoring": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 80, - "confidence": 0.9, - "reasoning": "ok", - "positive_factors": [], - "negative_factors": [], - } - ) - } - } - ] - }, - ) - elif schema_name == "evidence_extraction": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps({"summary": "extracted", "intent": "high"}) - } - } - ] - }, - ) - elif schema_name == "lead_scoring": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 85, - "confidence": 0.9, - "reasoning": "ok", - "positive_factors": [], - "negative_factors": [], - } - ) - } - } - ] - }, - ) - elif schema_name == "evidence_critique": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - {"critique_summary": "critique", "risks": [], "strengths": []} - ) - } - } - ] - }, - ) - elif schema_name == "heavy_lead_scoring": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 90, - "confidence": 0.95, - "reasoning": "ok", - "positive_factors": [], - "negative_factors": [], - } - ) - } - } - ] - }, - ) - return httpx.Response(404) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - - # 1. Light pipeline - await run_light_ai_pipeline( - client, - make_test_snapshot(), - vkey="vkey-test", - run_id="run-light-b", - customer_rules=customer_rules, - behavioral_metrics=behavioral_metrics, - ) - light_content = json.loads(captured_payloads[-1]["messages"][1]["content"]) - assert light_content["customer_rules"] == customer_rules - assert light_content["behavioral_metrics"] == behavioral_metrics - - # 2. Medium pipeline - await run_medium_ai_pipeline( - client, - make_test_snapshot(), - vkey="vkey-test", - run_id="run-medium-b", - customer_rules=customer_rules, - behavioral_metrics=behavioral_metrics, - ) - # Pass 1 evidence extraction has reduced snapshot with rules and metrics - p1_content = json.loads(captured_payloads[-2]["messages"][1]["content"]) - assert p1_content["customer_rules"] == customer_rules - assert p1_content["behavioral_metrics"] == behavioral_metrics - # Pass 2 scoring has rules and metrics in payload - p2_content = json.loads(captured_payloads[-1]["messages"][1]["content"]) - assert p2_content["customer_rules"] == customer_rules - assert p2_content["behavioral_metrics"] == behavioral_metrics - - # 3. Heavy pipeline - await run_heavy_ai_pipeline( - client, - make_test_snapshot(), - vkey="vkey-test", - run_id="run-heavy-b", - customer_rules=customer_rules, - behavioral_metrics=behavioral_metrics, - ) - # Pass 3 heavy scoring has rules and metrics - p3_content = json.loads(captured_payloads[-1]["messages"][1]["content"]) - assert p3_content["customer_rules"] == customer_rules - assert p3_content["behavioral_metrics"] == behavioral_metrics - - -def test_reduce_snapshot_exactly_51_short_entries_sets_lineage_and_metadata(): - from lead_scoring.aihub.client import reduce_snapshot_for_prompt - - # Exactly 51 short entries (each entry is small and fits in token chars) - short_custom_fields = {f"k_{i:02d}": f"v_{i:02d}" for i in range(51)} - - raw_snapshot_data = { - "captured_at": "2026-08-27T12:00:00Z", - "lead_profile": { - "ems_lead_id": "lead-51-entries", - "name": "Short Fields Lead", - "custom_fields": short_custom_fields, - }, - "conversations": [], - } - - snapshot = CanonicalSnapshot.model_validate(raw_snapshot_data) - - reduced = reduce_snapshot_for_prompt(snapshot) - - # Validate that custom_fields is bounded to 50 items plus the _truncated_lineage marker - reduced_cf = reduced["lead_profile"]["custom_fields"] - assert "_truncated_lineage" in reduced_cf - # 50 entries retained + 1 marker = 51 keys in reduced_cf - assert len(reduced_cf) == 51 - - # Top-level truncation_lineage is populated and marked truncated - assert reduced["truncation_lineage"] is not None - assert reduced["truncation_lineage"]["truncated"] is True - assert "token budget" in reduced["truncation_lineage"]["reason"] - - -def test_httpx_is_in_project_runtime_dependencies(): - import tomllib - from pathlib import Path - - pyproject_path = Path(__file__).parent.parent / "pyproject.toml" - with open(pyproject_path, "rb") as f: - data = tomllib.load(f) - - runtime_deps = data.get("project", {}).get("dependencies", []) - assert any(dep.startswith("httpx") for dep in runtime_deps) diff --git a/old_repo/tests/test_bounded_profiles_execution.py b/old_repo/tests/test_bounded_profiles_execution.py deleted file mode 100644 index 3a86f7a..0000000 --- a/old_repo/tests/test_bounded_profiles_execution.py +++ /dev/null @@ -1,1327 +0,0 @@ -import asyncio -import json -import logging -import uuid -from datetime import UTC, datetime, timedelta -from pathlib import Path -from typing import Any - -import httpx -import pytest -from alembic import command -from alembic.config import Config -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine - -from lead_scoring.aihub.client import AIHubClient -from lead_scoring.aihub.errors import AIHubError -from lead_scoring.aihub.schemas import EvidenceExtractionResult -from lead_scoring.db.models.outbox_event import OutboxEvent -from lead_scoring.db.models.scoring_run import ScoringRun -from lead_scoring.execution import execute_scoring_run -from lead_scoring.scoring import ( - EffortProfile, - FallbackPolicy, - RuleMetric, - ScoringConfig, - ScoringMode, - ScoringRule, -) - - -def get_alembic_config(database_url: str) -> Config: - root_dir = Path(__file__).resolve().parent.parent - config = Config(str(root_dir / "alembic.ini")) - config.attributes["sqlalchemy_url"] = database_url - return config - - -def make_snapshot_data(ems_lead_id: str = "lead-p-1") -> dict[str, Any]: - return { - "version": "1.0.0", - "scope": "linked_contact_history", - "captured_at": "2026-08-27T12:00:00Z", - "lead_profile": { - "ems_lead_id": ems_lead_id, - "name": "Le Thi D", - "phone": "+84901234567", - "email": "ltd@example.com", - "status": "New", - }, - "conversations": [ - { - "conversation_id": "conv-p-1", - "messages": [ - { - "message_id": "msg-p-1", - "sender_type": "contact", - "content": "Toi can tu van goi AI Lead Scoring cho cong ty", - "sent_at": "2026-08-27T11:00:00Z", - } - ], - } - ], - "identity_context": { - "match_quality": "direct", - "contact_id": "cnt-p-1", - }, - "trigger_metadata": { - "trigger_type": "manual_omni", - "trigger_conversation_id": "conv-p-1", - "actor_id": "user-p-1", - }, - "hitl_policy": { - "confidence_threshold": 0.80, - "policy_version": "1.0.0", - }, - } - - -@pytest.mark.asyncio -async def test_light_execution_success_completed(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - - config = ScoringConfig( - version="light-config-1", - base_score=50, - confidence=0.85, - confidence_threshold=0.80, - hitl_policy_version="policy-1", - rules=(ScoringRule(metric=RuleMetric.PROFILE_FIELD_COUNT, minimum=2, points=30),), - rule_weight=0.3, - ai_weight=0.7, - effort_profile=EffortProfile.LIGHT, - ai_model="openai/gpt-4o-mini", - fallback_policy=FallbackPolicy.RULES_ONLY_REVIEW, - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-light-1", - subject_ref="lead-p-1", - scoring_config_version="light-config-1", - idempotency_key="idemp-light-1", - payload_hash="1" * 64, - status="pending", - snapshot_data=make_snapshot_data("lead-p-1"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=30), - ) - ) - await session.commit() - - call_count = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - assert request.url.path == "/v1/chat/completions" - body = json.loads(request.content.decode("utf-8")) - schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") - assert schema_name == "light_lead_scoring" - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 85, - "confidence": 0.90, - "reasoning": "Single pass light evaluation.", - "positive_factors": ["Direct contact interest"], - "negative_factors": [], - } - ) - } - } - ] - }, - ) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - aihub_client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - - success = await execute_scoring_run( - session_factory, - run_id, - tenant_vkey="secret-vkey-token", - lease_seconds=30.0, - aihub_client=aihub_client, - clock=lambda: created_at + timedelta(seconds=2), - ) - assert success is True - assert call_count == 1 - - # Rule score = 50 + 30 = 80 - # AI score = 85 - # Final score = round(80 * 0.3 + 85 * 0.7) = round(24 + 59.5) = 84 - # Combined confidence = 0.85 * 0.3 + 0.90 * 0.7 = 0.255 + 0.63 = 0.885 >= 0.80 -> completed - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.status == "completed" - assert run.rule_score == 80 - assert run.ai_score == 85 - assert run.final_score == 84 - assert run.scoring_mode == ScoringMode.LIGHT_AI.value - assert run.degraded is False - assert run.confidence == 0.885 - assert run.ai_metadata == { - "model": "openai/gpt-4o-mini", - "effort_profile": "light", - "pass_count": 1, - } - - events = list( - (await session.execute(select(OutboxEvent).where(OutboxEvent.run_id == run_id))) - .scalars() - .all() - ) - assert len(events) == 1 - data = events[0].payload["data"] - assert data["status"] == "completed" - assert data["rule_score"] == 80 - assert data["confidence"] == 0.885 - assert data["error_code"] is None - assert data["retryable"] is None - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_heavy_execution_success_completed(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - - config = ScoringConfig( - version="heavy-config-1", - base_score=40, - confidence=0.90, - confidence_threshold=0.80, - hitl_policy_version="policy-1", - rules=(ScoringRule(metric=RuleMetric.PROFILE_FIELD_COUNT, minimum=2, points=20),), - rule_weight=0.2, - ai_weight=0.8, - effort_profile=EffortProfile.HEAVY, - ai_model="openai/gpt-4o-mini", - fallback_policy=FallbackPolicy.RULES_ONLY_REVIEW, - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-heavy-1", - subject_ref="lead-p-2", - scoring_config_version="heavy-config-1", - idempotency_key="idemp-heavy-1", - payload_hash="2" * 64, - status="pending", - snapshot_data=make_snapshot_data("lead-p-2"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=40), - ) - ) - await session.commit() - - call_sequence: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/v1/chat/completions" - body = json.loads(request.content.decode("utf-8")) - schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") - call_sequence.append(schema_name) - - if schema_name == "evidence_extraction": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "summary": "Enterprise interest in AI Platform", - "intent": "high", - "key_signals": ["Enterprise lead"], - } - ) - } - } - ] - }, - ) - elif schema_name == "evidence_critique": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "critique_summary": ( - "High value potential with strong buy-in" - ), - "risks": [], - "strengths": ["Enterprise scale"], - "cross_check_notes": "Validated across conversations", - } - ) - } - } - ] - }, - ) - elif schema_name == "heavy_lead_scoring": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 95, - "confidence": 0.95, - "reasoning": "High confidence enterprise lead", - "positive_factors": ["Enterprise scale", "Strong buy-in"], - "negative_factors": [], - } - ) - } - } - ] - }, - ) - return httpx.Response(404) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - aihub_client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - - success = await execute_scoring_run( - session_factory, - run_id, - tenant_vkey="secret-vkey-token", - lease_seconds=40.0, - aihub_client=aihub_client, - clock=lambda: created_at + timedelta(seconds=3), - ) - assert success is True - assert call_sequence == ["evidence_extraction", "evidence_critique", "heavy_lead_scoring"] - - # Rule score = 40 + 20 = 60 - # AI score = 95 - # Final score = round(60 * 0.2 + 95 * 0.8) = round(12 + 76) = 88 - # Combined confidence = 0.90 * 0.2 + 0.95 * 0.8 = 0.18 + 0.76 = 0.94 >= 0.80 -> completed - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.status == "completed" - assert run.rule_score == 60 - assert run.ai_score == 95 - assert run.final_score == 88 - assert run.scoring_mode == ScoringMode.HEAVY_AI.value - assert run.degraded is False - assert run.confidence == 0.94 - assert run.ai_metadata == { - "model": "openai/gpt-4o-mini", - "effort_profile": "heavy", - "pass_count": 3, - } - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_heavy_execution_fallback_rules_only_review_on_pass_2_failure(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - - config = ScoringConfig( - version="heavy-config-fallback-1", - base_score=65, - confidence=0.85, - confidence_threshold=0.80, - hitl_policy_version="policy-1", - effort_profile=EffortProfile.HEAVY, - fallback_policy=FallbackPolicy.RULES_ONLY_REVIEW, - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-heavy-fb", - subject_ref="lead-p-3", - scoring_config_version="heavy-config-fallback-1", - idempotency_key="idemp-heavy-fb", - payload_hash="3" * 64, - status="pending", - snapshot_data=make_snapshot_data("lead-p-3"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=40), - ) - ) - await session.commit() - - call_sequence: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - body = json.loads(request.content.decode("utf-8")) - schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") - call_sequence.append(schema_name) - - if schema_name == "evidence_extraction": - return httpx.Response( - 200, - json={"choices": [{"message": {"content": json.dumps({"summary": "Ok"})}}]}, - ) - elif schema_name == "evidence_critique": - return httpx.Response(429, json={"error": "Rate limit exceeded in Pass 2"}) - return httpx.Response(404) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - aihub_client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - - await execute_scoring_run( - session_factory, - run_id, - tenant_vkey="secret-vkey-token", - lease_seconds=40.0, - aihub_client=aihub_client, - clock=lambda: created_at + timedelta(seconds=3), - ) - # Exactly 2 calls (Pass 3 was never called) - assert call_sequence == ["evidence_extraction", "evidence_critique"] - - # Fallback to rules_only_review: - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.status == "completed" - assert run.rule_score == 65 - assert run.ai_score is None # Partial results discarded - assert run.final_score == 65 - assert run.scoring_mode == ScoringMode.RULES_ONLY_FALLBACK.value - assert run.degraded is True - assert run.confidence == 0.85 - assert run.ai_metadata == { - "error_code": "aihub_quota_exceeded", - "detail": "Quota or rate limit exceeded on AIHub", - "fallback_policy": "rules_only_review", - } - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_heavy_execution_fallback_fail_run_on_pass_3_timeout(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - - config = ScoringConfig( - version="heavy-config-fail-1", - base_score=65, - confidence=0.85, - confidence_threshold=0.80, - hitl_policy_version="policy-1", - effort_profile=EffortProfile.HEAVY, - fallback_policy=FallbackPolicy.FAIL_RUN, - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-heavy-fail", - subject_ref="lead-p-4", - scoring_config_version="heavy-config-fail-1", - idempotency_key="idemp-heavy-fail", - payload_hash="4" * 64, - status="pending", - snapshot_data=make_snapshot_data("lead-p-4"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=40), - ) - ) - await session.commit() - - call_sequence: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - body = json.loads(request.content.decode("utf-8")) - schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") - call_sequence.append(schema_name) - - if schema_name == "evidence_extraction": - return httpx.Response( - 200, - json={"choices": [{"message": {"content": json.dumps({"summary": "Ok"})}}]}, - ) - elif schema_name == "evidence_critique": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "critique_summary": "Critique ok", - "risks": [], - "strengths": [], - } - ) - } - } - ] - }, - ) - elif schema_name == "heavy_lead_scoring": - raise httpx.ReadTimeout("Pass 3 timed out after 10s") - return httpx.Response(404) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - aihub_client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - - await execute_scoring_run( - session_factory, - run_id, - tenant_vkey="secret-vkey-token", - lease_seconds=40.0, - aihub_client=aihub_client, - clock=lambda: created_at + timedelta(seconds=3), - ) - assert call_sequence == ["evidence_extraction", "evidence_critique", "heavy_lead_scoring"] - - # Fallback to fail_run: - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.status == "failed" - assert run.rule_score is None - assert run.ai_score is None - assert run.final_score is None - assert run.scoring_mode is None - assert run.confidence is None - assert run.degraded is True - assert run.error_code == "aihub_timeout" - assert run.retryable is False - assert run.ai_metadata == { - "error_code": "aihub_timeout", - "detail": "Request to AIHub timed out", - "fallback_policy": "fail_run", - } - - events = list( - (await session.execute(select(OutboxEvent).where(OutboxEvent.run_id == run_id))) - .scalars() - .all() - ) - assert len(events) == 1 - data = events[0].payload["data"] - assert data["status"] == "failed" - assert data["error_code"] == "aihub_timeout" - assert data["retryable"] is False - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_sentinel_vkey_never_persisted_in_light_and_heavy_profiles( - temp_postgres_db: str, - caplog: pytest.LogCaptureFixture, - capsys: pytest.CaptureFixture[str], -): - caplog.set_level(logging.DEBUG) - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - sentinel_vkey_light = "sentinel-light-secret-vkey-8888" - sentinel_vkey_heavy = "sentinel-heavy-secret-vkey-9999" - - # 1. Test Light profile with Sentinel - run_id_light = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - config_light = ScoringConfig( - version="light-sentinel-1", - confidence=0.85, - confidence_threshold=0.80, - hitl_policy_version="policy-1", - effort_profile=EffortProfile.LIGHT, - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id_light, - tenant_id="tenant-s-light", - subject_ref="lead-s-1", - scoring_config_version="light-sentinel-1", - idempotency_key="idemp-s-light", - payload_hash="s1" * 32, - status="pending", - snapshot_data=make_snapshot_data("lead-s-1"), - effective_config=config_light.model_dump(mode="json"), - confidence_threshold=config_light.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=30), - ) - ) - await session.commit() - - def handler_light(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 80, - "confidence": 0.85, - "reasoning": "Light sentinel run", - "positive_factors": [], - "negative_factors": [], - } - ) - } - } - ] - }, - ) - - transport_light = httpx.MockTransport(handler_light) - async with httpx.AsyncClient(transport=transport_light) as http_client: - aihub_client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - await execute_scoring_run( - session_factory, - run_id_light, - tenant_vkey=sentinel_vkey_light, - lease_seconds=30.0, - aihub_client=aihub_client, - clock=lambda: created_at + timedelta(seconds=2), - ) - - # 2. Test Heavy profile with Sentinel (failing under fail_run) - run_id_heavy = str(uuid.uuid4()) - config_heavy = ScoringConfig( - version="heavy-sentinel-1", - confidence=0.85, - confidence_threshold=0.80, - hitl_policy_version="policy-1", - effort_profile=EffortProfile.HEAVY, - fallback_policy=FallbackPolicy.FAIL_RUN, - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id_heavy, - tenant_id="tenant-s-heavy", - subject_ref="lead-s-2", - scoring_config_version="heavy-sentinel-1", - idempotency_key="idemp-s-heavy", - payload_hash="s2" * 32, - status="pending", - snapshot_data=make_snapshot_data("lead-s-2"), - effective_config=config_heavy.model_dump(mode="json"), - confidence_threshold=config_heavy.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=40), - ) - ) - await session.commit() - - def handler_heavy(request: httpx.Request) -> httpx.Response: - return httpx.Response(500, json={"error": "Sentinel error"}) - - transport_heavy = httpx.MockTransport(handler_heavy) - async with httpx.AsyncClient(transport=transport_heavy) as http_client: - aihub_client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - await execute_scoring_run( - session_factory, - run_id_heavy, - tenant_vkey=sentinel_vkey_heavy, - lease_seconds=40.0, - aihub_client=aihub_client, - clock=lambda: created_at + timedelta(seconds=2), - ) - - # Verify sentinel secrets are absent everywhere in DB and Outbox - async with session_factory() as session: - run_l = await session.get(ScoringRun, run_id_light) - assert run_l is not None - assert sentinel_vkey_light not in str(run_l.__dict__) - assert sentinel_vkey_light not in str(run_l.ai_metadata) - - run_h = await session.get(ScoringRun, run_id_heavy) - assert run_h is not None - assert sentinel_vkey_heavy not in str(run_h.__dict__) - assert sentinel_vkey_heavy not in str(run_h.ai_metadata) - - events = list((await session.execute(select(OutboxEvent))).scalars().all()) - for event in events: - assert sentinel_vkey_light not in str(event.payload) - assert sentinel_vkey_heavy not in str(event.payload) - - # Verify sentinel secrets are absent from captured logs, stdout, and stderr - assert sentinel_vkey_light not in caplog.text - assert sentinel_vkey_heavy not in caplog.text - captured = capsys.readouterr() - assert sentinel_vkey_light not in captured.out - assert sentinel_vkey_light not in captured.err - assert sentinel_vkey_heavy not in captured.out - assert sentinel_vkey_heavy not in captured.err - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_light_execution_review_required_due_to_confidence(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - - config = ScoringConfig( - version="light-conf-rr", - base_score=40, - confidence=0.60, - confidence_threshold=0.80, - hitl_policy_version="policy-1", - rule_weight=0.4, - ai_weight=0.6, - effort_profile=EffortProfile.LIGHT, - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-light-rr", - subject_ref="lead-p-rr", - scoring_config_version="light-conf-rr", - idempotency_key="idemp-light-rr", - payload_hash="5" * 64, - status="pending", - snapshot_data=make_snapshot_data("lead-p-rr"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=30), - ) - ) - await session.commit() - - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 50, - "confidence": 0.50, - "reasoning": "Uncertain light evaluation", - "positive_factors": [], - "negative_factors": ["Sparse context"], - } - ) - } - } - ] - }, - ) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - aihub_client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - await execute_scoring_run( - session_factory, - run_id, - tenant_vkey="secret-vkey", - lease_seconds=30.0, - aihub_client=aihub_client, - clock=lambda: created_at + timedelta(seconds=2), - ) - - # Combined confidence = 0.60 * 0.4 + 0.50 * 0.6 = 0.24 + 0.30 = 0.54 < 0.80 -> review_required - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.status == "completed" - assert run.rule_score == 40 - assert run.ai_score == 50 - assert run.final_score == round(40 * 0.4 + 50 * 0.6) - assert run.scoring_mode == ScoringMode.LIGHT_AI.value - assert run.degraded is False - assert run.confidence == 0.54 - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_heavy_execution_review_required_due_to_confidence(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - - config = ScoringConfig( - version="heavy-conf-rr", - base_score=40, - confidence=0.50, - confidence_threshold=0.80, - hitl_policy_version="policy-1", - rule_weight=0.3, - ai_weight=0.7, - effort_profile=EffortProfile.HEAVY, - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-heavy-rr", - subject_ref="lead-p-hrr", - scoring_config_version="heavy-conf-rr", - idempotency_key="idemp-heavy-rr", - payload_hash="6" * 64, - status="pending", - snapshot_data=make_snapshot_data("lead-p-hrr"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=40), - ) - ) - await session.commit() - - def handler(request: httpx.Request) -> httpx.Response: - body = json.loads(request.content.decode("utf-8")) - schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") - if schema_name == "evidence_extraction": - return httpx.Response( - 200, - json={"choices": [{"message": {"content": json.dumps({"summary": "Weak info"})}}]}, - ) - elif schema_name == "evidence_critique": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "critique_summary": "High risk", - "risks": ["Vague requirements"], - "strengths": [], - } - ) - } - } - ] - }, - ) - elif schema_name == "heavy_lead_scoring": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 35, - "confidence": 0.40, - "reasoning": "Low confidence due to risks", - "positive_factors": [], - "negative_factors": ["High risk"], - } - ) - } - } - ] - }, - ) - return httpx.Response(404) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - aihub_client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - await execute_scoring_run( - session_factory, - run_id, - tenant_vkey="secret-vkey", - lease_seconds=40.0, - aihub_client=aihub_client, - clock=lambda: created_at + timedelta(seconds=3), - ) - - # Combined confidence = 0.50 * 0.3 + 0.40 * 0.7 = 0.15 + 0.28 = 0.43 < 0.80 -> review_required - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.status == "completed" - assert run.rule_score == 40 - assert run.ai_score == 35 - assert run.final_score == round(40 * 0.3 + 35 * 0.7) - assert run.scoring_mode == ScoringMode.HEAVY_AI.value - assert run.degraded is False - assert run.confidence == 0.43 - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_light_execution_fallback_rules_only_review_on_5xx(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - - config = ScoringConfig( - version="light-fb-5xx", - base_score=72, - confidence=0.88, - confidence_threshold=0.80, - hitl_policy_version="policy-1", - effort_profile=EffortProfile.LIGHT, - fallback_policy=FallbackPolicy.RULES_ONLY_REVIEW, - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-l-5xx", - subject_ref="lead-p-5xx", - scoring_config_version="light-fb-5xx", - idempotency_key="idemp-l-5xx", - payload_hash="7" * 64, - status="pending", - snapshot_data=make_snapshot_data("lead-p-5xx"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=30), - ) - ) - await session.commit() - - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(500, json={"error": "AIHub Server Error"}) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - aihub_client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - await execute_scoring_run( - session_factory, - run_id, - tenant_vkey="secret-vkey", - lease_seconds=30.0, - aihub_client=aihub_client, - clock=lambda: created_at + timedelta(seconds=2), - ) - - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.status == "completed" - assert run.rule_score == 72 - assert run.ai_score is None - assert run.final_score == 72 - assert run.scoring_mode == ScoringMode.RULES_ONLY_FALLBACK.value - assert run.degraded is True - assert run.ai_metadata == { - "error_code": "aihub_server_error", - "detail": "AIHub returned server error", - "fallback_policy": "rules_only_review", - } - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_light_execution_fallback_fail_run_on_quota(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - - config = ScoringConfig( - version="light-fail-429", - base_score=72, - confidence=0.88, - confidence_threshold=0.80, - hitl_policy_version="policy-1", - effort_profile=EffortProfile.LIGHT, - fallback_policy=FallbackPolicy.FAIL_RUN, - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-l-429", - subject_ref="lead-p-429", - scoring_config_version="light-fail-429", - idempotency_key="idemp-l-429", - payload_hash="8" * 64, - status="pending", - snapshot_data=make_snapshot_data("lead-p-429"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=30), - ) - ) - await session.commit() - - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(429, json={"error": "Rate limit exceeded"}) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - aihub_client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - await execute_scoring_run( - session_factory, - run_id, - tenant_vkey="secret-vkey", - lease_seconds=30.0, - aihub_client=aihub_client, - clock=lambda: created_at + timedelta(seconds=2), - ) - - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.status == "failed" - assert run.rule_score is None - assert run.ai_score is None - assert run.final_score is None - assert run.scoring_mode is None - assert run.error_code == "aihub_quota_exceeded" - assert run.retryable is False - assert run.ai_metadata == { - "error_code": "aihub_quota_exceeded", - "detail": "Quota or rate limit exceeded on AIHub", - "fallback_policy": "fail_run", - } - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_missing_vkey_light_execution_falls_back_to_rules_only(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - # Test Light with unconfigured client - run_id_l = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - config_l = ScoringConfig( - version="unconf-light", - base_score=60, - confidence=0.8, - confidence_threshold=0.8, - hitl_policy_version="policy-1", - effort_profile=EffortProfile.LIGHT, - fallback_policy=FallbackPolicy.RULES_ONLY_REVIEW, - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id_l, - tenant_id="tenant-unconf-l", - subject_ref="lead-unconf-l", - scoring_config_version="unconf-light", - idempotency_key="idemp-unconf-l", - payload_hash="9" * 64, - status="pending", - snapshot_data=make_snapshot_data("lead-unconf-l"), - effective_config=config_l.model_dump(mode="json"), - confidence_threshold=config_l.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=30), - ) - ) - await session.commit() - - await execute_scoring_run( - session_factory, - run_id_l, - tenant_vkey=None, - lease_seconds=30.0, - aihub_client=None, - clock=lambda: created_at + timedelta(seconds=2), - ) - - async with session_factory() as session: - run = await session.get(ScoringRun, run_id_l) - assert run is not None - assert run.status == "completed" - assert run.rule_score == 60 - assert run.ai_score is None - assert run.final_score == 60 - assert run.scoring_mode == ScoringMode.RULES_ONLY_FALLBACK.value - assert run.degraded is True - assert run.error_code == "aihub_vkey_missing" - assert run.ai_metadata == { - "error_code": "aihub_vkey_missing", - "detail": "Tenant AIHub credential is missing", - "fallback_policy": "rules_only_review", - } - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_non_cancellation_exception_light_and_heavy_uses_canned_error( - temp_postgres_db: str, - caplog: pytest.LogCaptureFixture, - capsys: pytest.CaptureFixture[str], -): - caplog.set_level(logging.DEBUG) - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - sentinel_secret = "sentinel-leak-crash-exception-7777" - - config = ScoringConfig( - version="heavy-crash-cfg", - base_score=55, - confidence=0.85, - confidence_threshold=0.8, - hitl_policy_version="policy-1", - effort_profile=EffortProfile.HEAVY, - fallback_policy=FallbackPolicy.RULES_ONLY_REVIEW, - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-h-crash", - subject_ref="lead-h-crash", - scoring_config_version="heavy-crash-cfg", - idempotency_key="idemp-h-crash", - payload_hash="a" * 64, - status="pending", - snapshot_data=make_snapshot_data("lead-h-crash"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=40), - ) - ) - await session.commit() - - class CrashingClient: - async def extract_evidence(self, *args, **kwargs): - return EvidenceExtractionResult(summary="ok") - - async def critique_evidence(self, *args, **kwargs): - raise RuntimeError(f"Crashing during heavy critique with secret {sentinel_secret}") - - async def score_lead_heavy(self, *args, **kwargs): - raise RuntimeError(f"Crashing during heavy scoring with secret {sentinel_secret}") - - async def analyze_and_score_lead(self, *args, **kwargs): - raise RuntimeError(f"Crashing during light with secret {sentinel_secret}") - - async def score_lead(self, *args, **kwargs): - raise RuntimeError(f"Crashing with secret {sentinel_secret}") - - await execute_scoring_run( - session_factory, - run_id, - tenant_vkey=sentinel_secret, - lease_seconds=40.0, - aihub_client=CrashingClient(), - clock=lambda: created_at + timedelta(seconds=2), - ) - - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.status == "completed" - assert run.rule_score == 55 - assert run.final_score == 55 - assert run.scoring_mode == ScoringMode.RULES_ONLY_FALLBACK.value - assert run.degraded is True - assert run.ai_metadata == { - "error_code": "aihub_unknown_error", - "detail": "Unexpected internal execution error", - "fallback_policy": "rules_only_review", - } - - # Verify sentinel_secret NEVER appears anywhere in DB record or outbox - assert sentinel_secret not in str(run.__dict__) - assert sentinel_secret not in str(run.ai_metadata) - assert sentinel_secret not in str(run.effective_config) - - # Verify sentinel_secret NEVER appears anywhere in captured logs, stdout, or stderr - assert sentinel_secret not in caplog.text - captured = capsys.readouterr() - assert sentinel_secret not in captured.err - assert sentinel_secret not in captured.out - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_execution_fallback_allowed_false_forces_failed_status_under_rules_only_review( - temp_postgres_db: str, -): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - - # Config has rules_only_review fallback policy - config = ScoringConfig( - version="fb-not-allowed-cfg", - base_score=70, - confidence=0.85, - confidence_threshold=0.8, - hitl_policy_version="policy-1", - effort_profile=EffortProfile.MEDIUM, - fallback_policy=FallbackPolicy.RULES_ONLY_REVIEW, - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-no-fb", - subject_ref="lead-no-fb", - scoring_config_version="fb-not-allowed-cfg", - idempotency_key="idemp-no-fb", - payload_hash="b" * 64, - status="pending", - snapshot_data=make_snapshot_data("lead-no-fb"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=30), - ) - ) - await session.commit() - - class NonFallbackErrorClient: - async def extract_evidence(self, *args, **kwargs): - raise AIHubError( - "Critical auth failure not allowed to fallback", - error_code="aihub_non_fallbackable_error", - retryable=False, - fallback_allowed=False, - detail="Non-fallbackable failure", - ) - - async def score_lead(self, *args, **kwargs): - raise AIHubError("Score error", fallback_allowed=False) - - async def analyze_and_score_lead(self, *args, **kwargs): - raise AIHubError("Light error", fallback_allowed=False) - - async def critique_evidence(self, *args, **kwargs): - raise AIHubError("Critique error", fallback_allowed=False) - - async def score_lead_heavy(self, *args, **kwargs): - raise AIHubError("Heavy error", fallback_allowed=False) - - await execute_scoring_run( - session_factory, - run_id, - tenant_vkey="secret-vkey", - lease_seconds=30.0, - aihub_client=NonFallbackErrorClient(), - clock=lambda: created_at + timedelta(seconds=2), - ) - - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - # MUST be failed because fallback_allowed is False, even though config has rules_only_review - assert run.status == "failed" - assert run.rule_score is None - assert run.ai_score is None - assert run.final_score is None - assert run.error_code == "aihub_non_fallbackable_error" - assert run.retryable is False - assert run.ai_metadata == { - "error_code": "aihub_non_fallbackable_error", - "detail": "Non-fallbackable failure", - "fallback_policy": "rules_only_review", - } - - await engine.dispose() diff --git a/old_repo/tests/test_configurable_demo_backend.py b/old_repo/tests/test_configurable_demo_backend.py deleted file mode 100644 index 209b4f4..0000000 --- a/old_repo/tests/test_configurable_demo_backend.py +++ /dev/null @@ -1,899 +0,0 @@ -import asyncio -import json -import uuid -from datetime import UTC, datetime, timedelta -from typing import Any - -import httpx -import pytest -from alembic import command -from alembic.config import Config -from pydantic import SecretStr -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine - -from lead_scoring.aihub.client import AIHubClient -from lead_scoring.config import ServiceCallerConfig, Settings -from lead_scoring.db.models.scoring_run import ScoringRun -from lead_scoring.execution import ExecutionRuntime, execute_scoring_run -from lead_scoring.main import create_app -from lead_scoring.schemas.scoring_config import ( - EffortProfile, - ScoringConfig, -) -from lead_scoring.schemas.snapshot import CanonicalSnapshot -from lead_scoring.scoring import ( - RuleEvaluationStatus, - derive_outcome_fields, - evaluate_education_rules, -) - - -def get_alembic_config(database_url: str) -> Config: - from pathlib import Path - - root_dir = Path(__file__).resolve().parent.parent - config = Config(str(root_dir / "alembic.ini")) - config.attributes["sqlalchemy_url"] = database_url - return config - - -def make_education_snapshot( - custom_fields: dict[str, Any], - conversations: list[dict[str, Any]] | None = None, -) -> CanonicalSnapshot: - if conversations is None: - conversations = [ - { - "conversation_id": "conv-1", - "channel": "zalo", - "messages": [ - { - "message_id": "msg-1", - "sender_type": "contact", - "sent_at": "2026-08-28T11:00:00Z", - "content": "Toi muon hoi hoc phi khoa IELTS", - } - ], - } - ] - return CanonicalSnapshot.model_validate( - { - "version": "1.0.0", - "scope": "linked_contact_history", - "captured_at": "2026-08-28T12:00:00Z", - "lead_profile": { - "ems_lead_id": "ems-lead-edu-1", - "name": "Nguyen Van An", - "phone": "+84901234567", - "email": "an.nguyen@example.com", - "status": "new", - "custom_fields": custom_fields, - }, - "conversations": conversations, - "hitl_policy": { - "policy_version": "1.0.0", - "confidence_threshold": 0.8, - }, - } - ) - - -# ============================================================================ -# SEAM 1: Fixed Education Scoring Rules & Breakdown -# ============================================================================ - - -def test_education_age_exact_bands(): - config = ScoringConfig( - version="edu-age-v1", - base_score=0, - confidence=0.85, - confidence_threshold=0.8, - hitl_policy_version="1.0.0", - education_demo_enabled=True, - ) - - # 1. Age 3-15 -> 40 points - snap_10 = make_education_snapshot({"student_age": 10}, conversations=[]) - score_10, breakdown_10, _, _ = evaluate_education_rules(snap_10, config) - assert score_10 == 40 - age_item_10 = next(b for b in breakdown_10 if b.rule_name == "student_age") - assert age_item_10.status == RuleEvaluationStatus.MATCHED - assert age_item_10.points_awarded == 40 - - # 2. Age 16-18 -> 20 points - snap_17 = make_education_snapshot({"student_age": 17}, conversations=[]) - score_17, breakdown_17, _, _ = evaluate_education_rules(snap_17, config) - assert score_17 == 20 - age_item_17 = next(b for b in breakdown_17 if b.rule_name == "student_age") - assert age_item_17.status == RuleEvaluationStatus.MATCHED - assert age_item_17.points_awarded == 20 - - # 3. Age outside [3, 18] -> 0 points (not matched) - snap_25 = make_education_snapshot({"student_age": 25}, conversations=[]) - score_25, breakdown_25, _, _ = evaluate_education_rules(snap_25, config) - assert score_25 == 0 - age_item_25 = next(b for b in breakdown_25 if b.rule_name == "student_age") - assert age_item_25.status == RuleEvaluationStatus.NOT_MATCHED - assert age_item_25.points_awarded == 0 - - # 4. Age missing -> 0 points (skipped missing data) - snap_none = make_education_snapshot({}, conversations=[]) - score_none, breakdown_none, _, _ = evaluate_education_rules(snap_none, config) - assert score_none == 0 - age_item_none = next(b for b in breakdown_none if b.rule_name == "student_age") - assert age_item_none.status == RuleEvaluationStatus.SKIPPED_MISSING_DATA - assert age_item_none.points_awarded == 0 - - -def test_education_recency_exact_bands(): - config = ScoringConfig( - version="edu-rec-v1", - base_score=0, - confidence=0.85, - confidence_threshold=0.8, - hitl_policy_version="1.0.0", - education_demo_enabled=True, - ) - - def snap_at(hours_ago: float) -> CanonicalSnapshot: - captured = datetime(2026, 8, 28, 12, 0, tzinfo=UTC) - sent = captured - timedelta(hours=hours_ago) - return CanonicalSnapshot.model_validate( - { - "version": "1.0.0", - "scope": "linked_contact_history", - "captured_at": captured.isoformat(), - "lead_profile": { - "ems_lead_id": "ems-lead-rec-1", - "custom_fields": {}, - }, - "conversations": [ - { - "conversation_id": "conv-rec", - "messages": [ - { - "message_id": "msg-rec", - "sender_type": "contact", - "sent_at": sent.isoformat(), - "content": "hello", - } - ], - } - ], - } - ) - - # 0-3d (e.g. 1d = 24h) -> 40 points - s1, b1, _, _ = evaluate_education_rules(snap_at(24.0), config) - assert s1 == 40 - assert next(b for b in b1 if b.rule_name == "contact_message_recency").points_awarded == 40 - - # 4-7d (e.g. 5d = 120h) -> 25 points - s2, b2, _, _ = evaluate_education_rules(snap_at(120.0), config) - assert s2 == 25 - assert next(b for b in b2 if b.rule_name == "contact_message_recency").points_awarded == 25 - - # 8-14d (e.g. 10d = 240h) -> 15 points - s3, b3, _, _ = evaluate_education_rules(snap_at(240.0), config) - assert s3 == 15 - assert next(b for b in b3 if b.rule_name == "contact_message_recency").points_awarded == 15 - - # 15-30d (e.g. 20d = 480h) -> 5 points - s4, b4, _, _ = evaluate_education_rules(snap_at(480.0), config) - assert s4 == 5 - assert next(b for b in b4 if b.rule_name == "contact_message_recency").points_awarded == 5 - - # >30d (e.g. 35d = 840h) -> 0 points (not matched) - s5, b5, _, _ = evaluate_education_rules(snap_at(840.0), config) - assert s5 == 0 - item_5 = next(b for b in b5 if b.rule_name == "contact_message_recency") - assert item_5.status == RuleEvaluationStatus.NOT_MATCHED - assert item_5.points_awarded == 0 - - # No customer message -> skipped_missing_data (0 points) - s6, b6, _, _ = evaluate_education_rules(make_education_snapshot({}, conversations=[]), config) - assert s6 == 0 - item_6 = next(b for b in b6 if b.rule_name == "contact_message_recency") - assert item_6.status == RuleEvaluationStatus.SKIPPED_MISSING_DATA - assert item_6.points_awarded == 0 - - -def test_education_source_exact_bands(): - config = ScoringConfig( - version="edu-src-v1", - base_score=0, - confidence=0.85, - confidence_threshold=0.8, - hitl_policy_version="1.0.0", - education_demo_enabled=True, - ) - - # Referral/Walk-in = 20 - s_ref, b_ref, _, _ = evaluate_education_rules( - make_education_snapshot({"lead_source": "Walk-in"}, conversations=[]), config - ) - assert s_ref == 20 - assert next(b for b in b_ref if b.rule_name == "lead_source").points_awarded == 20 - - # Call-in = 15 - s_call, b_call, _, _ = evaluate_education_rules( - make_education_snapshot({"lead_source": "hotline"}, conversations=[]), config - ) - assert s_call == 15 - assert next(b for b in b_call if b.rule_name == "lead_source").points_awarded == 15 - - # Digital = 10 - s_dig, b_dig, _, _ = evaluate_education_rules( - make_education_snapshot({"lead_source": "zalo"}, conversations=[]), config - ) - assert s_dig == 10 - assert next(b for b in b_dig if b.rule_name == "lead_source").points_awarded == 10 - - # Campaign = 8 - s_cam, b_cam, _, _ = evaluate_education_rules( - make_education_snapshot({"lead_source": "ads"}, conversations=[]), config - ) - assert s_cam == 8 - assert next(b for b in b_cam if b.rule_name == "lead_source").points_awarded == 8 - - # other nonempty = 5 - s_oth, b_oth, _, _ = evaluate_education_rules( - make_education_snapshot({"lead_source": "alumni_network"}, conversations=[]), config - ) - assert s_oth == 5 - assert next(b for b in b_oth if b.rule_name == "lead_source").points_awarded == 5 - - # missing = 0 (skipped) - s_mis, b_mis, _, _ = evaluate_education_rules( - make_education_snapshot({}, conversations=[]), config - ) - assert s_mis == 0 - assert next(b for b in b_mis if b.rule_name == "lead_source").points_awarded == 0 - - -def test_education_completeness_four_groups(): - config = ScoringConfig( - version="edu-comp-v1", - base_score=0, - confidence=0.85, - confidence_threshold=0.8, - hitl_policy_version="1.0.0", - education_demo_enabled=True, - ) - - # All 4 present: age, course_interest, customer_message, source -> 100% (1.0) - snap_full = make_education_snapshot( - { - "student_age": 12, - "course_interest": "IELTS Junior", - "lead_source": "facebook", - } - ) - _, _, _, comp_full = evaluate_education_rules(snap_full, config) - assert comp_full.completeness_ratio == 1.0 - assert set(comp_full.present_fields) == { - "age", - "course_interest", - "customer_message", - "source", - } - assert len(comp_full.missing_fields) == 0 - - # 2 of 4 present: customer_message and source -> 50% (0.50) - snap_half = make_education_snapshot({"lead_source": "facebook"}) - _, _, _, comp_half = evaluate_education_rules(snap_half, config) - assert comp_half.completeness_ratio == 0.50 - assert set(comp_half.present_fields) == {"customer_message", "source"} - assert set(comp_half.missing_fields) == {"age", "course_interest"} - - # 1 of 4 present: course_interest only -> 25% (0.25) - snap_quarter = make_education_snapshot({"course_interest": "General English"}, conversations=[]) - _, _, _, comp_quarter = evaluate_education_rules(snap_quarter, config) - assert comp_quarter.completeness_ratio == 0.25 - assert set(comp_quarter.present_fields) == {"course_interest"} - - -# ============================================================================ -# SEAM 2: Configured Medium System Prompts -# ============================================================================ - - -@pytest.mark.asyncio -async def test_medium_pipeline_receives_configured_prompts(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 28, 12, 0, tzinfo=UTC) - - custom_extract = "CUSTOM SYSTEM PROMPT PASS 1: Extract academic goals only." - custom_score = "CUSTOM SYSTEM PROMPT PASS 2: Score strictly on enrollment urgency." - - config = ScoringConfig( - version="custom-prompts-v1", - base_score=50, - confidence=0.9, - confidence_threshold=0.8, - hitl_policy_version="1.0.0", - education_demo_enabled=True, - effort_profile=EffortProfile.MEDIUM, - medium_extract_prompt=custom_extract, - medium_score_prompt=custom_score, - ) - - snapshot = make_education_snapshot( - {"student_age": 15, "course_interest": "IELTS", "lead_source": "zalo"} - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-edu-prompt", - subject_ref="ems-lead-edu-1", - scoring_config_version="custom-prompts-v1", - idempotency_key="idemp-edu-prompt", - payload_hash="p" * 64, - status="pending", - snapshot_data=snapshot.model_dump(mode="json"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=30), - ) - ) - await session.commit() - - observed_prompts: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - body = json.loads(request.content.decode("utf-8")) - system_msg = next(m["content"] for m in body["messages"] if m["role"] == "system") - observed_prompts.append(system_msg) - - schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") - if schema_name == "evidence_extraction": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - {"summary": "Student wants IELTS", "intent": "high"} - ) - } - } - ] - }, - ) - elif schema_name == "lead_scoring": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 88, - "confidence": 0.92, - "reasoning": "High enrollment intent.", - "positive_factors": ["IELTS target"], - "negative_factors": [], - } - ) - } - } - ] - }, - ) - return httpx.Response(404) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - aihub_client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - success = await execute_scoring_run( - session_factory, - run_id, - tenant_vkey="secret-vkey-token-123", - lease_seconds=30.0, - aihub_client=aihub_client, - clock=lambda: created_at + timedelta(seconds=2), - ) - assert success is True - - assert len(observed_prompts) == 2 - assert observed_prompts[0] == custom_extract - assert observed_prompts[1] == custom_score - - -# ============================================================================ -# SEAM 3: Execution Trace Secret Exclusion & Low Completeness Forced Review -# ============================================================================ - - -@pytest.mark.asyncio -async def test_execution_trace_persisted_and_excludes_secrets(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 28, 12, 0, tzinfo=UTC) - secret_vkey = "top-secret-tenant-vkey-xyz-789" - - config = ScoringConfig( - version="trace-test-v1", - base_score=20, - confidence=0.85, - confidence_threshold=0.8, - hitl_policy_version="1.0.0", - education_demo_enabled=True, - effort_profile=EffortProfile.MEDIUM, - ) - snapshot = make_education_snapshot( - { - "student_age": 17, - "course_interest": "IELTS", - "lead_source": "website", - } - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-trace-sec", - subject_ref="ems-lead-edu-1", - scoring_config_version="trace-test-v1", - idempotency_key="idemp-trace-sec", - payload_hash="s" * 64, - status="pending", - snapshot_data=snapshot.model_dump(mode="json"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=30), - ) - ) - await session.commit() - - def handler(request: httpx.Request) -> httpx.Response: - body = json.loads(request.content.decode("utf-8")) - schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") - if schema_name == "evidence_extraction": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - {"summary": "Evidence extracted", "intent": "medium"} - ) - } - } - ] - }, - ) - elif schema_name == "lead_scoring": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 82, - "confidence": 0.88, - "reasoning": "Strong match for intensive class.", - "positive_factors": ["High intent"], - "negative_factors": [], - } - ) - } - } - ] - }, - ) - return httpx.Response(404) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - aihub_client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - await execute_scoring_run( - session_factory, - run_id, - tenant_vkey=secret_vkey, - lease_seconds=30.0, - aihub_client=aihub_client, - clock=lambda: created_at + timedelta(seconds=2), - ) - - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.execution_trace is not None - - trace = run.execution_trace - trace_json = json.dumps(trace) - - # Critical Security Assertions: - assert secret_vkey not in trace_json - assert "Authorization" not in trace_json - assert "Bearer" not in trace_json - assert config.medium_extract_prompt not in trace_json - - # Required fields in trace: - assert "normalized_features" in trace - assert trace["normalized_features"]["student_age"] == 17 - assert "rule_breakdown" in trace - assert len(trace["rule_breakdown"]) >= 3 - assert "data_completeness" in trace - assert trace["data_completeness"]["completeness_ratio"] == 1.0 - assert "ai_evaluation" in trace - assert trace["ai_evaluation"]["reasoning"] == "Strong match for intensive class." - assert "calculation" in trace - assert "terminal_status_reason" in trace - - -@pytest.mark.asyncio -async def test_low_completeness_forces_review_required(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 28, 12, 0, tzinfo=UTC) - - config = ScoringConfig( - version="trace-low-comp-v1", - base_score=50, - confidence=0.95, - confidence_threshold=0.80, - hitl_policy_version="1.0.0", - education_demo_enabled=True, - effort_profile=EffortProfile.LIGHT, - completeness_threshold=0.50, # 50% required - ) - # Only 1 of 4 completeness groups present (customer_message in conv): 25% < 50% - snapshot = make_education_snapshot({}) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-low-comp", - subject_ref="ems-lead-edu-1", - scoring_config_version="trace-low-comp-v1", - idempotency_key="idemp-low-comp", - payload_hash="c" * 64, - status="pending", - snapshot_data=snapshot.model_dump(mode="json"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=30), - ) - ) - await session.commit() - - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 90, - "confidence": 0.95, - "reasoning": "High confidence AI score.", - "positive_factors": [], - "negative_factors": [], - } - ) - } - } - ] - }, - ) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - aihub_client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - await execute_scoring_run( - session_factory, - run_id, - tenant_vkey="secret-key", - lease_seconds=30.0, - aihub_client=aihub_client, - clock=lambda: created_at + timedelta(seconds=2), - ) - - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.status == "completed" - assert run.execution_trace is not None - - -def test_public_outcome_fields_deterministic_derivation(): - # 1. High priority outcome - cat_hi, exp_hi, act_hi = derive_outcome_fields( - status="completed", - effective_score=85, - final_score=85, - rule_score=80, - ai_reasoning="Student expressed urgency for upcoming exam.", - ) - assert cat_hi == "hot" - assert exp_hi == "Lead đạt 85/100 sau khi kết hợp bộ quy tắc và phân tích hội thoại bằng AI." - assert act_hi is not None and "24 giờ" in act_hi - - # 2. Medium priority outcome - cat_med, exp_med, act_med = derive_outcome_fields( - status="completed", - effective_score=65, - final_score=65, - rule_score=60, - ) - assert cat_med == "warm" - assert exp_med is not None and "65/100" in exp_med - assert act_med is not None and "khóa học" in act_med.lower() - - # 3. Fallback when AI reasoning is None (rule-only / degraded) - cat_low, exp_low, act_low = derive_outcome_fields( - status="review_required", - effective_score=None, - final_score=40, - rule_score=40, - ai_reasoning=None, - ) - assert cat_low == "warm" - assert exp_low is not None and "40/100" in exp_low - assert act_low is not None and "chăm sóc" in act_low.lower() - - -# ============================================================================ -# SEAM 4: Minimal Authenticated Internal GET Endpoint & Admission Validation -# ============================================================================ - - -@pytest.mark.asyncio -async def test_admission_rejects_insufficient_evidence_and_accepts_valid(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - config = ScoringConfig( - version="1.0.0", - confidence=0.85, - confidence_threshold=0.8, - hitl_policy_version="1.0.0", - education_demo_enabled=True, - ) - - settings = Settings( - environment="development", - database_url=temp_postgres_db, - scoring_configs=[config], - service_callers=[ - ServiceCallerConfig( - caller_id="test-caller", - token=SecretStr("valid-token"), - allowed_apps=["omni"], - allowed_features=["lead-scoring"], - allowed_tenants=["tenant-adm-test"], - ) - ], - tenant_allowlist=["tenant-adm-test"], - ) - app = create_app(settings=settings) - app.state.session_factory = session_factory - app.state.execution_runtime = ExecutionRuntime(session_factory, 30.0, 5.0) - - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url="http://test" - ) as client: - # 1. No course_interest AND no customer message -> 422 insufficient_scoring_evidence - payload_insufficient = { - "tenant_id": "tenant-adm-test", - "subject_ref": "ems-lead-no-ev", - "scoring_config_version": "1.0.0", - "snapshot": { - "version": "1.0.0", - "scope": "linked_contact_history", - "captured_at": "2026-08-28T12:00:00Z", - "lead_profile": { - "ems_lead_id": "ems-lead-no-ev", - "custom_fields": {"student_age": 15}, # no course_interest - }, - "conversations": [], # no customer message - }, - } - resp = await client.post( - "/v1/scoring-runs", - json=payload_insufficient, - headers={ - "Authorization": "Bearer valid-token", - "X-Tenant-VKey": "vkey-123", - "Idempotency-Key": "adm-idem-1", - "X-DotB-App": "omni", - "X-DotB-Feature": "lead-scoring", - }, - ) - assert resp.status_code == 422 - assert resp.json()["error_code"] == "insufficient_scoring_evidence" - - # 2. Has course_interest without customer message -> 202 accepted - payload_with_course = { - "tenant_id": "tenant-adm-test", - "subject_ref": "ems-lead-course-only", - "scoring_config_version": "1.0.0", - "snapshot": { - "version": "1.0.0", - "scope": "linked_contact_history", - "captured_at": "2026-08-28T12:00:00Z", - "lead_profile": { - "ems_lead_id": "ems-lead-course-only", - "custom_fields": {"course_interest": "IELTS 7.0+"}, - }, - "conversations": [], - }, - } - resp_ok = await client.post( - "/v1/scoring-runs", - json=payload_with_course, - headers={ - "Authorization": "Bearer valid-token", - "X-Tenant-VKey": "vkey-123", - "Idempotency-Key": "adm-idem-2", - "X-DotB-App": "omni", - "X-DotB-Feature": "lead-scoring", - }, - ) - assert resp_ok.status_code == 202 - - -@pytest.mark.asyncio -async def test_internal_trace_endpoint_access_and_rejection(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - now = datetime(2026, 8, 28, 12, 0, tzinfo=UTC) - - test_trace = { - "run_id": run_id, - "status": "completed", - "calculation": {"final_score": 85}, - } - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-trace-ep", - subject_ref="lead-trace-ep", - scoring_config_version="1.0.0", - idempotency_key="idemp-trace-ep", - payload_hash="t" * 64, - status="completed", - snapshot_data={}, - execution_trace=test_trace, - created_at=now, - updated_at=now, - ) - ) - await session.commit() - - # 1. Non-production environment: authorized caller should receive trace - settings_dev = Settings( - environment="development", - database_url=temp_postgres_db, - service_callers=[ - ServiceCallerConfig( - caller_id="test-caller", - token=SecretStr("valid-token"), - allowed_apps=["omni"], - allowed_features=["lead-scoring"], - allowed_tenants=["tenant-trace-ep"], - ) - ], - tenant_allowlist=["tenant-trace-ep"], - ) - app_dev = create_app(settings=settings_dev) - app_dev.state.session_factory = session_factory - - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app_dev), base_url="http://test" - ) as client: - # Success path - resp = await client.get( - f"/v1/scoring-runs/{run_id}/trace", - headers={ - "Authorization": "Bearer valid-token", - "X-Tenant-Id": "tenant-trace-ep", - }, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["run_id"] == run_id - assert data["calculation"]["final_score"] == 85 - - # Cross-tenant forbidden path - cross_resp = await client.get( - f"/v1/scoring-runs/{run_id}/trace", - headers={ - "Authorization": "Bearer valid-token", - "X-Tenant-Id": "other-tenant", - }, - ) - assert cross_resp.status_code == 403 - - # Unauthorized (no token) - unauth_resp = await client.get( - f"/v1/scoring-runs/{run_id}/trace", - headers={"X-Tenant-Id": "tenant-trace-ep"}, - ) - assert unauth_resp.status_code == 401 - - # 2. Production environment: MUST reject access with 403 - settings_prod = Settings( - environment="production", - database_url=temp_postgres_db, - aihub_base_url="https://aihub.internal", - service_callers=[ - ServiceCallerConfig( - caller_id="test-caller", - token=SecretStr("valid-token"), - allowed_apps=["omni"], - allowed_features=["lead-scoring"], - allowed_tenants=["tenant-trace-ep"], - ) - ], - tenant_allowlist=["tenant-trace-ep"], - ) - app_prod = create_app(settings=settings_prod) - app_prod.state.session_factory = session_factory - - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app_prod), base_url="http://test" - ) as client: - prod_resp = await client.get( - f"/v1/scoring-runs/{run_id}/trace", - headers={ - "Authorization": "Bearer valid-token", - "X-Tenant-Id": "tenant-trace-ep", - }, - ) - assert prod_resp.status_code == 403 - assert prod_resp.json()["error_code"] == "trace_disabled_in_production" - - -def test_education_rule_points_come_from_selected_config(): - config = ScoringConfig( - version="edu-custom-points-v1", - base_score=0, - confidence=0.85, - confidence_threshold=0.8, - hitl_policy_version="1.0.0", - education_demo_enabled=True, - age_primary_points=31, - ) - snapshot = make_education_snapshot({"student_age": 10}, conversations=[]) - - score, breakdown, _, _ = evaluate_education_rules(snapshot, config) - - assert score == 31 - assert next(item for item in breakdown if item.rule_name == "student_age").points_awarded == 31 diff --git a/old_repo/tests/test_contract_pack_cli.py b/old_repo/tests/test_contract_pack_cli.py deleted file mode 100644 index 8e90556..0000000 --- a/old_repo/tests/test_contract_pack_cli.py +++ /dev/null @@ -1,219 +0,0 @@ -import json -from pathlib import Path -from typing import Literal -from uuid import UUID - -from pydantic import BaseModel, ConfigDict - -from lead_scoring.cli.contract_pack import ( - API_MAJOR_VERSION, - CONTRACT_PACK_VERSION, - MARKER_CLOSE, - MARKER_OPEN, - canonical_json, - generate_contract_files, - run_contract_pack_cli, - validate_fixture_corpus, -) -from lead_scoring.cli.webhook_contract import generate_contract_files as generate_webhook_files -from lead_scoring.schemas.errors import ErrorTaxonomy -from lead_scoring.schemas.lineage import ScoringRunLineage -from lead_scoring.schemas.outcomes import OutcomeTaxonomy -from lead_scoring.schemas.review import LeadScoringReviewedEvent, ReviewResponse -from lead_scoring.schemas.scoring_config import ScoringConfig, ScoringConfigVersionSelection -from lead_scoring.schemas.scoring_run import ( - CreateScoringRunRequest, - CreateScoringRunResponse, - ScoringRunResponse, -) -from lead_scoring.schemas.snapshot import CanonicalSnapshot -from lead_scoring.schemas.webhook import ( - LeadScoringTerminalEvent, - WebhookIngestErrorResponse, -) - - -class LegacyTerminalEvent(BaseModel): - """Consumer model tối giản mô phỏng reader cũ tương thích additive.""" - - model_config = ConfigDict(extra="ignore") - - event_id: UUID - event_type: Literal["lead_scoring.run.terminal"] - event_version: Literal["1.0.0"] - tenant_id: str - subject_ref: str - scoring_config_version: str - run_id: UUID - run_created_at: str - occurred_at: str - data: dict - - -def test_contract_pack_is_deterministic_and_versioned(): - files = generate_contract_files() - - assert files == generate_contract_files() - assert json.loads(files["contract.json"])["contract_pack_version"] == CONTRACT_PACK_VERSION - manifest = json.loads(files["contract.json"]) - assert manifest["api_major_version"] == API_MAJOR_VERSION - assert manifest["release_status"] == "pre-release" - assert set(manifest["profiles"]) == { - "canonical_snapshot", - "scoring_config", - "admission", - "polling", - "review", - "lineage", - "webhook", - "errors", - } - assert all(profile["version"] == "1.0.0" for profile in manifest["profiles"].values()) - assert "openapi.json" in files - assert "SHA256SUMS" in files - assert ( - files["fixtures/tenant-unavailable.json"] - == generate_webhook_files()["fixtures/tenant-unavailable.json"] - ) - - -def test_contract_schemas_and_fixture_corpus_use_pydantic_models(): - files = generate_contract_files() - - assert json.loads(files["schemas/canonical-snapshot.schema.json"]) == ( - CanonicalSnapshot.model_json_schema() - ) - assert ( - json.loads(files["schemas/scoring-config.schema.json"]) == ScoringConfig.model_json_schema() - ) - assert json.loads(files["schemas/scoring-config-selection.schema.json"]) == ( - ScoringConfigVersionSelection.model_json_schema() - ) - assert json.loads(files["schemas/admission-request.schema.json"]) == ( - CreateScoringRunRequest.model_json_schema() - ) - assert json.loads(files["schemas/admission-response.schema.json"]) == ( - CreateScoringRunResponse.model_json_schema() - ) - assert json.loads(files["schemas/polling-response.schema.json"]) == ( - ScoringRunResponse.model_json_schema() - ) - assert ( - json.loads(files["schemas/review-response.schema.json"]) - == ReviewResponse.model_json_schema() - ) - assert json.loads(files["schemas/lineage.schema.json"]) == ScoringRunLineage.model_json_schema() - assert json.loads(files["schemas/terminal-event.schema.json"]) == ( - LeadScoringTerminalEvent.model_json_schema() - ) - assert json.loads(files["schemas/reviewed-event.schema.json"]) == ( - LeadScoringReviewedEvent.model_json_schema() - ) - ErrorTaxonomy.model_validate(json.loads(files["error-taxonomy.json"])) - OutcomeTaxonomy.model_validate(json.loads(files["outcome-taxonomy.json"])) - - validate_fixture_corpus(files) - assert json.loads(files["fixtures/snapshot.json"])["version"] == "1.0.0" - assert json.loads(files["fixtures/admission-request.json"])["subject_ref"] == "ems-lead-1001" - ScoringConfigVersionSelection.model_validate( - json.loads(files["fixtures/scoring-config-selection.json"]) - ) - assert json.loads(files["fixtures/review-override-request.json"])["override_score"] == 90 - WebhookIngestErrorResponse.model_validate(json.loads(files["fixtures/tenant-unavailable.json"])) - - -def test_marker_fixtures_cover_shared_merge_cases(): - files = generate_contract_files() - names = { - "plain-description-append", - "empty-description-append", - "single-valid-block-replace", - "orphan-opening", - "orphan-closing", - "nested-block", - "overlapping-block", - "multiple-complete-blocks", - "unicode-multiline-preservation", - "deleted-block-followed-by-append", - "failed-run-no-render", - } - fixtures = {name: json.loads(files[f"fixtures/marker/{name}.json"]) for name in names} - - assert MARKER_OPEN in fixtures["plain-description-append"]["expected_description"] - assert MARKER_CLOSE in fixtures["plain-description-append"]["expected_description"] - assert fixtures["empty-description-append"]["current_description"] == "" - assert fixtures["empty-description-append"]["expected_description"].startswith(MARKER_OPEN) - assert fixtures["unicode-multiline-preservation"]["writeback"] is True - assert fixtures["unicode-multiline-preservation"]["current_description"].startswith( - "Khách hàng: Nguyễn Ánh 😊" - ) - - malformed = [ - fixtures[name] - for name in ( - "orphan-opening", - "orphan-closing", - "nested-block", - "overlapping-block", - "multiple-complete-blocks", - ) - ] - assert all(item["expected_error"] == "marker_block_malformed" for item in malformed) - assert all(item["expected_description"] is None for item in malformed) - assert all(item["writeback"] is False for item in malformed) - assert fixtures["failed-run-no-render"]["marker_input"] is None - assert fixtures["failed-run-no-render"]["writeback"] is False - - -def test_fixture_index_declares_shared_provider_consumer_corpus(): - files = generate_contract_files() - index = json.loads(files["fixture-index.json"]) - - assert index["provider_validation"] == "validate_fixture_corpus" - assert index["consumer_validation"] - assert all(item["role"] == "provider_consumer" for item in index["positive"]) - assert {item["path"] for item in index["marker"]} == { - "fixtures/marker/deleted-block-followed-by-append.json", - "fixtures/marker/empty-description-append.json", - "fixtures/marker/failed-run-no-render.json", - "fixtures/marker/multiple-complete-blocks.json", - "fixtures/marker/nested-block.json", - "fixtures/marker/orphan-closing.json", - "fixtures/marker/orphan-opening.json", - "fixtures/marker/overlapping-block.json", - "fixtures/marker/plain-description-append.json", - "fixtures/marker/single-valid-block-replace.json", - "fixtures/marker/unicode-multiline-preservation.json", - } - assert all(item["role"] == "provider_consumer" for item in index["marker"]) - assert {item["path"] for item in index["negative"]} == { - "fixtures/negative/snapshot-future-message.json", - "fixtures/negative/admission-subject-mismatch.json", - "fixtures/negative/review-override-out-of-range.json", - "fixtures/negative/event-missing-version.json", - } - - -def test_additive_fixture_remains_compatible_with_previous_consumer(): - files = generate_contract_files() - current = json.loads(files["fixtures/webhook/completed.json"]) - - legacy = LegacyTerminalEvent.model_validate({**current, "new_optional_field": "ignored"}) - assert legacy.event_type == "lead_scoring.run.terminal" - assert legacy.run_id == UUID(current["run_id"]) - - -def test_contract_pack_cli_generates_checks_and_rejects_drift(tmp_path: Path): - output_dir = tmp_path / "contract-pack" - - assert run_contract_pack_cli(["--output", str(output_dir)]) == 0 - assert run_contract_pack_cli(["--output", str(output_dir), "--check"]) == 0 - assert run_contract_pack_cli(["--output", str(output_dir), "--validate-fixtures"]) == 0 - - fixture = output_dir / "fixtures" / "snapshot.json" - fixture.write_text(canonical_json({"stale": True}), encoding="utf-8") - assert run_contract_pack_cli(["--output", str(output_dir), "--check"]) == 1 - - assert run_contract_pack_cli(["--output", str(output_dir)]) == 0 - (output_dir / "unexpected.json").write_text("{}\n", encoding="utf-8") - assert run_contract_pack_cli(["--output", str(output_dir), "--check"]) == 1 diff --git a/old_repo/tests/test_dynamic_rules.py b/old_repo/tests/test_dynamic_rules.py deleted file mode 100644 index 23913cd..0000000 --- a/old_repo/tests/test_dynamic_rules.py +++ /dev/null @@ -1,465 +0,0 @@ -import pytest - -from lead_scoring.schemas.dynamic_rule import ( - DynamicRule, - DynamicRuleOperator, - DynamicRuleTarget, -) -from lead_scoring.schemas.scoring_config import ScoringConfig -from lead_scoring.scoring import ( - RuleEvaluationStatus, - evaluate_education_rules, -) -from test_configurable_demo_backend import make_education_snapshot - - -def test_dynamic_rule_operators(): - snap = make_education_snapshot( - { - "student_age": 12, - "budget": 6000000, - "branch": "Quan 3, TP. Ho Chi Minh", - "urgency": "high", - }, - conversations=[ - { - "conversation_id": "conv-1", - "channel": "zalo", - "messages": [ - { - "message_id": "m1", - "sender_type": "contact", - "sent_at": "2026-08-28T11:00:00Z", - "content": "Toi can tim lop tieng Anh gap", - } - ], - } - ], - ) - - rules = ( - DynamicRule( - id="rule_budget_gt", - name="Ngân sách trên 5 triệu", - target=DynamicRuleTarget.CUSTOM_FIELD, - custom_field_key="budget", - operator=DynamicRuleOperator.GT, - value=5000000, - points=15, - ), - DynamicRule( - id="rule_branch_contains", - name="Cơ sở Quận 3", - target=DynamicRuleTarget.CUSTOM_FIELD, - custom_field_key="branch", - operator=DynamicRuleOperator.CONTAINS, - value="Quận 3", - points=10, - ), - DynamicRule( - id="rule_urgency_in", - name="Mức độ cấp bách cao hoặc khẩn", - target=DynamicRuleTarget.CUSTOM_FIELD, - custom_field_key="urgency", - operator=DynamicRuleOperator.IN, - value=["high", "urgent"], - points=10, - ), - DynamicRule( - id="rule_age_gte", - name="Tuổi từ 10 trở lên", - target=DynamicRuleTarget.STUDENT_AGE, - operator=DynamicRuleOperator.GTE, - value=10, - points=5, - ), - ) - - config = ScoringConfig( - version="dynamic-test-v1", - base_score=0, - confidence=0.85, - confidence_threshold=0.8, - hitl_policy_version="1.0.0", - education_demo_enabled=True, - dynamic_rules=rules, - ) - - score, breakdown, _, _ = evaluate_education_rules(snap, config) - - # Fixed rules: - # student_age=12 -> age_primary (40) - # recency -> hot (40) - # lead_source -> missing (0) - # Fixed subtotal = 80 - # Dynamic rules: - # budget (15) + branch (10) + urgency (10) + age_gte (5) = 40 - # Total = 80 + 40 = 120 -> clamped to 100 - assert score == 100 - - dyn_items = [b for b in breakdown if b.rule_name.startswith("rule_")] - assert len(dyn_items) == 4 - assert all(item.status == RuleEvaluationStatus.MATCHED for item in dyn_items) - assert sum(item.points_awarded for item in dyn_items) == 40 - - -def test_dynamic_rule_missing_and_disabled(): - snap = make_education_snapshot( - {"student_age": 12}, - conversations=[], - ) - - rules = ( - # Missing custom field - DynamicRule( - id="rule_missing_vip", - name="Khách VIP", - target=DynamicRuleTarget.CUSTOM_FIELD, - custom_field_key="vip_status", - operator=DynamicRuleOperator.EQ, - value="gold", - points=20, - ), - # Disabled rule - DynamicRule( - id="rule_disabled", - name="Luật tạm dừng", - target=DynamicRuleTarget.STUDENT_AGE, - operator=DynamicRuleOperator.EQ, - value=12, - points=30, - enabled=False, - ), - ) - - config = ScoringConfig( - version="dynamic-test-missing-v1", - base_score=0, - confidence=0.85, - confidence_threshold=0.8, - hitl_policy_version="1.0.0", - education_demo_enabled=True, - dynamic_rules=rules, - ) - - score, breakdown, _, _ = evaluate_education_rules(snap, config) - - missing_item = next(b for b in breakdown if b.rule_name == "rule_missing_vip") - assert missing_item.status == RuleEvaluationStatus.SKIPPED_MISSING_DATA - assert missing_item.points_awarded == 0 - - disabled_item = next(b for b in breakdown if b.rule_name == "rule_disabled") - assert disabled_item.status == RuleEvaluationStatus.NOT_MATCHED - assert disabled_item.points_awarded == 0 - - -def test_rule_overrides_validation(): - import pytest - from pydantic import ValidationError - - from lead_scoring.schemas.scoring_run import CreateScoringRunRequest - - base_snapshot = make_education_snapshot({"student_age": 12, "course_interest": "English"}) - - # 1. Duplicate rule ID - with pytest.raises(ValidationError, match="Duplicate dynamic rule ID"): - CreateScoringRunRequest( - tenant_id="tenant_1", - subject_ref=base_snapshot.lead_profile.ems_lead_id, - scoring_config_version="1.0.0", - rule_overrides=[ - DynamicRule( - id="rule_1", - name="Rule 1", - target=DynamicRuleTarget.STUDENT_AGE, - operator=DynamicRuleOperator.EQ, - value=12, - points=10, - ), - DynamicRule( - id="rule_1", - name="Rule 1 Duplicate", - target=DynamicRuleTarget.STUDENT_AGE, - operator=DynamicRuleOperator.EQ, - value=12, - points=20, - ), - ], - snapshot=base_snapshot, - ) - - # 2. Custom field without custom_field_key - with pytest.raises( - ValidationError, match="custom_field_key is required when target is 'custom_field'" - ): - CreateScoringRunRequest( - tenant_id="tenant_1", - subject_ref=base_snapshot.lead_profile.ems_lead_id, - scoring_config_version="1.0.0", - rule_overrides=[ - DynamicRule( - id="rule_cf", - name="Missing Key", - target=DynamicRuleTarget.CUSTOM_FIELD, - custom_field_key="", - operator=DynamicRuleOperator.EQ, - value="test", - points=10, - ) - ], - snapshot=base_snapshot, - ) - - # 3. String value > 1000 chars - with pytest.raises(ValidationError, match="exceeds maximum allowed size"): - CreateScoringRunRequest( - tenant_id="tenant_1", - subject_ref=base_snapshot.lead_profile.ems_lead_id, - scoring_config_version="1.0.0", - rule_overrides=[ - DynamicRule( - id="rule_long", - name="Long String", - target=DynamicRuleTarget.LEAD_SOURCE, - operator=DynamicRuleOperator.EQ, - value="x" * 1001, - points=10, - ) - ], - snapshot=base_snapshot, - ) - - # 4. List value > 100 items - with pytest.raises(ValidationError, match="exceeds maximum allowed length"): - CreateScoringRunRequest( - tenant_id="tenant_1", - subject_ref=base_snapshot.lead_profile.ems_lead_id, - scoring_config_version="1.0.0", - rule_overrides=[ - DynamicRule( - id="rule_list_long", - name="Long List", - target=DynamicRuleTarget.LEAD_SOURCE, - operator=DynamicRuleOperator.IN, - value=["item"] * 101, - points=10, - ) - ], - snapshot=base_snapshot, - ) - - # 5. Over 30 rules - with pytest.raises(ValidationError): - CreateScoringRunRequest( - tenant_id="tenant_1", - subject_ref=base_snapshot.lead_profile.ems_lead_id, - scoring_config_version="1.0.0", - rule_overrides=[ - DynamicRule( - id=f"rule_{i}", - name=f"Rule {i}", - target=DynamicRuleTarget.STUDENT_AGE, - operator=DynamicRuleOperator.EQ, - value=i, - points=1, - ) - for i in range(31) - ], - snapshot=base_snapshot, - ) - - -def test_rule_overrides_overlay_and_replay(): - snap = make_education_snapshot( - { - "student_age": 10, - "target_campus": "Disctrict 1", - "course_interest": "IELTS", - }, - conversations=[ - { - "conversation_id": "conv-overlay", - "channel": "zalo", - "messages": [ - { - "message_id": "m1", - "sender_type": "contact", - "sent_at": "2026-08-28T11:00:00Z", - "content": "Toi can dang ky IELTS", - } - ], - } - ], - ) - - base_config = ScoringConfig( - version="base-overlay-v1", - base_score=10, - confidence=0.90, - confidence_threshold=0.80, - hitl_policy_version="1.0.0", - dynamic_rules=(), - ) - - overrides = [ - DynamicRule( - id="rule_campus_d1", - name="Cơ sở Quận 1", - target=DynamicRuleTarget.CUSTOM_FIELD, - custom_field_key="target_campus", - operator=DynamicRuleOperator.CONTAINS, - value="District 1", - points=25, - ), - DynamicRule( - id="rule_age_10", - name="Tuổi đúng 10", - target=DynamicRuleTarget.STUDENT_AGE, - operator=DynamicRuleOperator.EQ, - value=10, - points=15, - ), - ] - - effective_config = base_config.model_copy(update={"dynamic_rules": tuple(overrides)}) - assert len(effective_config.dynamic_rules) == 2 - - score, breakdown, _, _ = evaluate_education_rules(snap, effective_config) - age_bd = next(b for b in breakdown if b.rule_name == "rule_age_10") - assert age_bd.status == RuleEvaluationStatus.MATCHED - assert age_bd.points_awarded == 15 - - -@pytest.mark.asyncio -async def test_api_rule_overrides_overlay_and_replay(temp_postgres_db: str): - import asyncio - from pathlib import Path - - from alembic import command - from alembic.config import Config - from httpx import ASGITransport, AsyncClient - from pydantic import SecretStr - - from lead_scoring.config import ServiceCallerConfig, Settings - from lead_scoring.main import create_app - from lead_scoring.schemas.scoring_config import ScoringConfig - - root_dir = Path(__file__).resolve().parent.parent - ini_path = root_dir / "alembic.ini" - alembic_cfg = Config(str(ini_path)) - alembic_cfg.attributes["sqlalchemy_url"] = temp_postgres_db - await asyncio.to_thread(command.upgrade, alembic_cfg, "head") - - base_config = ScoringConfig( - version="overlay-demo-v1", - base_score=20, - rule_weight=1.0, - ai_weight=0.0, - confidence=0.90, - confidence_threshold=0.80, - hitl_policy_version="1.0.0", - dynamic_rules=(), - ) - - settings = Settings( - database_url=temp_postgres_db, - scoring_configs=[base_config], - tenant_allowlist=["tenant_dyn"], - service_callers=[ - ServiceCallerConfig( - caller_id="test_caller", - token=SecretStr("valid_token"), - allowed_apps=["omni"], - allowed_features=["scoring"], - allowed_tenants=["tenant_dyn"], - ) - ], - ) - - app = create_app(settings=settings) - - snapshot_data = { - "version": "1.0.0", - "scope": "linked_contact_history", - "captured_at": "2026-08-27T12:00:00Z", - "lead_profile": { - "ems_lead_id": "lead_overlay_1", - "name": "Student A", - "custom_fields": { - "course_interest": "Python Bootcamp", - "campus": "District 1", - }, - }, - "conversations": [], - } - - rule_overrides = [ - { - "id": "dyn_campus_d1", - "name": "District 1 Campus", - "target": "custom_field", - "custom_field_key": "campus", - "operator": "eq", - "value": "District 1", - "points": 30, - "enabled": True, - } - ] - - payload = { - "tenant_id": "tenant_dyn", - "subject_ref": "lead_overlay_1", - "scoring_config_version": "overlay-demo-v1", - "rule_overrides": rule_overrides, - "snapshot": snapshot_data, - } - - headers = { - "Authorization": "Bearer valid_token", - "Idempotency-Key": "idem-overlay-101", - "X-DotB-App": "omni", - "X-DotB-Feature": "scoring", - } - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - # 1. Admit run with rule_overrides - resp = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert resp.status_code == 202 - run_id = resp.json()["run_id"] - - # 2. Wait for completion - get_resp = None - for _ in range(50): - await asyncio.sleep(0.1) - get_resp = await client.get( - f"/v1/scoring-runs/{run_id}", - headers={"Authorization": "Bearer valid_token", "X-Tenant-Id": "tenant_dyn"}, - ) - if get_resp.status_code == 200 and get_resp.json()["status"] in { - "completed", - "failed", - }: - break - - assert get_resp is not None - run_data = get_resp.json() - assert run_data["status"] == "completed" - assert run_data["rule_score"] == 50 # 20 base + 30 rule - assert run_data["final_score"] == 50 - assert run_data["breakdown"] is not None - assert any(b["rule_name"] == "dyn_campus_d1" for b in run_data["breakdown"]) - - # 3. Replay with identical payload and idempotency key - replay_resp = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert replay_resp.status_code == 202 - assert replay_resp.json()["run_id"] == run_id - - # 4. Check internal trace endpoint contains effective_config rule_breakdown - trace_resp = await client.get( - f"/v1/scoring-runs/{run_id}/trace", - headers={"Authorization": "Bearer valid_token", "X-Tenant-Id": "tenant_dyn"}, - ) - assert trace_resp.status_code == 200 - trace_data = trace_resp.json() - assert any(b["rule_name"] == "dyn_campus_d1" for b in trace_data["rule_breakdown"]) diff --git a/old_repo/tests/test_execution.py b/old_repo/tests/test_execution.py deleted file mode 100644 index 197127d..0000000 --- a/old_repo/tests/test_execution.py +++ /dev/null @@ -1,142 +0,0 @@ -import asyncio -import uuid -from datetime import UTC, datetime, timedelta -from pathlib import Path - -import pytest -from alembic import command -from alembic.config import Config -from sqlalchemy import select -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine - -from lead_scoring.db.models.outbox_event import OutboxEvent -from lead_scoring.db.models.scoring_run import ScoringRun -from lead_scoring.execution import recover_interrupted_runs - - -def get_alembic_config(database_url: str) -> Config: - root_dir = Path(__file__).resolve().parent.parent - config = Config(str(root_dir / "alembic.ini")) - config.attributes["sqlalchemy_url"] = database_url - return config - - -@pytest.mark.asyncio -async def test_recovery_terminalizes_expired_run_once_with_atomic_outbox( - temp_postgres_db: str, -) -> None: - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-recovery", - subject_ref="lead-recovery", - scoring_config_version="1.0.0", - idempotency_key="recovery-key", - payload_hash="0" * 64, - status="running", - snapshot_data={}, - confidence_threshold=0.8, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=30), - ) - ) - await session.commit() - - recovery_time = created_at + timedelta(seconds=31) - assert await recover_interrupted_runs(session_factory, clock=lambda: recovery_time) == 1 - assert await recover_interrupted_runs(session_factory, clock=lambda: recovery_time) == 0 - - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.status == "failed" - assert run.error_code == "worker_interrupted" - assert run.retryable is True - assert run.completed_at == recovery_time - - events = list( - (await session.execute(select(OutboxEvent).where(OutboxEvent.run_id == run_id))) - .scalars() - .all() - ) - assert len(events) == 1 - assert events[0].payload["data"] == { - "status": "failed", - "rule_score": None, - "ai_score": None, - "final_score": None, - "effective_score": None, - "confidence": None, - "category": "cold", - "explanation": "Không thể chấm điểm: worker_interrupted", - "recommended_action": "Kiểm tra lỗi và thử chấm điểm lại.", - "scoring_mode": None, - "degraded": None, - "error_code": "worker_interrupted", - "retryable": True, - } - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_recovery_rolls_back_run_transition_when_outbox_insert_fails( - temp_postgres_db: str, -) -> None: - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 13, 0, tzinfo=UTC) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-atomicity", - subject_ref="lead-atomicity", - scoring_config_version="1.0.0", - idempotency_key="atomicity-key", - payload_hash="1" * 64, - status="running", - snapshot_data={}, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at, - ) - ) - session.add( - OutboxEvent( - id=str(uuid.uuid4()), - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id=run_id, - payload={}, - occurred_at=created_at, - ) - ) - await session.commit() - - with pytest.raises(IntegrityError): - await recover_interrupted_runs( - session_factory, - clock=lambda: created_at + timedelta(seconds=1), - ) - - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.status == "running" - assert run.error_code is None - assert run.retryable is None - - await engine.dispose() diff --git a/old_repo/tests/test_health.py b/old_repo/tests/test_health.py deleted file mode 100644 index 806fbfc..0000000 --- a/old_repo/tests/test_health.py +++ /dev/null @@ -1,155 +0,0 @@ -import asyncio -from importlib.metadata import version -from unittest.mock import MagicMock - -import pytest -from httpx import ASGITransport, AsyncClient -from pydantic import ValidationError - -from lead_scoring import __version__ -from lead_scoring.config import Settings -from lead_scoring.db.session import check_db_health -from lead_scoring.main import create_app -from lead_scoring.schemas.health import HealthResponse - - -@pytest.mark.asyncio -async def test_health_endpoint_returns_ok_when_lifespan_healthy(): - settings = Settings(database_url="postgresql+asyncpg://user:pass@localhost:5432/testdb") - app = create_app(settings=settings) - - # Use lifespan context and mock engine probe - async with app.router.lifespan_context(app): - # Lifespan initializes app.state.engine and session_factory - assert hasattr(app.state, "engine") - mock_engine = MagicMock() - - class HealthyAsyncCM: - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - pass - - async def execute(self, statement): - return 1 - - mock_engine.connect.return_value = HealthyAsyncCM() - app.state.engine = mock_engine - - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - response = await client.get("/health") - - assert response.status_code == 200 - data = response.json() - validated = HealthResponse.model_validate(data) - assert validated.status == "ok" - assert validated.database == "connected" - assert validated.version == __version__ - - -@pytest.mark.asyncio -async def test_health_endpoint_returns_503_when_database_unhealthy(): - settings = Settings(database_url="postgresql+asyncpg://user:pass@localhost:5432/testdb") - app = create_app(settings=settings) - - mock_engine = MagicMock() - - class FailingAsyncCM: - async def __aenter__(self): - raise ConnectionRefusedError("Database down") - - async def __aexit__(self, exc_type, exc_val, exc_tb): - pass - - mock_engine.connect.return_value = FailingAsyncCM() - app.state.engine = mock_engine - - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - response = await client.get("/health") - - assert response.status_code == 503 - data = response.json() - validated = HealthResponse.model_validate(data) - assert validated.status == "unhealthy" - assert validated.database == "unreachable" - assert validated.version == __version__ - - -@pytest.mark.asyncio -async def test_health_endpoint_returns_503_when_engine_unconfigured(): - settings = Settings(database_url="postgresql+asyncpg://user:pass@localhost:5432/testdb") - app = create_app(settings=settings) - - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - app.state.engine = None - response = await client.get("/health") - - assert response.status_code == 503 - data = response.json() - validated = HealthResponse.model_validate(data) - assert validated.status == "unhealthy" - assert validated.database == "unconfigured" - - -@pytest.mark.asyncio -async def test_check_db_health_timeout(): - mock_engine = MagicMock() - - class HangingAsyncCM: - async def __aenter__(self): - await asyncio.sleep(5.0) - - async def __aexit__(self, exc_type, exc_val, exc_tb): - pass - - mock_engine.connect.return_value = HangingAsyncCM() - - is_healthy = await check_db_health(mock_engine, timeout=0.1) - assert is_healthy is False - - -def test_settings_url_normalization(): - s1 = Settings(database_url="postgres://user:pass@host:5432/db") - assert s1.database_url == "postgresql+asyncpg://user:pass@host:5432/db" - - s2 = Settings(database_url="postgresql://user:pass@host:5432/db") - assert s2.database_url == "postgresql+asyncpg://user:pass@host:5432/db" - - s3 = Settings(database_url="postgresql+asyncpg://user:pass@host:5432/db") - assert s3.database_url == "postgresql+asyncpg://user:pass@host:5432/db" - - -def test_settings_timeout_must_be_positive(): - with pytest.raises(ValidationError): - Settings(db_health_timeout_seconds=0) - - with pytest.raises(ValidationError): - Settings(db_health_timeout_seconds=-1.0) - - -def test_version_matches_distribution_metadata(): - installed_version = version("lead-scoring") - assert __version__ == installed_version - settings = Settings() - assert settings.app_version == installed_version - health = HealthResponse() - assert health.version == installed_version - - -def test_openapi_declares_503_health_response(): - app = create_app() - openapi_schema = app.openapi() - health_responses = openapi_schema["paths"]["/health"]["get"]["responses"] - assert "200" in health_responses - assert "503" in health_responses - - -def test_engine_factory_enforces_hide_parameters(): - from lead_scoring.db.session import create_engine_from_url - - engine = create_engine_from_url( - "postgresql+asyncpg://user:pass@localhost:5432/testdb", - echo=True, - ) - assert engine.sync_engine.hide_parameters is True diff --git a/old_repo/tests/test_medium_execution.py b/old_repo/tests/test_medium_execution.py deleted file mode 100644 index b799116..0000000 --- a/old_repo/tests/test_medium_execution.py +++ /dev/null @@ -1,754 +0,0 @@ -import asyncio -import json -import logging -import uuid -from datetime import UTC, datetime, timedelta -from pathlib import Path -from typing import Any - -import httpx -import pytest -from alembic import command -from alembic.config import Config -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine - -from lead_scoring.aihub.client import AIHubClient -from lead_scoring.db.models.outbox_event import OutboxEvent -from lead_scoring.db.models.scoring_run import ScoringRun -from lead_scoring.execution import execute_scoring_run -from lead_scoring.scoring import ( - EffortProfile, - FallbackPolicy, - RuleMetric, - ScoringConfig, - ScoringRule, -) - - -def get_alembic_config(database_url: str) -> Config: - root_dir = Path(__file__).resolve().parent.parent - config = Config(str(root_dir / "alembic.ini")) - config.attributes["sqlalchemy_url"] = database_url - return config - - -def make_snapshot_data(ems_lead_id: str = "lead-m-1") -> dict[str, Any]: - return { - "version": "1.0.0", - "scope": "linked_contact_history", - "captured_at": "2026-08-27T12:00:00Z", - "lead_profile": { - "ems_lead_id": ems_lead_id, - "name": "Nguyen Van C", - "phone": "+84987654321", - "email": "nvc@example.com", - "status": "New", - }, - "conversations": [ - { - "conversation_id": "conv-m-1", - "messages": [ - { - "message_id": "msg-m-1", - "sender_type": "contact", - "content": "Toi can trien khai giai phap AI cho 100 nhan vien", - "sent_at": "2026-08-27T11:00:00Z", - } - ], - } - ], - "identity_context": { - "match_quality": "direct", - "contact_id": "cnt-m-1", - }, - "trigger_metadata": { - "trigger_type": "manual_omni", - "trigger_conversation_id": "conv-m-1", - "actor_id": "user-m-1", - }, - "hitl_policy": { - "confidence_threshold": 0.80, - "policy_version": "1.0.0", - }, - } - - -@pytest.mark.asyncio -async def test_medium_execution_success_completed(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - - config = ScoringConfig( - version="medium-config-1", - base_score=50, - confidence=0.90, - confidence_threshold=0.80, - hitl_policy_version="policy-1", - rules=(ScoringRule(metric=RuleMetric.PROFILE_FIELD_COUNT, minimum=2, points=30),), - rule_weight=0.4, - ai_weight=0.6, - effort_profile=EffortProfile.MEDIUM, - ai_model="gpt-4o-mini", - fallback_policy=FallbackPolicy.RULES_ONLY_REVIEW, - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-med-1", - subject_ref="lead-m-1", - scoring_config_version="medium-config-1", - idempotency_key="idemp-med-1", - payload_hash="a" * 64, - status="pending", - snapshot_data=make_snapshot_data("lead-m-1"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=30), - ) - ) - await session.commit() - - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/v1/chat/completions" - body = json.loads(request.content.decode("utf-8")) - schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") - if schema_name == "evidence_extraction": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "summary": "Lead needs AI solution for 100 staff", - "intent": "high", - "key_signals": ["100 staff", "enterprise solution"], - } - ) - } - } - ] - }, - ) - elif schema_name == "lead_scoring": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 90, - "confidence": 0.95, - "reasoning": "Large enterprise scale and urgent need.", - "positive_factors": ["100 staff size"], - "negative_factors": [], - } - ) - } - } - ] - }, - ) - return httpx.Response(404) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - aihub_client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - - success = await execute_scoring_run( - session_factory, - run_id, - tenant_vkey="secret-vkey-token", - lease_seconds=30.0, - aihub_client=aihub_client, - clock=lambda: created_at + timedelta(seconds=2), - ) - assert success is True - - # Rule score = 50 + 30 = 80 - # AI score = 90 - # Final score = round(80 * 0.4 + 90 * 0.6) = round(32 + 54) = 86 - # Combined confidence = 0.90 * 0.4 + 0.95 * 0.6 = 0.36 + 0.57 = 0.93 >= 0.80 -> completed - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.status == "completed" - assert run.rule_score == 80 - assert run.ai_score == 90 - assert run.final_score == 86 - assert run.scoring_mode == "medium_ai" - assert run.degraded is False - assert run.confidence == 0.93 - assert run.ai_metadata is not None - assert run.ai_metadata["model"] == "gpt-4o-mini" - assert run.ai_metadata["pass_count"] == 2 - assert "evidence_summary" not in run.ai_metadata - assert "reasoning" not in run.ai_metadata - - events = list( - (await session.execute(select(OutboxEvent).where(OutboxEvent.run_id == run_id))) - .scalars() - .all() - ) - assert len(events) == 1 - data = events[0].payload["data"] - assert data["status"] == "completed" - assert data["rule_score"] == 80 - assert data["confidence"] == 0.93 - assert data["error_code"] is None - assert data["retryable"] is None - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_medium_execution_success_review_required_due_to_confidence(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - - config = ScoringConfig( - version="medium-config-2", - base_score=40, - confidence=0.60, - confidence_threshold=0.80, - hitl_policy_version="policy-1", - rule_weight=0.4, - ai_weight=0.6, - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-med-2", - subject_ref="lead-m-2", - scoring_config_version="medium-config-2", - idempotency_key="idemp-med-2", - payload_hash="b" * 64, - status="pending", - snapshot_data=make_snapshot_data("lead-m-2"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=30), - ) - ) - await session.commit() - - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/v1/chat/completions" - body = json.loads(request.content.decode("utf-8")) - schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") - if schema_name == "evidence_extraction": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - {"summary": "Limited evidence", "intent": "low"} - ) - } - } - ] - }, - ) - elif schema_name == "lead_scoring": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 45, - "confidence": 0.50, - "reasoning": "Unclear requirements.", - "positive_factors": [], - "negative_factors": ["limited info"], - } - ) - } - } - ] - }, - ) - return httpx.Response(404) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - aihub_client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - - await execute_scoring_run( - session_factory, - run_id, - tenant_vkey="secret-vkey-token", - lease_seconds=30.0, - aihub_client=aihub_client, - clock=lambda: created_at + timedelta(seconds=2), - ) - - # Combined confidence = 0.60 * 0.4 + 0.50 * 0.6 = 0.24 + 0.30 = 0.54 < 0.80 -> review_required - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.status == "completed" - assert run.final_score == round(40 * 0.4 + 45 * 0.6) - assert run.confidence == 0.54 - assert run.degraded is False - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_medium_execution_fallback_rules_only_review_on_ai_failure(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - - config = ScoringConfig( - version="medium-config-3", - base_score=70, - confidence=0.85, - confidence_threshold=0.80, - hitl_policy_version="policy-1", - fallback_policy=FallbackPolicy.RULES_ONLY_REVIEW, - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-med-3", - subject_ref="lead-m-3", - scoring_config_version="medium-config-3", - idempotency_key="idemp-med-3", - payload_hash="c" * 64, - status="pending", - snapshot_data=make_snapshot_data("lead-m-3"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=30), - ) - ) - await session.commit() - - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(500, json={"error": "AIHub Gateway Error"}) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - aihub_client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - - await execute_scoring_run( - session_factory, - run_id, - tenant_vkey="secret-vkey-token", - lease_seconds=30.0, - aihub_client=aihub_client, - clock=lambda: created_at + timedelta(seconds=2), - ) - - # Fallback to rules_only_review: - # status = review_required - # final_score = rule_score (70) - # ai_score = None (discarded) - # degraded = True - # scoring_mode = "rules_only_fallback" - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.status == "completed" - assert run.rule_score == 70 - assert run.ai_score is None - assert run.final_score == 70 - assert run.scoring_mode == "rules_only_fallback" - assert run.degraded is True - assert run.ai_metadata is not None - assert run.ai_metadata["error_code"] == "aihub_server_error" - - events = list( - (await session.execute(select(OutboxEvent).where(OutboxEvent.run_id == run_id))) - .scalars() - .all() - ) - assert len(events) == 1 - data = events[0].payload["data"] - assert data["status"] == "completed" - assert data["rule_score"] == 70 - assert data["confidence"] == 0.85 - assert data["error_code"] == "aihub_server_error" - assert data["retryable"] is False - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_medium_execution_fallback_fail_run(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - - config = ScoringConfig( - version="medium-config-4", - base_score=70, - confidence=0.85, - confidence_threshold=0.80, - hitl_policy_version="policy-1", - fallback_policy=FallbackPolicy.FAIL_RUN, - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-med-4", - subject_ref="lead-m-4", - scoring_config_version="medium-config-4", - idempotency_key="idemp-med-4", - payload_hash="d" * 64, - status="pending", - snapshot_data=make_snapshot_data("lead-m-4"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=30), - ) - ) - await session.commit() - - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(429, json={"error": "Rate limit exceeded"}) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - aihub_client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - - await execute_scoring_run( - session_factory, - run_id, - tenant_vkey="secret-vkey-token", - lease_seconds=30.0, - aihub_client=aihub_client, - clock=lambda: created_at + timedelta(seconds=2), - ) - - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.status == "failed" - assert run.error_code == "aihub_quota_exceeded" - assert run.retryable is False - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_medium_execution_vkey_sentinel_never_persisted(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - sentinel_vkey = "sentinel-super-secret-vkey-xyz-999" - - config = ScoringConfig( - version="medium-config-5", - confidence=0.9, - confidence_threshold=0.8, - hitl_policy_version="policy-1", - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-med-5", - subject_ref="lead-m-5", - scoring_config_version="medium-config-5", - idempotency_key="idemp-med-5", - payload_hash="e" * 64, - status="pending", - snapshot_data=make_snapshot_data("lead-m-5"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=30), - ) - ) - await session.commit() - - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/v1/chat/completions" - body = json.loads(request.content.decode("utf-8")) - schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") - if schema_name == "evidence_extraction": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - {"summary": "Enterprise lead", "intent": "high"} - ) - } - } - ] - }, - ) - elif schema_name == "lead_scoring": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 85, - "confidence": 0.9, - "reasoning": "Good fit", - "positive_factors": ["enterprise"], - "negative_factors": [], - } - ) - } - } - ] - }, - ) - return httpx.Response(404) - - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as http_client: - aihub_client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) - - await execute_scoring_run( - session_factory, - run_id, - tenant_vkey=sentinel_vkey, - lease_seconds=30.0, - aihub_client=aihub_client, - clock=lambda: created_at + timedelta(seconds=2), - ) - - # Check database row and outbox event - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - - # Convert entire model and dicts to string and check sentinel - run_str = str(run.__dict__) - assert sentinel_vkey not in run_str - assert sentinel_vkey not in str(run.snapshot_data) - assert sentinel_vkey not in str(run.effective_config) - assert sentinel_vkey not in str(run.ai_metadata) - - events = list( - (await session.execute(select(OutboxEvent).where(OutboxEvent.run_id == run_id))) - .scalars() - .all() - ) - assert len(events) == 1 - assert sentinel_vkey not in str(events[0].payload) - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_medium_execution_unconfigured_aihub_client_triggers_fallback(temp_postgres_db: str): - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - - config = ScoringConfig( - version="medium-config-unconf", - base_score=75, - confidence=0.9, - confidence_threshold=0.8, - hitl_policy_version="policy-1", - effort_profile=EffortProfile.MEDIUM, - fallback_policy=FallbackPolicy.RULES_ONLY_REVIEW, - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-unconf", - subject_ref="lead-unconf", - scoring_config_version="medium-config-unconf", - idempotency_key="idemp-unconf", - payload_hash="f" * 64, - status="pending", - snapshot_data=make_snapshot_data("lead-unconf"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=30), - ) - ) - await session.commit() - - # Pass aihub_client=None explicitly - await execute_scoring_run( - session_factory, - run_id, - tenant_vkey="secret-vkey-token", - lease_seconds=30.0, - aihub_client=None, - clock=lambda: created_at + timedelta(seconds=2), - ) - - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.status == "completed" - assert run.rule_score == 75 - assert run.ai_score is None - assert run.final_score == 75 - assert run.scoring_mode == "rules_only_fallback" - assert run.degraded is True - assert run.ai_metadata is not None - assert run.ai_metadata["error_code"] == "aihub_client_unconfigured" - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_medium_execution_non_cancellation_exception_uses_canned_error_and_no_sentinel_leak( - temp_postgres_db: str, - caplog: pytest.LogCaptureFixture, - capsys: pytest.CaptureFixture[str], -): - caplog.set_level(logging.DEBUG) - await asyncio.to_thread(command.upgrade, get_alembic_config(temp_postgres_db), "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - run_id = str(uuid.uuid4()) - created_at = datetime(2026, 8, 27, 12, 0, tzinfo=UTC) - sentinel_secret = "sentinel-leak-in-exception-1234567890" - - config = ScoringConfig( - version="medium-config-crash", - base_score=60, - confidence=0.85, - confidence_threshold=0.8, - hitl_policy_version="policy-1", - effort_profile=EffortProfile.MEDIUM, - fallback_policy=FallbackPolicy.RULES_ONLY_REVIEW, - ) - - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant-crash", - subject_ref="lead-crash", - scoring_config_version="medium-config-crash", - idempotency_key="idemp-crash", - payload_hash="1" * 64, - status="pending", - snapshot_data=make_snapshot_data("lead-crash"), - effective_config=config.model_dump(mode="json"), - confidence_threshold=config.confidence_threshold, - created_at=created_at, - updated_at=created_at, - lease_expires_at=created_at + timedelta(seconds=30), - ) - ) - await session.commit() - - class CrashingClient: - async def extract_evidence(self, *args, **kwargs): - raise RuntimeError(f"Crashing with secret {sentinel_secret}") - - async def score_lead(self, *args, **kwargs): - raise RuntimeError(f"Crashing with secret {sentinel_secret}") - - async def analyze_and_score_lead(self, *args, **kwargs): - raise RuntimeError(f"Crashing with secret {sentinel_secret}") - - async def critique_evidence(self, *args, **kwargs): - raise RuntimeError(f"Crashing with secret {sentinel_secret}") - - async def score_lead_heavy(self, *args, **kwargs): - raise RuntimeError(f"Crashing with secret {sentinel_secret}") - - await execute_scoring_run( - session_factory, - run_id, - tenant_vkey=sentinel_secret, - lease_seconds=30.0, - aihub_client=CrashingClient(), - clock=lambda: created_at + timedelta(seconds=2), - ) - - async with session_factory() as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.status == "completed" - assert run.rule_score == 60 - assert run.final_score == 60 - assert run.scoring_mode == "rules_only_fallback" - assert run.degraded is True - assert run.ai_metadata is not None - assert run.ai_metadata["error_code"] == "aihub_unknown_error" - assert run.ai_metadata["detail"] == "Unexpected internal execution error" - - # Verify sentinel_secret NEVER appears anywhere in DB record or outbox - assert sentinel_secret not in str(run.__dict__) - assert sentinel_secret not in str(run.ai_metadata) - assert sentinel_secret not in str(run.effective_config) - - events = list( - (await session.execute(select(OutboxEvent).where(OutboxEvent.run_id == run_id))) - .scalars() - .all() - ) - assert len(events) == 1 - assert sentinel_secret not in str(events[0].payload) - - # Verify sentinel_secret NEVER appears anywhere in captured logs, stdout, or stderr - assert sentinel_secret not in caplog.text - captured = capsys.readouterr() - assert sentinel_secret not in captured.err - assert sentinel_secret not in captured.out - - await engine.dispose() diff --git a/old_repo/tests/test_medium_scoring.py b/old_repo/tests/test_medium_scoring.py deleted file mode 100644 index 917c669..0000000 --- a/old_repo/tests/test_medium_scoring.py +++ /dev/null @@ -1,192 +0,0 @@ -import pytest -from pydantic import ValidationError - -from lead_scoring.config import Settings -from lead_scoring.scoring import ( - EffortProfile, - FallbackPolicy, - ScoringConfig, - combine_confidence, - combine_final_score, -) - - -def test_scoring_config_default_weights_and_ai_fields(): - config = ScoringConfig( - version="config-1", - confidence=0.8, - confidence_threshold=0.8, - hitl_policy_version="policy-1", - ) - assert config.rule_weight == 0.4 - assert config.ai_weight == 0.6 - assert config.effort_profile == EffortProfile.MEDIUM - assert config.ai_model == "openai/gpt-4o-mini" - assert config.fallback_policy == FallbackPolicy.RULES_ONLY_REVIEW - assert config.ai_timeout_seconds == 10.0 - - -def test_scoring_config_rejects_unknown_effort_profile(): - with pytest.raises(ValidationError): - ScoringConfig.model_validate( - { - "version": "config-bad-effort", - "confidence": 0.8, - "confidence_threshold": 0.8, - "hitl_policy_version": "policy-1", - "effort_profile": "unsupported_profile", - } - ) - - -def test_scoring_config_rejects_unknown_fallback_policy(): - with pytest.raises(ValidationError): - ScoringConfig.model_validate( - { - "version": "config-bad-fallback", - "confidence": 0.8, - "confidence_threshold": 0.8, - "hitl_policy_version": "policy-1", - "fallback_policy": "unknown_fallback_policy", - } - ) - - -def test_scoring_config_validates_weights_sum_to_one(): - # Valid explicit weights - config = ScoringConfig( - version="config-2", - confidence=0.8, - confidence_threshold=0.8, - hitl_policy_version="policy-1", - rule_weight=0.3, - ai_weight=0.7, - ) - assert config.rule_weight == 0.3 - assert config.ai_weight == 0.7 - - # Invalid weights sum - with pytest.raises(ValidationError) as exc_info: - ScoringConfig( - version="config-3", - confidence=0.8, - confidence_threshold=0.8, - hitl_policy_version="policy-1", - rule_weight=0.5, - ai_weight=0.6, - ) - assert "must equal 1.0" in str(exc_info.value) - - -def test_settings_validate_worker_lease_covers_ai_timeout(): - # 2 * 10s + 5s = 25s required. worker_lease_seconds=20s must fail. - with pytest.raises(ValidationError) as exc_info: - Settings( - worker_lease_seconds=20.0, - scoring_configs=[ - ScoringConfig( - version="cfg-long-ai", - confidence=0.8, - confidence_threshold=0.8, - hitl_policy_version="policy-1", - ai_timeout_seconds=10.0, - ) - ], - ) - assert "must exceed two-pass AI timeout plus margin" in str(exc_info.value) - - # Light profile (1 pass): 1 * 10s + 5s = 15s required. worker_lease_seconds=12s must fail. - with pytest.raises(ValidationError) as exc_info: - Settings( - worker_lease_seconds=12.0, - scoring_configs=[ - ScoringConfig( - version="cfg-light-lease", - confidence=0.8, - confidence_threshold=0.8, - hitl_policy_version="policy-1", - effort_profile=EffortProfile.LIGHT, - ai_timeout_seconds=10.0, - ) - ], - ) - assert "must exceed one-pass AI timeout plus margin" in str(exc_info.value) - - # Heavy profile (3 passes): 3 * 10s + 5s = 35s required. worker_lease_seconds=30s must fail. - with pytest.raises(ValidationError) as exc_info: - Settings( - worker_lease_seconds=30.0, - scoring_configs=[ - ScoringConfig( - version="cfg-heavy-lease", - confidence=0.8, - confidence_threshold=0.8, - hitl_policy_version="policy-1", - effort_profile=EffortProfile.HEAVY, - ai_timeout_seconds=10.0, - ) - ], - ) - assert "must exceed three-pass AI timeout plus margin" in str(exc_info.value) - - -def test_settings_reads_aihub_base_url_from_env(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv("AIHUB_BASE_URL", "https://aihub-custom.internal.dotb.vn") - settings = Settings() - assert settings.aihub_base_url == "https://aihub-custom.internal.dotb.vn" - - -def test_settings_validates_aihub_base_url_shapes_and_environment(): - # 1. Dev/Test accepts localhost HTTP - s_dev = Settings(environment="development", aihub_base_url="http://localhost:8080") - assert s_dev.aihub_base_url == "http://localhost:8080" - - s_127 = Settings(environment="development", aihub_base_url="http://127.0.0.1:8080") - assert s_127.aihub_base_url == "http://127.0.0.1:8080" - - # 2. Dev/Test rejects remote HTTP - with pytest.raises(ValidationError, match="HTTP scheme is only allowed for localhost"): - Settings(environment="development", aihub_base_url="http://untrusted-remote.com") - - # 3. Production requires HTTPS - s_prod = Settings(environment="production", aihub_base_url="https://aihub.internal") - assert s_prod.aihub_base_url == "https://aihub.internal" - - with pytest.raises(ValidationError, match="must use HTTPS in production"): - Settings(environment="production", aihub_base_url="http://localhost:8080") - - with pytest.raises(ValidationError, match="must use HTTPS in production"): - Settings(environment="production", aihub_base_url="http://aihub.internal") - - # 4. Rejects user credentials in URL - with pytest.raises(ValidationError, match="user credentials"): - Settings(aihub_base_url="https://user:pass@aihub.internal") - - # 5. Rejects query parameters - with pytest.raises(ValidationError, match="query parameters"): - Settings(aihub_base_url="https://aihub.internal?env=prod") - - # 6. Rejects fragments - with pytest.raises(ValidationError, match="fragments"): - Settings(aihub_base_url="https://aihub.internal#anchor") - - -def test_combine_final_score_deterministic_and_clamped(): - # 80 * 0.4 + 90 * 0.6 = 32 + 54 = 86 - assert combine_final_score(80, 90, 0.4, 0.6) == 86 - - # 100 * 0.5 + 0 * 0.5 = 50 - assert combine_final_score(100, 0, 0.5, 0.5) == 50 - - # Bounds clamping [0, 100] - assert combine_final_score(0, 0, 0.4, 0.6) == 0 - assert combine_final_score(100, 100, 0.4, 0.6) == 100 - - -def test_combine_confidence_deterministic_and_clamped(): - # 0.8 * 0.4 + 0.9 * 0.6 = 0.32 + 0.54 = 0.86 - assert combine_confidence(0.8, 0.9, 0.4, 0.6) == 0.86 - - # Clamping bounds [0.0, 1.0] - assert combine_confidence(0.0, 0.0, 0.4, 0.6) == 0.0 - assert combine_confidence(1.0, 1.0, 0.4, 0.6) == 1.0 diff --git a/old_repo/tests/test_migrations.py b/old_repo/tests/test_migrations.py deleted file mode 100644 index 81d3f03..0000000 --- a/old_repo/tests/test_migrations.py +++ /dev/null @@ -1,765 +0,0 @@ -import asyncio -from pathlib import Path - -import pytest -from alembic import command -from alembic.config import Config -from sqlalchemy import text -from sqlalchemy.ext.asyncio import create_async_engine - - -def get_alembic_config(database_url: str) -> Config: - root_dir = Path(__file__).resolve().parent.parent - ini_path = root_dir / "alembic.ini" - alembic_cfg = Config(str(ini_path)) - # Pass via attributes to avoid '%' escaping issues with passwords - alembic_cfg.attributes["sqlalchemy_url"] = database_url - return alembic_cfg - - -@pytest.mark.asyncio -async def test_alembic_migration_upgrade_and_downgrade_lifecycle(temp_postgres_db: str): - alembic_cfg = get_alembic_config(temp_postgres_db) - engine = create_async_engine(temp_postgres_db) - - # 1. Run upgrade to head - await asyncio.to_thread(command.upgrade, alembic_cfg, "head") - - # 2. Verify alembic_version has the current head and tables exist - async with engine.connect() as conn: - result = await conn.execute(text("SELECT version_num FROM alembic_version;")) - row = result.fetchone() - assert row is not None - assert row[0] == "0010_dotb_headers" - - table_check = await conn.execute( - text( - "SELECT EXISTS (" - " SELECT FROM information_schema.tables " - " WHERE table_schema = 'public' AND table_name = 'scoring_runs'" - ");" - ) - ) - assert table_check.scalar() is True - - audit_table_check = await conn.execute( - text( - "SELECT EXISTS (" - " SELECT FROM information_schema.tables " - " WHERE table_schema = 'public' AND table_name = 'review_audits'" - ");" - ) - ) - assert audit_table_check.scalar() is True - - # Check new columns exist - cols_result = await conn.execute( - text( - "SELECT column_name FROM information_schema.columns " - "WHERE table_schema = 'public' AND table_name = 'scoring_runs';" - ) - ) - cols = {r[0] for r in cols_result.fetchall()} - assert { - "ai_score", - "final_score", - "scoring_mode", - "degraded", - "ai_metadata", - "version", - "effective_score", - "review_decision", - "reviewed_by", - "reviewed_at", - "review_note", - "parent_run_id", - "redacted_at", - "execution_trace", - "dotb_app", - "dotb_feature", - }.issubset(cols) - - audit_cols_result = await conn.execute( - text( - "SELECT column_name FROM information_schema.columns " - "WHERE table_schema = 'public' AND table_name = 'review_audits';" - ) - ) - audit_cols = {r[0] for r in audit_cols_result.fetchall()} - assert { - "child_run_id", - "effective_score", - "redacted_at", - }.issubset(audit_cols) - - outbox_cols_result = await conn.execute( - text( - "SELECT column_name FROM information_schema.columns " - "WHERE table_schema = 'public' AND table_name = 'outbox_events';" - ) - ) - outbox_cols = {r[0] for r in outbox_cols_result.fetchall()} - assert { - "delivery_status", - "attempt_count", - "next_attempt_at", - "last_attempt_at", - "delivered_at", - "lease_expires_at", - "claim_token", - "last_error_code", - "last_error_detail", - "last_http_status", - }.issubset(outbox_cols) - - # 3. Run downgrade to base - await asyncio.to_thread(command.downgrade, alembic_cfg, "base") - - # 4. Verify alembic_version is empty and scoring_runs table is dropped - async with engine.connect() as conn: - result_after_downgrade = await conn.execute( - text("SELECT version_num FROM alembic_version;") - ) - row_after = result_after_downgrade.fetchone() - assert row_after is None - - table_check_after = await conn.execute( - text( - "SELECT EXISTS (" - " SELECT FROM information_schema.tables " - " WHERE table_schema = 'public' AND table_name = 'scoring_runs'" - ");" - ) - ) - assert table_check_after.scalar() is False - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_alembic_migration_repeated_upgrade_idempotence(temp_postgres_db: str): - alembic_cfg = get_alembic_config(temp_postgres_db) - engine = create_async_engine(temp_postgres_db) - - # Initial upgrade - await asyncio.to_thread(command.upgrade, alembic_cfg, "head") - - # Second upgrade to head should be a no-op and succeed cleanly - await asyncio.to_thread(command.upgrade, alembic_cfg, "head") - - async with engine.connect() as conn: - result = await conn.execute(text("SELECT version_num FROM alembic_version;")) - row = result.fetchone() - assert row is not None - assert row[0] == "0010_dotb_headers" - - # Downgrade back to base to keep disposable container clean - await asyncio.to_thread(command.downgrade, alembic_cfg, "base") - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_alembic_migration_downgrade_with_populated_review_and_outbox_data( - temp_postgres_db: str, -): - alembic_cfg = get_alembic_config(temp_postgres_db) - engine = create_async_engine(temp_postgres_db) - - # 1. Upgrade to head (0005) - await asyncio.to_thread(command.upgrade, alembic_cfg, "head") - - run_id = "00000000-0000-0000-0000-000000000001" - terminal_event_id = "00000000-0000-0000-0000-000000000002" - rev1_event_id = "00000000-0000-0000-0000-000000000003" - rev2_event_id = "00000000-0000-0000-0000-000000000004" - audit1_id = "00000000-0000-0000-0000-000000000005" - audit2_id = "00000000-0000-0000-0000-000000000006" - - # 2. Insert populated data representing multiple reviews and terminal outbox event - async with engine.begin() as conn: - await conn.execute( - text( - "INSERT INTO scoring_runs (" - " id, tenant_id, subject_ref, scoring_config_version, idempotency_key, " - " payload_hash, status, snapshot_data, version, rule_score, " - " ai_score, final_score, effective_score, scoring_mode, " - " degraded, review_decision, reviewed_by, reviewed_at, review_note, " - " created_at, updated_at" - ") VALUES (" - " :id, 'tenant_1', 'lead_1', '1.0.0', 'run-idem-1', " - " 'hash-1', 'completed', '{}'::jsonb, 3, 80, " - " NULL, 80, 95, 'rules_only', " - " false, 'override', 'admin_user', now(), 'Second override note', " - " now(), now()" - ")" - ), - {"id": run_id}, - ) - - # Terminal event - await conn.execute( - text( - "INSERT INTO outbox_events (" - " id, event_type, event_version, run_id, payload, occurred_at" - ") VALUES (" - " :id, 'lead_scoring.run.terminal', '1.0.0', :run_id, " - " '{}'::jsonb, now()" - ")" - ), - {"id": terminal_event_id, "run_id": run_id}, - ) - - # Two review events for the same run_id (would violate unique without partial index) - await conn.execute( - text( - "INSERT INTO outbox_events (" - " id, event_type, event_version, run_id, payload, occurred_at" - ") VALUES (" - " :id, 'lead_scoring.run.reviewed', '1.0.0', :run_id, " - " '{\"version\": 2}'::jsonb, now()" - ")" - ), - {"id": rev1_event_id, "run_id": run_id}, - ) - await conn.execute( - text( - "INSERT INTO outbox_events (" - " id, event_type, event_version, run_id, payload, occurred_at" - ") VALUES (" - " :id, 'lead_scoring.run.reviewed', '1.0.0', :run_id, " - " '{\"version\": 3}'::jsonb, now()" - ")" - ), - {"id": rev2_event_id, "run_id": run_id}, - ) - - # Review audits - await conn.execute( - text( - "INSERT INTO review_audits (" - " id, run_id, tenant_id, decision, actor_id, actor_role, idempotency_key, " - " payload_hash, previous_version, new_version, calculated_score, effective_score, " - " note, created_at" - ") VALUES (" - " :id, :run_id, 'tenant_1', 'override', 'admin_user', 'administrator', 'idem-1', " - " 'hash1', 1, 2, 80, 90, 'first note', now()" - ")" - ), - {"id": audit1_id, "run_id": run_id}, - ) - await conn.execute( - text( - "INSERT INTO review_audits (" - " id, run_id, tenant_id, decision, actor_id, actor_role, idempotency_key, " - " payload_hash, previous_version, new_version, calculated_score, effective_score, " - " note, created_at" - ") VALUES (" - " :id, :run_id, 'tenant_1', 'override', 'admin_user', 'administrator', 'idem-2', " - " 'hash2', 2, 3, 80, 95, 'second note', now()" - ")" - ), - {"id": audit2_id, "run_id": run_id}, - ) - - # 3. Downgrade to 0004_medium_ai_pipeline (prior revision) - await asyncio.to_thread(command.downgrade, alembic_cfg, "0004_medium_ai_pipeline") - - # 4. Verify post-downgrade state - async with engine.connect() as conn: - res = await conn.execute(text("SELECT version_num FROM alembic_version;")) - assert res.scalar() == "0004_medium_ai_pipeline" - - # review_audits table should be gone - audit_check = await conn.execute( - text( - "SELECT EXISTS (" - " SELECT FROM information_schema.tables " - " WHERE table_schema = 'public' AND table_name = 'review_audits'" - ");" - ) - ) - assert audit_check.scalar() is False - - # scoring_runs should have kept existing pre-0005 data while columns were dropped - run_res = await conn.execute( - text("SELECT id, status, final_score FROM scoring_runs WHERE id = :id;"), - {"id": run_id}, - ) - run_row = run_res.fetchone() - assert run_row is not None - assert run_row[0] == run_id - assert run_row[1] == "completed" - assert run_row[2] == 80 - - # outbox_events should retain terminal event while review events are purged - events_res = await conn.execute( - text("SELECT id, event_type FROM outbox_events WHERE run_id = :id;"), {"id": run_id} - ) - events = events_res.fetchall() - assert len(events) == 1 - assert events[0][0] == terminal_event_id - assert events[0][1] == "lead_scoring.run.terminal" - - # 5. Upgrade back to head to verify round-trip - await asyncio.to_thread(command.upgrade, alembic_cfg, "head") - - async with engine.connect() as conn: - res = await conn.execute(text("SELECT version_num FROM alembic_version;")) - assert res.scalar() == "0010_dotb_headers" - - # Downgrade to base to clean up - await asyncio.to_thread(command.downgrade, alembic_cfg, "base") - await engine.dispose() - - -@pytest.mark.asyncio -async def test_alembic_migration_0006_downgrade_and_upgrade_with_reevaluate_data( - temp_postgres_db: str, -): - alembic_cfg = get_alembic_config(temp_postgres_db) - engine = create_async_engine(temp_postgres_db) - - # 1. Upgrade to head (0006) - await asyncio.to_thread(command.upgrade, alembic_cfg, "head") - - parent_run_id = "00000000-0000-0000-0000-000000000010" - child_run_id = "00000000-0000-0000-0000-000000000011" - audit_id = "00000000-0000-0000-0000-000000000012" - - async with engine.begin() as conn: - # Insert parent run (superseded) - await conn.execute( - text( - "INSERT INTO scoring_runs (" - " id, tenant_id, subject_ref, scoring_config_version, idempotency_key, " - " payload_hash, status, snapshot_data, version, rule_score, " - " ai_score, final_score, effective_score, scoring_mode, " - " degraded, review_decision, reviewed_by, reviewed_at, review_note, " - " parent_run_id, created_at, updated_at" - ") VALUES (" - " :id, 'tenant_1', 'lead_1', '1.0.0', 'parent-idem-1', " - " 'hash-p', 'superseded', '{}'::jsonb, 2, 60, " - " NULL, 60, NULL, 'rules_only', " - " false, 're_evaluate', 'admin_user', now(), 'Re-evaluating with new data', " - " NULL, now(), now()" - ")" - ), - {"id": parent_run_id}, - ) - - # Insert child run with parent_run_id - await conn.execute( - text( - "INSERT INTO scoring_runs (" - " id, tenant_id, subject_ref, scoring_config_version, idempotency_key, " - " payload_hash, status, snapshot_data, version, rule_score, " - " ai_score, final_score, effective_score, scoring_mode, " - " degraded, review_decision, reviewed_by, reviewed_at, review_note, " - " parent_run_id, created_at, updated_at" - ") VALUES (" - " :id, 'tenant_1', 'lead_1', '1.0.0', 'child-idem-1', " - " 'hash-c', 'pending', '{}'::jsonb, 1, NULL, " - " NULL, NULL, NULL, NULL, " - " NULL, NULL, NULL, NULL, NULL, " - " :parent_id, now(), now()" - ")" - ), - {"id": child_run_id, "parent_id": parent_run_id}, - ) - - # Insert review audit with NULL effective_score and child_run_id - await conn.execute( - text( - "INSERT INTO review_audits (" - " id, run_id, tenant_id, decision, actor_id, actor_role, idempotency_key, " - " payload_hash, previous_version, new_version, " - " calculated_score, effective_score, " - " child_run_id, note, created_at" - ") VALUES (" - " :id, :parent_id, 'tenant_1', 're_evaluate', 'admin_user', " - " 'administrator', 'rev-idem-1', " - " 'hash-rev', 1, 2, 60, NULL, " - " :child_id, 'Re-evaluating with new data', now()" - ")" - ), - {"id": audit_id, "parent_id": parent_run_id, "child_id": child_run_id}, - ) - - # 2. Downgrade to 0005_review_api_and_audit - await asyncio.to_thread(command.downgrade, alembic_cfg, "0005_review_api_and_audit") - - async with engine.connect() as conn: - res = await conn.execute(text("SELECT version_num FROM alembic_version;")) - assert res.scalar() == "0005_review_api_and_audit" - - # Check that audit effective_score defaulted to 0 on downgrade (not NULL) - audit_res = await conn.execute( - text("SELECT effective_score FROM review_audits WHERE id = :id;"), - {"id": audit_id}, - ) - assert audit_res.scalar() == 0 - - # Check that parent_run_id column no longer exists - cols_result = await conn.execute( - text( - "SELECT column_name FROM information_schema.columns " - "WHERE table_schema = 'public' AND table_name = 'scoring_runs';" - ) - ) - cols = {r[0] for r in cols_result.fetchall()} - assert "parent_run_id" not in cols - - # 3. Upgrade back to head (0009) - await asyncio.to_thread(command.upgrade, alembic_cfg, "head") - - async with engine.connect() as conn: - res = await conn.execute(text("SELECT version_num FROM alembic_version;")) - assert res.scalar() == "0010_dotb_headers" - - # Downgrade to base - await asyncio.to_thread(command.downgrade, alembic_cfg, "base") - await engine.dispose() - - -@pytest.mark.asyncio -async def test_alembic_migration_0007_downgrade_and_upgrade_with_outbox_data( - temp_postgres_db: str, -): - alembic_cfg = get_alembic_config(temp_postgres_db) - engine = create_async_engine(temp_postgres_db) - - # 1. Upgrade to head (0007) - await asyncio.to_thread(command.upgrade, alembic_cfg, "head") - - run_id = "00000000-0000-0000-0000-000000000020" - event_id = "00000000-0000-0000-0000-000000000021" - - async with engine.begin() as conn: - await conn.execute( - text( - "INSERT INTO scoring_runs (" - " id, tenant_id, subject_ref, scoring_config_version, idempotency_key, " - " payload_hash, status, snapshot_data, version, rule_score, " - " created_at, updated_at" - ") VALUES (" - " :id, 'tenant_1', 'lead_1', '1.0.0', 'run-idem-20', " - " 'hash-20', 'completed', '{}'::jsonb, 1, 85, " - " now(), now()" - ")" - ), - {"id": run_id}, - ) - - await conn.execute( - text( - "INSERT INTO outbox_events (" - " id, event_type, event_version, run_id, payload, occurred_at, " - " delivery_status, attempt_count, next_attempt_at, claim_token, " - " last_error_code, last_error_detail, last_http_status" - ") VALUES (" - " :id, 'lead_scoring.run.terminal', '1.0.0', :run_id, '{}'::jsonb, now(), " - " 'dead_letter', 5, now(), 'token-20', " - " 'retries_exhausted', 'Max retry limit reached', 500" - ")" - ), - {"id": event_id, "run_id": run_id}, - ) - - # 2. Downgrade to 0006_reevaluate_child_lineage - await asyncio.to_thread(command.downgrade, alembic_cfg, "0006_reevaluate_child_lineage") - - async with engine.connect() as conn: - res = await conn.execute(text("SELECT version_num FROM alembic_version;")) - assert res.scalar() == "0006_reevaluate_child_lineage" - - # Check outbox_events retained original core columns and record - outbox_res = await conn.execute( - text("SELECT id, event_type, run_id FROM outbox_events WHERE id = :id;"), - {"id": event_id}, - ) - row = outbox_res.fetchone() - assert row is not None - assert row[0] == event_id - assert row[1] == "lead_scoring.run.terminal" - assert row[2] == run_id - - # Check that 0007 columns are dropped - cols_result = await conn.execute( - text( - "SELECT column_name FROM information_schema.columns " - "WHERE table_schema = 'public' AND table_name = 'outbox_events';" - ) - ) - cols = {r[0] for r in cols_result.fetchall()} - assert "delivery_status" not in cols - assert "attempt_count" not in cols - assert "next_attempt_at" not in cols - - # 3. Upgrade back to head (0009) - await asyncio.to_thread(command.upgrade, alembic_cfg, "head") - - async with engine.connect() as conn: - res = await conn.execute(text("SELECT version_num FROM alembic_version;")) - assert res.scalar() == "0010_dotb_headers" - - # Check default values populated on upgraded existing row - outbox_res = await conn.execute( - text("SELECT id, delivery_status, attempt_count FROM outbox_events WHERE id = :id;"), - {"id": event_id}, - ) - row = outbox_res.fetchone() - assert row is not None - assert row[1] == "pending" - assert row[2] == 0 - - # Downgrade to base - await asyncio.to_thread(command.downgrade, alembic_cfg, "base") - await engine.dispose() - - -@pytest.mark.asyncio -async def test_alembic_migration_0008_downgrade_and_upgrade_with_retention_data( - temp_postgres_db: str, -): - alembic_cfg = get_alembic_config(temp_postgres_db) - engine = create_async_engine(temp_postgres_db) - - # 1. Upgrade to head (0008) - await asyncio.to_thread(command.upgrade, alembic_cfg, "head") - - run_id = "00000000-0000-0000-0000-000000000030" - audit_id = "00000000-0000-0000-0000-000000000031" - - async with engine.begin() as conn: - await conn.execute( - text( - "INSERT INTO scoring_runs (" - " id, tenant_id, subject_ref, scoring_config_version, idempotency_key, " - " payload_hash, status, snapshot_data, version, rule_score, " - " redacted_at, created_at, updated_at" - ") VALUES (" - " :id, 'tenant_1', 'lead_1', '1.0.0', 'run-idem-30', " - " 'hash-30', 'completed', '{}'::jsonb, 1, 85, " - " now(), now(), now()" - ")" - ), - {"id": run_id}, - ) - - await conn.execute( - text( - "INSERT INTO review_audits (" - " id, run_id, tenant_id, decision, actor_id, actor_role, idempotency_key, " - " payload_hash, previous_version, new_version, calculated_score, effective_score, " - " redacted_at, created_at" - ") VALUES (" - " :id, :run_id, 'tenant_1', 'approve', 'redacted', 'agent', 'rev-idem-30', " - " 'redacted', 1, 2, 85, 85, now(), now()" - ")" - ), - {"id": audit_id, "run_id": run_id}, - ) - - # 2. Downgrade to 0007_outbox_delivery_state - await asyncio.to_thread(command.downgrade, alembic_cfg, "0007_outbox_delivery_state") - - async with engine.connect() as conn: - res = await conn.execute(text("SELECT version_num FROM alembic_version;")) - assert res.scalar() == "0007_outbox_delivery_state" - - # Check that redacted_at columns are dropped from scoring_runs and review_audits - run_cols_result = await conn.execute( - text( - "SELECT column_name FROM information_schema.columns " - "WHERE table_schema = 'public' AND table_name = 'scoring_runs';" - ) - ) - run_cols = {r[0] for r in run_cols_result.fetchall()} - assert "redacted_at" not in run_cols - - audit_cols_result = await conn.execute( - text( - "SELECT column_name FROM information_schema.columns " - "WHERE table_schema = 'public' AND table_name = 'review_audits';" - ) - ) - audit_cols = {r[0] for r in audit_cols_result.fetchall()} - assert "redacted_at" not in audit_cols - - # Ensure base records are still preserved - run_res = await conn.execute( - text("SELECT id, status FROM scoring_runs WHERE id = :id;"), - {"id": run_id}, - ) - assert run_res.fetchone() is not None - - # 3. Upgrade back to head (0009) - await asyncio.to_thread(command.upgrade, alembic_cfg, "head") - - async with engine.connect() as conn: - res = await conn.execute(text("SELECT version_num FROM alembic_version;")) - assert res.scalar() == "0010_dotb_headers" - - # Check redacted_at is now NULL for existing rows - run_check = await conn.execute( - text("SELECT id, redacted_at FROM scoring_runs WHERE id = :id;"), - {"id": run_id}, - ) - row = run_check.fetchone() - assert row is not None - assert row[1] is None - - # Downgrade to base - await asyncio.to_thread(command.downgrade, alembic_cfg, "base") - await engine.dispose() - - -@pytest.mark.asyncio -async def test_alembic_migration_0009_downgrade_and_upgrade_with_execution_trace( - temp_postgres_db: str, -): - alembic_cfg = get_alembic_config(temp_postgres_db) - engine = create_async_engine(temp_postgres_db) - - # 1. Upgrade to head (0009) - await asyncio.to_thread(command.upgrade, alembic_cfg, "head") - - run_id = "00000000-0000-0000-0000-000000000040" - - async with engine.begin() as conn: - await conn.execute( - text( - "INSERT INTO scoring_runs (" - " id, tenant_id, subject_ref, scoring_config_version, idempotency_key, " - " payload_hash, status, snapshot_data, version, rule_score, " - " execution_trace, created_at, updated_at" - ") VALUES (" - " :id, 'tenant_1', 'lead_1', '1.0.0', 'run-idem-40', " - " 'hash-40', 'completed', '{}'::jsonb, 1, 85, " - ' \'{"status": "completed", "calculation": {"rule_score": 85}}\'::jsonb, ' - " now(), now()" - ")" - ), - {"id": run_id}, - ) - - # 2. Downgrade to 0008_retention_redaction_state - await asyncio.to_thread(command.downgrade, alembic_cfg, "0008_retention_redaction_state") - - async with engine.connect() as conn: - res = await conn.execute(text("SELECT version_num FROM alembic_version;")) - assert res.scalar() == "0008_retention_redaction_state" - - # Check that execution_trace column is dropped - run_cols_result = await conn.execute( - text( - "SELECT column_name FROM information_schema.columns " - "WHERE table_schema = 'public' AND table_name = 'scoring_runs';" - ) - ) - run_cols = {r[0] for r in run_cols_result.fetchall()} - assert "execution_trace" not in run_cols - - # Ensure base records are still preserved - run_res = await conn.execute( - text("SELECT id, status FROM scoring_runs WHERE id = :id;"), - {"id": run_id}, - ) - assert run_res.fetchone() is not None - - # 3. Upgrade back to head (0010) - await asyncio.to_thread(command.upgrade, alembic_cfg, "head") - - async with engine.connect() as conn: - res = await conn.execute(text("SELECT version_num FROM alembic_version;")) - assert res.scalar() == "0010_dotb_headers" - - # Check execution_trace is now NULL for existing rows - run_check = await conn.execute( - text("SELECT id, execution_trace FROM scoring_runs WHERE id = :id;"), - {"id": run_id}, - ) - row = run_check.fetchone() - assert row is not None - assert row[1] is None - - # Downgrade to base - await asyncio.to_thread(command.downgrade, alembic_cfg, "base") - await engine.dispose() - - -@pytest.mark.asyncio -async def test_alembic_migration_0010_downgrade_and_upgrade_with_dotb_headers( - temp_postgres_db: str, -): - alembic_cfg = get_alembic_config(temp_postgres_db) - engine = create_async_engine(temp_postgres_db) - - # 1. Upgrade to head (0010) - await asyncio.to_thread(command.upgrade, alembic_cfg, "head") - - run_id = "00000000-0000-0000-0000-000000000050" - - async with engine.begin() as conn: - await conn.execute( - text( - "INSERT INTO scoring_runs (" - " id, tenant_id, subject_ref, scoring_config_version, idempotency_key, " - " payload_hash, status, snapshot_data, version, rule_score, " - " dotb_app, dotb_feature, created_at, updated_at" - ") VALUES (" - " :id, 'tenant_1', 'lead_1', '1.0.0', 'run-idem-50', " - " 'hash-50', 'completed', '{}'::jsonb, 1, 85, " - " 'dotb_omni', 'lead-scoring', " - " now(), now()" - ")" - ), - {"id": run_id}, - ) - - # 2. Downgrade to 0009_execution_trace - await asyncio.to_thread(command.downgrade, alembic_cfg, "0009_execution_trace") - - async with engine.connect() as conn: - res = await conn.execute(text("SELECT version_num FROM alembic_version;")) - assert res.scalar() == "0009_execution_trace" - - # Check that dotb_app and dotb_feature columns are dropped - run_cols_result = await conn.execute( - text( - "SELECT column_name FROM information_schema.columns " - "WHERE table_schema = 'public' AND table_name = 'scoring_runs';" - ) - ) - run_cols = {r[0] for r in run_cols_result.fetchall()} - assert "dotb_app" not in run_cols - assert "dotb_feature" not in run_cols - - # Ensure base records are still preserved - run_res = await conn.execute( - text("SELECT id, status FROM scoring_runs WHERE id = :id;"), - {"id": run_id}, - ) - assert run_res.fetchone() is not None - - # 3. Upgrade back to head (0010) - await asyncio.to_thread(command.upgrade, alembic_cfg, "head") - - async with engine.connect() as conn: - res = await conn.execute(text("SELECT version_num FROM alembic_version;")) - assert res.scalar() == "0010_dotb_headers" - - # Check columns are restored and NULL for existing row - run_check = await conn.execute( - text("SELECT id, dotb_app, dotb_feature FROM scoring_runs WHERE id = :id;"), - {"id": run_id}, - ) - row = run_check.fetchone() - assert row is not None - assert row[1] is None - assert row[2] is None - - # Downgrade to base - await asyncio.to_thread(command.downgrade, alembic_cfg, "base") - await engine.dispose() diff --git a/old_repo/tests/test_observability.py b/old_repo/tests/test_observability.py deleted file mode 100644 index fdd70fb..0000000 --- a/old_repo/tests/test_observability.py +++ /dev/null @@ -1,125 +0,0 @@ -import logging - -import pytest -from httpx import ASGITransport, AsyncClient - -from lead_scoring.config import Settings -from lead_scoring.main import create_app -from lead_scoring.observability import Observability, redact_payload, redact_text - - -def test_redact_payload_removes_credentials_snapshot_and_pii() -> None: - secret = "sentinel-vkey-123" - payload = { - "authorization": f"Bearer {secret}", - "tenant_v_key": secret, - "service_secret": "sentinel-service-secret", - "snapshot": {"lead_profile": {"email": "person@example.com"}}, - "review_note": "private note", - "safe_code": "aihub_timeout", - } - - redacted = redact_payload(payload) - - assert secret not in repr(redacted) - assert "sentinel-service-secret" not in repr(redacted) - assert redacted["snapshot"] == "[REDACTED]" - assert redacted["review_note"] == "[REDACTED]" - assert redacted["safe_code"] == "aihub_timeout" - technical_metadata = redact_payload({"stage_name": "AIHub", "message_id": "msg-1"}) - assert technical_metadata == {"stage_name": "AIHub", "message_id": "msg-1"} - - -def test_observability_records_redacted_exception_and_alert_threshold(caplog) -> None: - caplog.set_level(logging.DEBUG) - telemetry = Observability( - alert_thresholds={"aihub_failures_total": 1}, - ) - - telemetry.record_exception( - "aihub_failure", - RuntimeError("request contained sentinel-secret"), - run_id="run-1", - secrets=("sentinel-secret",), - ) - telemetry.increment( - "aihub_failures_total", - run_id="run-1", - fields={ - "snapshot": { - "email": "person@example.com", - "phone": "+84901234567", - } - }, - ) - telemetry.record( - "trace.test", - fields={"safe_value": "sentinel-service-secret"}, - secrets=("sentinel-service-secret",), - ) - snapshot = telemetry.snapshot() - assert snapshot["counters"]["aihub_failures_total"] == 1 - assert snapshot["alerts"][0]["metric"] == "aihub_failures_total" - assert "sentinel-secret" not in repr(snapshot) - assert "sentinel-service-secret" not in repr(snapshot) - assert "sentinel-service-secret" not in caplog.text - assert "+84901234567" not in repr(snapshot) - assert "person@example.com" not in repr(snapshot) - - -def test_settings_expose_kill_switch_and_alert_thresholds() -> None: - settings = Settings(kill_switch_enabled=True, stuck_run_alert_threshold=2) - - assert settings.kill_switch_enabled is True - assert settings.stuck_run_alert_threshold == 2 - - -def test_observability_exposes_required_metric_families_and_redacted_text() -> None: - telemetry = Observability() - for metric in ( - "scoring_admission_total", - "scoring_run_latency_seconds", - "aihub_failures_total", - "scoring_degraded_rate", - "scoring_hitl_rate", - "scoring_override_rate", - "aihub_tokens_total", - "scoring_stuck_runs_total", - "outbox_retries_total", - "outbox_dead_letter_total", - "retention_failures_total", - ): - telemetry.increment(metric) - telemetry.observe("scoring_run_latency_seconds", 0.25) - telemetry.observe("scoring_degraded_rate", 1.0) - - snapshot = telemetry.snapshot() - assert set(snapshot["counters"]).issuperset( - { - "scoring_admission_total", - "aihub_failures_total", - "outbox_dead_letter_total", - "retention_failures_total", - } - ) - assert snapshot["histograms"]["scoring_degraded_rate"]["average"] == 1.0 - assert redact_text("Authorization: Bearer vkey-secret", secrets=("vkey-secret",)) == ( - "Authorization: Bearer [REDACTED]" - ) - assert "person@example.com" not in redact_text("person@example.com") - assert "+84901234567" not in redact_text("+84901234567") - - -@pytest.mark.asyncio -async def test_request_correlation_does_not_echo_pii() -> None: - app = create_app(Settings()) - - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - response = await client.get( - "/health", - headers={"X-Request-ID": "person@example.com"}, - ) - - assert response.status_code == 503 - assert response.headers["X-Request-ID"] != "person@example.com" - assert "person@example.com" not in repr(app.state.telemetry.snapshot()) diff --git a/old_repo/tests/test_openapi_cli.py b/old_repo/tests/test_openapi_cli.py deleted file mode 100644 index 522d209..0000000 --- a/old_repo/tests/test_openapi_cli.py +++ /dev/null @@ -1,102 +0,0 @@ -import json -from pathlib import Path - -import pytest - -from lead_scoring.cli.openapi import DEFAULT_OPENAPI_PATH, generate_openapi_json, run_openapi_cli - - -def test_generate_openapi_json_is_deterministic(): - json_str_1 = generate_openapi_json() - json_str_2 = generate_openapi_json() - - assert json_str_1 == json_str_2 - data = json.loads(json_str_1) - assert data["info"]["title"] == "Lead Scoring Service" - assert "/health" in data["paths"] - assert "/v1/scoring-runs" in data["paths"] - assert "/v1/scoring-runs/{run_id}" in data["paths"] - assert "CreateScoringRunRequest" in data["components"]["schemas"] - assert "CreateScoringRunResponse" in data["components"]["schemas"] - assert "ScoringRunResponse" in data["components"]["schemas"] - assert "ErrorResponse" in data["components"]["schemas"] - - # Security scheme assertions - assert "securitySchemes" in data["components"] - assert "HTTPBearer" in data["components"]["securitySchemes"] - bearer_scheme = data["components"]["securitySchemes"]["HTTPBearer"] - assert bearer_scheme["type"] == "http" - assert bearer_scheme["scheme"] == "bearer" - - # Operation security assertions - post_security = data["paths"]["/v1/scoring-runs"]["post"].get("security", []) - assert any("HTTPBearer" in s for s in post_security) - - get_security = data["paths"]["/v1/scoring-runs/{run_id}"]["get"].get("security", []) - assert any("HTTPBearer" in s for s in get_security) - - -def test_default_openapi_path_is_at_project_root(): - assert DEFAULT_OPENAPI_PATH.name == "openapi.json" - assert (DEFAULT_OPENAPI_PATH.parent / "pyproject.toml").exists() - - -def test_openapi_generation_ignores_env_file_pollution(monkeypatch: pytest.MonkeyPatch): - # Setting an env var should not mutate deterministic contract generation - monkeypatch.setenv("APP_NAME", "mutated-name") - json_str_1 = generate_openapi_json() - json_str_2 = generate_openapi_json() - assert json_str_1 == json_str_2 - data = json.loads(json_str_1) - assert data["info"]["title"] == "Lead Scoring Service" - - -def test_openapi_cli_generates_file_at_custom_path(tmp_path: Path): - out_file = tmp_path / "custom_openapi.json" - exit_code = run_openapi_cli(["--output", str(out_file)]) - - assert exit_code == 0 - assert out_file.exists() - content = out_file.read_text(encoding="utf-8") - assert content == generate_openapi_json() - - -def test_openapi_cli_check_success_when_matching(tmp_path: Path): - out_file = tmp_path / "openapi.json" - out_file.write_text(generate_openapi_json(), encoding="utf-8") - - exit_code = run_openapi_cli(["--output", str(out_file), "--check"]) - assert exit_code == 0 - - -def test_openapi_cli_check_fails_when_file_absent(tmp_path: Path): - out_file = tmp_path / "non_existent_openapi.json" - - exit_code = run_openapi_cli(["--output", str(out_file), "--check"]) - assert exit_code != 0 - - -def test_openapi_cli_check_fails_when_file_stale(tmp_path: Path): - out_file = tmp_path / "openapi.json" - out_file.write_text('{"stale": true}\n', encoding="utf-8") - - exit_code = run_openapi_cli(["--output", str(out_file), "--check"]) - assert exit_code != 0 - - -def test_generate_openapi_preserves_custom_app_openapi(): - from lead_scoring.main import create_app - - custom_app = create_app() - original_openapi = custom_app.openapi - - def _custom_openapi(): - schema = original_openapi() - schema["info"]["x-custom-extension"] = "pilot-test" - return schema - - custom_app.openapi = _custom_openapi # type: ignore - - json_str = generate_openapi_json(custom_app) - data = json.loads(json_str) - assert data["info"].get("x-custom-extension") == "pilot-test" diff --git a/old_repo/tests/test_polling_contract_cli.py b/old_repo/tests/test_polling_contract_cli.py deleted file mode 100644 index 5e22011..0000000 --- a/old_repo/tests/test_polling_contract_cli.py +++ /dev/null @@ -1,153 +0,0 @@ -import json -from pathlib import Path - -import pytest -from pydantic import ValidationError - -from lead_scoring.cli.polling_contract import ( - DEFAULT_SCORING_CONFIG, - canonical_json, - generate_contract_files, - run_polling_contract_cli, -) -from lead_scoring.schemas.scoring_run import ( - AiMetadataSuccess, - ScoringRunResponse, - ScoringRunStatus, -) -from lead_scoring.scoring import ( - ScoringMode, -) - - -def test_polling_contract_files_are_deterministic_and_validate_against_models(): - files = generate_contract_files() - - assert files == generate_contract_files() - assert set(files) == { - "contract.json", - "scoring-run-response.schema.json", - "error-response.schema.json", - "fixtures/pending.json", - "fixtures/running.json", - "fixtures/completed.json", - "fixtures/failed.json", - } - - manifest = json.loads(files["contract.json"]) - assert manifest["api_major_version"] == "v1" - assert manifest["contract_pack_version"] == "0.1.0" - assert manifest["endpoint_path"] == "/v1/scoring-runs/{run_id}" - assert manifest["method"] == "GET" - assert manifest["headers"]["authorization"] == "Bearer " - assert manifest["headers"]["tenant_id"] == "X-Tenant-Id" - assert manifest["path_params"]["run_id"]["type"] == "string" - assert manifest["supported_statuses"] == [ - "pending", - "running", - "completed", - "failed", - ] - assert manifest["reserved_statuses"] == [] - assert ( - "missing/invalid required headers or path parameters" - in manifest["http_responses"]["422"]["description"] - ) - - response_schema = json.loads(files["scoring-run-response.schema.json"]) - assert "status" in response_schema["required"] - assert "run_id" in response_schema["required"] - assert "tenant_id" in response_schema["required"] - assert "subject_ref" in response_schema["required"] - assert "rule_score" in response_schema["required"] - assert "ai_score" in response_schema["required"] - assert "final_score" in response_schema["required"] - assert "effective_score" in response_schema["required"] - assert "scoring_mode" in response_schema["required"] - assert "degraded" in response_schema["required"] - assert "ai_metadata" in response_schema["required"] - assert "confidence" in response_schema["required"] - assert "confidence_threshold" in response_schema["required"] - assert "effective_config" in response_schema["required"] - assert "error_code" in response_schema["required"] - assert "retryable" in response_schema["required"] - assert "version" in response_schema["required"] - assert "started_at" in response_schema["required"] - assert "completed_at" in response_schema["required"] - assert "created_at" in response_schema["required"] - assert "updated_at" in response_schema["required"] - - error_schema = json.loads(files["error-response.schema.json"]) - assert "error_code" in error_schema["required"] - assert "detail" in error_schema["required"] - - # Public polling exposes the selected version, not internal prompt/config contents. - for name in ["pending", "running", "completed", "failed"]: - raw = json.loads(files[f"fixtures/{name}.json"]) - res = ScoringRunResponse.model_validate(raw) - assert res.effective_config is None - assert res.confidence_threshold == 0.80 - - pending = ScoringRunResponse.model_validate(json.loads(files["fixtures/pending.json"])) - assert pending.status is ScoringRunStatus.PENDING - assert pending.scoring_mode is None - assert pending.ai_metadata is None - assert pending.confidence is None - assert pending.started_at is None - assert pending.completed_at is None - - running = ScoringRunResponse.model_validate(json.loads(files["fixtures/running.json"])) - assert running.status is ScoringRunStatus.RUNNING - assert running.scoring_mode is None - assert running.ai_metadata is None - assert running.confidence is None - assert running.started_at is not None - assert running.completed_at is None - - completed = ScoringRunResponse.model_validate(json.loads(files["fixtures/completed.json"])) - assert completed.status is ScoringRunStatus.COMPLETED - assert completed.scoring_mode is ScoringMode.MEDIUM_AI - assert completed.rule_score == 82 - assert completed.ai_score == 78 - assert completed.final_score == 80 - assert completed.confidence == 0.91 - assert completed.degraded is False - assert isinstance(completed.ai_metadata, AiMetadataSuccess) - assert completed.ai_metadata.model == DEFAULT_SCORING_CONFIG.ai_model - assert completed.ai_metadata.effort_profile is DEFAULT_SCORING_CONFIG.effort_profile - assert completed.ai_metadata.pass_count == 2 - - failed = ScoringRunResponse.model_validate(json.loads(files["fixtures/failed.json"])) - assert failed.status is ScoringRunStatus.FAILED - assert failed.scoring_mode is None - assert failed.ai_metadata is None - assert failed.error_code == "worker_interrupted" - assert failed.retryable is True - - -def test_scoring_run_response_forbids_extra_fields(): - files = generate_contract_files() - raw = json.loads(files["fixtures/completed.json"]) - raw["unexpected_extra_field"] = "bad" - with pytest.raises(ValidationError): - ScoringRunResponse.model_validate(raw) - - -def test_polling_contract_cli_generates_and_checks_contract_pack(tmp_path: Path): - output_dir = tmp_path / "polling-contract" - - assert run_polling_contract_cli(["--output", str(output_dir)]) == 0 - assert run_polling_contract_cli(["--output", str(output_dir), "--check"]) == 0 - - -def test_polling_contract_cli_rejects_stale_or_unexpected_artifacts(tmp_path: Path): - output_dir = tmp_path / "polling-contract" - assert run_polling_contract_cli(["--output", str(output_dir)]) == 0 - - fixture = output_dir / "fixtures" / "completed.json" - fixture.write_text(canonical_json({"stale": True}), encoding="utf-8") - assert run_polling_contract_cli(["--output", str(output_dir), "--check"]) == 1 - - assert run_polling_contract_cli(["--output", str(output_dir)]) == 0 - (output_dir / "unexpected.json").write_text("{}\n", encoding="utf-8") - assert run_polling_contract_cli(["--output", str(output_dir), "--check"]) == 1 diff --git a/old_repo/tests/test_retention.py b/old_repo/tests/test_retention.py deleted file mode 100644 index c51d904..0000000 --- a/old_repo/tests/test_retention.py +++ /dev/null @@ -1,1119 +0,0 @@ -import asyncio -import logging -import uuid -from datetime import UTC, datetime, timedelta -from pathlib import Path -from typing import Any, cast - -import pytest -from alembic import command -from alembic.config import Config -from sqlalchemy import select, text -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine - -from lead_scoring.db.models.outbox_event import OutboxEvent -from lead_scoring.db.models.review_audit import ReviewAudit -from lead_scoring.db.models.scoring_run import ScoringRun -from lead_scoring.retention import ( - RetentionCleanupResult, - TenantPurgeResult, - execute_retention_cleanup, - purge_tenant_data, -) - - -def get_alembic_config(database_url: str) -> Config: - root_dir = Path(__file__).resolve().parent.parent - ini_path = root_dir / "alembic.ini" - alembic_cfg = Config(str(ini_path)) - alembic_cfg.attributes["sqlalchemy_url"] = database_url - return alembic_cfg - - -@pytest.fixture -async def db_session_factory(temp_postgres_db: str): - alembic_cfg = get_alembic_config(temp_postgres_db) - await asyncio.to_thread(command.upgrade, alembic_cfg, "head") - - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, expire_on_commit=False) - try: - yield session_factory - finally: - async with engine.begin() as conn: - await conn.execute( - text("TRUNCATE TABLE review_audits, outbox_events, scoring_runs CASCADE;") - ) - await engine.dispose() - - -def _sample_snapshot_with_pii() -> dict[str, Any]: - return { - "schema_version": "1.0.0", - "lead_profile": { - "ems_lead_id": "lead-pii-001", - "full_name": "Nguyễn Văn Nhạy Cảm", - "phone": "+84901234567", - "email": "sensitive_user@example.com", - "company": "Top Secret Corp", - "title": "Director", - }, - "conversations": [ - { - "conversation_id": "conv-001", - "assigned_agent_id": "agent-secret-01", - "messages": [ - { - "message_id": "msg-001", - "sender_role": "lead", - "content": "Tôi muốn mua gói enterprise 100k USD ngay hôm nay!", - "timestamp": "2026-05-01T10:00:00Z", - } - ], - } - ], - "trigger_metadata": { - "trigger_conversation_id": "conv-001", - }, - "hitl_policy": { - "confidence_threshold": 0.8, - "policy_version": "1.0.0", - }, - } - - -@pytest.mark.asyncio -async def test_retention_boundary_before_exact_after_90_days( - db_session_factory: async_sessionmaker[AsyncSession], -): - fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - cutoff = fixed_now - timedelta(days=90) # 2026-05-30 12:00:00 UTC - - # Run 1: 1 second before cutoff (created_at = 2026-05-30 11:59:59 UTC) -> EXPIRED -> Redact - run_id_before = str(uuid.uuid4()) - # Run 2: Exactly at cutoff (created_at = 2026-05-30 12:00:00 UTC) -> EXPIRED -> Redact - run_id_exact = str(uuid.uuid4()) - # Run 3: 1 second after cutoff (created_at = 2026-05-30 12:00:01 UTC) -> ACTIVE -> Keep intact - run_id_after = str(uuid.uuid4()) - - async with db_session_factory() as session: - session.add_all( - [ - ScoringRun( - id=run_id_before, - tenant_id="tenant_a", - subject_ref="lead-pii-001", - scoring_config_version="1.0.0", - idempotency_key="key-before", - payload_hash="hash-before-pii", - status="completed", - snapshot_data=_sample_snapshot_with_pii(), - effective_config={"rule_weight": 0.5, "ai_weight": 0.5}, - rule_score=80, - ai_score=90, - final_score=85, - effective_score=85, - reviewed_by="agent_secret_before", - review_note="Confidential reviewer note before", - created_at=cutoff - timedelta(seconds=1), - updated_at=cutoff - timedelta(seconds=1), - ), - ScoringRun( - id=run_id_exact, - tenant_id="tenant_a", - subject_ref="lead-pii-001", - scoring_config_version="1.0.0", - idempotency_key="key-exact", - payload_hash="hash-exact-pii", - status="completed", - snapshot_data=_sample_snapshot_with_pii(), - effective_config={"rule_weight": 0.5, "ai_weight": 0.5}, - rule_score=75, - ai_score=85, - final_score=80, - effective_score=80, - reviewed_by="agent_secret_exact", - review_note="Confidential reviewer note exact", - created_at=cutoff, - updated_at=cutoff, - ), - ScoringRun( - id=run_id_after, - tenant_id="tenant_a", - subject_ref="lead-pii-001", - scoring_config_version="1.0.0", - idempotency_key="key-after", - payload_hash="hash-after-pii", - status="completed", - snapshot_data=_sample_snapshot_with_pii(), - effective_config={"rule_weight": 0.5, "ai_weight": 0.5}, - rule_score=70, - ai_score=80, - final_score=75, - effective_score=75, - reviewed_by="agent_active", - review_note="Active reviewer note", - created_at=cutoff + timedelta(seconds=1), - updated_at=cutoff + timedelta(seconds=1), - ), - ] - ) - await session.commit() - - # Execute retention cleanup with injected clock - result: RetentionCleanupResult = await execute_retention_cleanup( - db_session_factory, - clock=lambda: fixed_now, - ) - - assert result.runs_redacted == 2 - - # Verify each run's persistence state - async with db_session_factory() as session: - r_before = ( - await session.execute(select(ScoringRun).where(ScoringRun.id == run_id_before)) - ).scalar_one() - r_exact = ( - await session.execute(select(ScoringRun).where(ScoringRun.id == run_id_exact)) - ).scalar_one() - r_after = ( - await session.execute(select(ScoringRun).where(ScoringRun.id == run_id_after)) - ).scalar_one() - - # Run before cutoff: redacted - assert r_before.snapshot_data == {} - assert r_before.payload_hash == "redacted" - assert r_before.reviewed_by is None - assert r_before.review_note is None - assert getattr(r_before, "redacted_at", None) is not None - assert r_before.final_score == 85 - assert r_before.effective_score == 85 - - # Run exact at cutoff: redacted - assert r_exact.snapshot_data == {} - assert r_exact.payload_hash == "redacted" - assert r_exact.reviewed_by is None - assert r_exact.review_note is None - assert getattr(r_exact, "redacted_at", None) is not None - assert r_exact.final_score == 80 - assert r_exact.effective_score == 80 - - # Run after cutoff: preserved intact - assert r_after.snapshot_data != {} - assert "Nguyễn Văn Nhạy Cảm" in str(r_after.snapshot_data) - assert r_after.payload_hash == "hash-after-pii" - assert r_after.reviewed_by == "agent_active" - assert r_after.review_note == "Active reviewer note" - assert getattr(r_after, "redacted_at", None) is None - - -@pytest.mark.asyncio -async def test_retention_preserves_strict_audit_allowlist( - db_session_factory: async_sessionmaker[AsyncSession], -): - fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - old_time = fixed_now - timedelta(days=95) - run_id = str(uuid.uuid4()) - parent_id = str(uuid.uuid4()) - - async with db_session_factory() as session: - session.add( - ScoringRun( - id=parent_id, - tenant_id="tenant_audit_test", - subject_ref="subject_ref_123", - scoring_config_version="1.2.0", - idempotency_key="idempotency_parent_audit", - payload_hash="redacted", - status="superseded", - snapshot_data={}, - redacted_at=old_time, - created_at=old_time - timedelta(days=1), - updated_at=old_time - timedelta(days=1), - ) - ) - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant_audit_test", - subject_ref="subject_ref_123", - scoring_config_version="1.2.0", - parent_run_id=parent_id, - idempotency_key="idempotency_key_audit", - payload_hash="sensitive_hash_value_123456", - status="completed", - snapshot_data=_sample_snapshot_with_pii(), - effective_config={ - "version": "1.2.0", - "rule_weight": 0.4, - "ai_weight": 0.6, - "confidence_threshold": 0.85, - "effort_profile": "medium", - "ai_model": "claude-3-5-sonnet", - }, - confidence_threshold=0.85, - rule_score=88, - ai_score=92, - final_score=90, - effective_score=90, - scoring_mode="medium_ai", - degraded=False, - ai_metadata={ - "model": "claude-3-5-sonnet", - "effort_profile": "medium", - "pass_count": 2, - }, - confidence=0.88, - error_code=None, - retryable=None, - version=2, - review_decision="approve", - reviewed_by="human_actor_999", - reviewed_at=old_time + timedelta(hours=1), - review_note="Internal reviewer private commentary", - started_at=old_time, - completed_at=old_time + timedelta(seconds=15), - created_at=old_time, - updated_at=old_time + timedelta(hours=1), - ) - ) - await session.commit() - - result = await execute_retention_cleanup( - db_session_factory, - clock=lambda: fixed_now, - ) - assert result.runs_redacted == 1 - - async with db_session_factory() as session: - run = ( - await session.execute(select(ScoringRun).where(ScoringRun.id == run_id)) - ).scalar_one() - - # Sensitive fields must be redacted - assert run.snapshot_data == {} - assert run.payload_hash == "redacted" - assert run.reviewed_by is None - assert run.review_note is None - - # Allowed structured audit fields must be preserved exactly - assert run.id == run_id - assert run.tenant_id == "tenant_audit_test" - assert run.subject_ref == "subject_ref_123" - assert run.scoring_config_version == "1.2.0" - assert run.parent_run_id == parent_id - assert run.idempotency_key == "idempotency_key_audit" - assert run.status == "completed" - assert run.rule_score == 88 - assert run.ai_score == 92 - assert run.final_score == 90 - assert run.effective_score == 90 - assert run.scoring_mode == "medium_ai" - assert run.degraded is False - assert run.confidence == 0.88 - assert run.confidence_threshold == 0.85 - assert run.version == 2 - assert run.review_decision == "approve" - assert run.reviewed_at == old_time + timedelta(hours=1) - assert run.started_at == old_time - assert run.completed_at == old_time + timedelta(seconds=15) - assert run.created_at == old_time - assert run.ai_metadata == { - "model": "claude-3-5-sonnet", - "effort_profile": "medium", - "pass_count": 2, - } - - -@pytest.mark.asyncio -async def test_retention_review_audits_actor_and_note_redaction( - db_session_factory: async_sessionmaker[AsyncSession], -): - fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - old_time = fixed_now - timedelta(days=91) - recent_time = fixed_now - timedelta(days=10) - - audit_old_id = str(uuid.uuid4()) - audit_recent_id = str(uuid.uuid4()) - run_id = str(uuid.uuid4()) - - async with db_session_factory() as session: - session.add_all( - [ - ReviewAudit( - id=audit_old_id, - run_id=run_id, - tenant_id="tenant_audit", - decision="override", - actor_id="reviewer_private_id_123", - actor_role="agent", - idempotency_key="audit-idem-old", - payload_hash="audit-hash-old", - previous_version=1, - new_version=2, - calculated_score=60, - effective_score=85, - child_run_id=None, - note="Confidential human explanation with private data", - created_at=old_time, - ), - ReviewAudit( - id=audit_recent_id, - run_id=run_id, - tenant_id="tenant_audit", - decision="approve", - actor_id="reviewer_recent_id_456", - actor_role="administrator", - idempotency_key="audit-idem-recent", - payload_hash="audit-hash-recent", - previous_version=2, - new_version=3, - calculated_score=85, - effective_score=85, - child_run_id=None, - note="Recent unexpired note", - created_at=recent_time, - ), - ] - ) - await session.commit() - - result = await execute_retention_cleanup( - db_session_factory, - clock=lambda: fixed_now, - ) - assert result.audits_redacted == 1 - - async with db_session_factory() as session: - old_audit = ( - await session.execute(select(ReviewAudit).where(ReviewAudit.id == audit_old_id)) - ).scalar_one() - recent_audit = ( - await session.execute(select(ReviewAudit).where(ReviewAudit.id == audit_recent_id)) - ).scalar_one() - - # Old audit: actor_id, note, payload_hash redacted - assert old_audit.actor_id == "redacted" - assert old_audit.note is None - assert old_audit.payload_hash == "redacted" - assert getattr(old_audit, "redacted_at", None) is not None - - # Old audit: non-personal structured audit fields preserved - assert old_audit.actor_role == "agent" - assert old_audit.decision == "override" - assert old_audit.calculated_score == 60 - assert old_audit.effective_score == 85 - assert old_audit.previous_version == 1 - assert old_audit.new_version == 2 - assert old_audit.idempotency_key == "audit-idem-old" - - # Recent audit: completely preserved - assert recent_audit.actor_id == "reviewer_recent_id_456" - assert recent_audit.note == "Recent unexpired note" - assert recent_audit.payload_hash == "audit-hash-recent" - assert getattr(recent_audit, "redacted_at", None) is None - - -@pytest.mark.asyncio -async def test_retention_outbox_events_preserves_pending_and_cleans_delivered( - db_session_factory: async_sessionmaker[AsyncSession], -): - fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - old_time = fixed_now - timedelta(days=92) - recent_time = fixed_now - timedelta(days=5) - - ev_old_delivered_id = str(uuid.uuid4()) - ev_old_dead_letter_id = str(uuid.uuid4()) - ev_old_pending_id = str(uuid.uuid4()) - ev_old_delivering_id = str(uuid.uuid4()) - ev_recent_delivered_id = str(uuid.uuid4()) - - async with db_session_factory() as session: - session.add_all( - [ - OutboxEvent( - id=ev_old_delivered_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id="run-1", - payload={"data": "old_delivered"}, - occurred_at=old_time, - delivery_status="delivered", - delivered_at=old_time, - ), - OutboxEvent( - id=ev_old_dead_letter_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id="run-2", - payload={"data": "old_dead_letter"}, - occurred_at=old_time, - delivery_status="dead_letter", - ), - OutboxEvent( - id=ev_old_pending_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id="run-3", - payload={"data": "old_pending_must_not_drop"}, - occurred_at=old_time, - delivery_status="pending", - ), - OutboxEvent( - id=ev_old_delivering_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id="run-4", - payload={"data": "old_delivering_must_not_drop"}, - occurred_at=old_time, - delivery_status="delivering", - lease_expires_at=fixed_now + timedelta(seconds=30), - ), - OutboxEvent( - id=ev_recent_delivered_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id="run-5", - payload={"data": "recent_delivered"}, - occurred_at=recent_time, - delivery_status="delivered", - delivered_at=recent_time, - ), - ] - ) - await session.commit() - - result = await execute_retention_cleanup( - db_session_factory, - clock=lambda: fixed_now, - ) - - # 2 old terminal events cleaned (delivered & dead_letter) - assert result.outbox_events_cleaned == 2 - - async with db_session_factory() as session: - events = (await session.execute(select(OutboxEvent.id))).scalars().all() - assert ev_old_delivered_id not in events - assert ev_old_dead_letter_id not in events - # Pending and delivering events MUST remain - assert ev_old_pending_id in events - assert ev_old_delivering_id in events - # Recent delivered event must remain - assert ev_recent_delivered_id in events - - -@pytest.mark.asyncio -async def test_retention_idempotency_and_restart_safety( - db_session_factory: async_sessionmaker[AsyncSession], -): - fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - old_time = fixed_now - timedelta(days=100) - - run_id = str(uuid.uuid4()) - audit_id = str(uuid.uuid4()) - event_id = str(uuid.uuid4()) - - async with db_session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant_idem", - subject_ref="lead-1", - scoring_config_version="1.0.0", - idempotency_key="run-idem", - payload_hash="hash", - status="completed", - snapshot_data=_sample_snapshot_with_pii(), - rule_score=80, - final_score=80, - effective_score=80, - reviewed_by="agent_1", - review_note="note_1", - created_at=old_time, - updated_at=old_time, - ) - ) - session.add( - ReviewAudit( - id=audit_id, - run_id=run_id, - tenant_id="tenant_idem", - decision="approve", - actor_id="agent_1", - actor_role="agent", - idempotency_key="audit-idem", - payload_hash="hash", - previous_version=1, - new_version=2, - calculated_score=80, - effective_score=80, - note="audit note", - created_at=old_time, - ) - ) - session.add( - OutboxEvent( - id=event_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id=run_id, - payload={"data": "delivered"}, - occurred_at=old_time, - delivery_status="delivered", - delivered_at=old_time, - ) - ) - await session.commit() - - # Pass 1 - res1 = await execute_retention_cleanup(db_session_factory, clock=lambda: fixed_now) - assert res1.runs_redacted == 1 - assert res1.audits_redacted == 1 - assert res1.outbox_events_cleaned == 1 - - # Pass 2 immediately (idempotency check) - res2 = await execute_retention_cleanup(db_session_factory, clock=lambda: fixed_now) - assert res2.runs_redacted == 0 - assert res2.audits_redacted == 0 - assert res2.outbox_events_cleaned == 0 - - -@pytest.mark.asyncio -async def test_tenant_offboarding_purges_all_target_tenant_data_only( - db_session_factory: async_sessionmaker[AsyncSession], -): - fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - - tenant_target = "tenant_offboard_target" - tenant_other = "tenant_other_keep" - - run_target_id = str(uuid.uuid4()) - run_other_id = str(uuid.uuid4()) - audit_target_id = str(uuid.uuid4()) - audit_other_id = str(uuid.uuid4()) - outbox_target_id = str(uuid.uuid4()) - outbox_other_id = str(uuid.uuid4()) - - async with db_session_factory() as session: - # Target tenant data - session.add( - ScoringRun( - id=run_target_id, - tenant_id=tenant_target, - subject_ref="lead-target", - scoring_config_version="1.0.0", - idempotency_key="run-target", - payload_hash="hash-target", - status="completed", - snapshot_data=_sample_snapshot_with_pii(), - created_at=fixed_now, - updated_at=fixed_now, - ) - ) - session.add( - ReviewAudit( - id=audit_target_id, - run_id=run_target_id, - tenant_id=tenant_target, - decision="approve", - actor_id="agent_target", - actor_role="agent", - idempotency_key="audit-target", - payload_hash="hash-audit-target", - previous_version=1, - new_version=2, - created_at=fixed_now, - ) - ) - session.add( - OutboxEvent( - id=outbox_target_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id=run_target_id, - payload={"tenant_id": tenant_target, "subject": "lead-target"}, - occurred_at=fixed_now, - delivery_status="pending", - ) - ) - - # Other tenant data (must be preserved) - session.add( - ScoringRun( - id=run_other_id, - tenant_id=tenant_other, - subject_ref="lead-other", - scoring_config_version="1.0.0", - idempotency_key="run-other", - payload_hash="hash-other", - status="completed", - snapshot_data=_sample_snapshot_with_pii(), - created_at=fixed_now, - updated_at=fixed_now, - ) - ) - session.add( - ReviewAudit( - id=audit_other_id, - run_id=run_other_id, - tenant_id=tenant_other, - decision="approve", - actor_id="agent_other", - actor_role="agent", - idempotency_key="audit-other", - payload_hash="hash-audit-other", - previous_version=1, - new_version=2, - created_at=fixed_now, - ) - ) - session.add( - OutboxEvent( - id=outbox_other_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id=run_other_id, - payload={"tenant_id": tenant_other, "subject": "lead-other"}, - occurred_at=fixed_now, - delivery_status="pending", - ) - ) - await session.commit() - - purge_result: TenantPurgeResult = await purge_tenant_data( - db_session_factory, - tenant_id=tenant_target, - clock=lambda: fixed_now, - ) - - assert purge_result.tenant_id == tenant_target - assert purge_result.runs_deleted == 1 - assert purge_result.audits_deleted == 1 - assert purge_result.outbox_events_deleted == 1 - - # Verify target tenant data is 100% gone - async with db_session_factory() as session: - target_runs = ( - (await session.execute(select(ScoringRun).where(ScoringRun.tenant_id == tenant_target))) - .scalars() - .all() - ) - target_audits = ( - ( - await session.execute( - select(ReviewAudit).where(ReviewAudit.tenant_id == tenant_target) - ) - ) - .scalars() - .all() - ) - target_events = ( - (await session.execute(select(OutboxEvent).where(OutboxEvent.run_id == run_target_id))) - .scalars() - .all() - ) - - assert len(target_runs) == 0 - assert len(target_audits) == 0 - assert len(target_events) == 0 - - # Verify other tenant data is 100% preserved - other_run = ( - await session.execute(select(ScoringRun).where(ScoringRun.tenant_id == tenant_other)) - ).scalar_one() - other_audit = ( - await session.execute(select(ReviewAudit).where(ReviewAudit.tenant_id == tenant_other)) - ).scalar_one() - other_event = ( - await session.execute(select(OutboxEvent).where(OutboxEvent.run_id == run_other_id)) - ).scalar_one() - - assert other_run.id == run_other_id - assert other_audit.id == audit_other_id - assert other_event.id == outbox_other_id - - -@pytest.mark.asyncio -async def test_tenant_offboarding_idempotency_and_nonexistent_tenant( - db_session_factory: async_sessionmaker[AsyncSession], -): - fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - - # Purge non-existent tenant - res_none = await purge_tenant_data( - db_session_factory, - tenant_id="non_existent_tenant_xyz", - clock=lambda: fixed_now, - ) - assert res_none.runs_deleted == 0 - assert res_none.audits_deleted == 0 - assert res_none.outbox_events_deleted == 0 - - -@pytest.mark.asyncio -async def test_retention_and_offboarding_failure_signal_sanitizes_pii( - db_session_factory: async_sessionmaker[AsyncSession], - caplog: pytest.LogCaptureFixture, -): - # Test that errors in retention or purge do not log sensitive values - sentinel_pii = "SECRET_SENTINEL_PII_DATA_12345" - - class FailingSessionFactory: - def __call__(self): - raise RuntimeError(f"Database crash with payload context: {sentinel_pii}") - - caplog.set_level(logging.ERROR) - - with pytest.raises(RuntimeError): - await execute_retention_cleanup( - cast(async_sessionmaker[AsyncSession], FailingSessionFactory()), - ) - - for record in caplog.records: - assert sentinel_pii not in record.message - - -@pytest.mark.asyncio -async def test_retention_redacts_ai_metadata_detail_free_text( - db_session_factory: async_sessionmaker[AsyncSession], -): - fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - old_time = fixed_now - timedelta(days=95) - - run_fail_id = str(uuid.uuid4()) - run_success_id = str(uuid.uuid4()) - - async with db_session_factory() as session: - session.add_all( - [ - ScoringRun( - id=run_fail_id, - tenant_id="tenant_ai_meta", - subject_ref="lead_fail", - scoring_config_version="1.0.0", - idempotency_key="fail-key", - payload_hash="hash-fail", - status="review_required", - snapshot_data=_sample_snapshot_with_pii(), - rule_score=60, - ai_score=None, - final_score=60, - effective_score=None, - degraded=True, - error_code="aihub_timeout", - ai_metadata={ - "error_code": "aihub_timeout", - "detail": "Timeout with AIHub prompt containing customer private data", - "fallback_policy": "rules_only_review", - }, - created_at=old_time, - updated_at=old_time, - ), - ScoringRun( - id=run_success_id, - tenant_id="tenant_ai_meta", - subject_ref="lead_succ", - scoring_config_version="1.0.0", - idempotency_key="succ-key", - payload_hash="hash-succ", - status="completed", - snapshot_data=_sample_snapshot_with_pii(), - rule_score=80, - ai_score=90, - final_score=85, - effective_score=85, - degraded=False, - ai_metadata={ - "model": "claude-3-5-sonnet", - "effort_profile": "medium", - "pass_count": 2, - }, - created_at=old_time, - updated_at=old_time, - ), - ] - ) - await session.commit() - - result = await execute_retention_cleanup(db_session_factory, clock=lambda: fixed_now) - assert result.runs_redacted == 2 - - async with db_session_factory() as session: - r_fail = ( - await session.execute(select(ScoringRun).where(ScoringRun.id == run_fail_id)) - ).scalar_one() - r_succ = ( - await session.execute(select(ScoringRun).where(ScoringRun.id == run_success_id)) - ).scalar_one() - - # Failed run: detail in ai_metadata must be redacted to "redacted" - assert r_fail.ai_metadata is not None - assert r_fail.ai_metadata["detail"] == "redacted" - assert r_fail.ai_metadata["error_code"] == "aihub_timeout" - assert r_fail.ai_metadata["fallback_policy"] == "rules_only_review" - - # Success run: structural non-free-text metadata preserved - assert r_succ.ai_metadata == { - "model": "claude-3-5-sonnet", - "effort_profile": "medium", - "pass_count": 2, - } - - -@pytest.mark.asyncio -async def test_retention_respects_batch_size_processing( - db_session_factory: async_sessionmaker[AsyncSession], -): - fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - old_time = fixed_now - timedelta(days=100) - - run_ids = [str(uuid.uuid4()) for _ in range(5)] - - async with db_session_factory() as session: - for idx, rid in enumerate(run_ids): - session.add( - ScoringRun( - id=rid, - tenant_id="tenant_batch", - subject_ref=f"lead_batch_{idx}", - scoring_config_version="1.0.0", - idempotency_key=f"batch-key-{idx}", - payload_hash=f"hash-{idx}", - status="completed", - snapshot_data=_sample_snapshot_with_pii(), - created_at=old_time, - updated_at=old_time, - ) - ) - await session.commit() - - # Call 1 with batch_size=2: exactly 2 items redacted - res1 = await execute_retention_cleanup( - db_session_factory, - clock=lambda: fixed_now, - batch_size=2, - ) - assert res1.runs_redacted == 2 - - # Call 2 with batch_size=2: next 2 items redacted - res2 = await execute_retention_cleanup( - db_session_factory, - clock=lambda: fixed_now, - batch_size=2, - ) - assert res2.runs_redacted == 2 - - # Call 3 with batch_size=2: last 1 item redacted - res3 = await execute_retention_cleanup( - db_session_factory, - clock=lambda: fixed_now, - batch_size=2, - ) - assert res3.runs_redacted == 1 - - # Call 4: 0 items remaining - res4 = await execute_retention_cleanup( - db_session_factory, - clock=lambda: fixed_now, - batch_size=2, - ) - assert res4.runs_redacted == 0 - - async with db_session_factory() as session: - runs = ( - ( - await session.execute( - select(ScoringRun).where(ScoringRun.tenant_id == "tenant_batch") - ) - ) - .scalars() - .all() - ) - assert len(runs) == 5 - for r in runs: - assert r.snapshot_data == {} - assert r.payload_hash == "redacted" - assert r.redacted_at is not None - - -@pytest.mark.asyncio -async def test_tenant_offboarding_purges_orphan_outbox_events( - db_session_factory: async_sessionmaker[AsyncSession], -): - fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - - target_tenant = "tenant_orphan_test" - other_tenant = "tenant_other_preserve" - - orphan_target_event_id = str(uuid.uuid4()) - orphan_other_event_id = str(uuid.uuid4()) - - async with db_session_factory() as session: - # Orphan outbox event for target tenant (run_id does not exist in scoring_runs) - session.add( - OutboxEvent( - id=orphan_target_event_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id=str(uuid.uuid4()), - payload={"tenant_id": target_tenant, "subject": "lead-orphan"}, - occurred_at=fixed_now, - delivery_status="pending", - ) - ) - # Orphan outbox event for other tenant (must be preserved) - session.add( - OutboxEvent( - id=orphan_other_event_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id=str(uuid.uuid4()), - payload={"tenant_id": other_tenant, "subject": "lead-preserve"}, - occurred_at=fixed_now, - delivery_status="pending", - ) - ) - await session.commit() - - purge_res = await purge_tenant_data( - db_session_factory, - tenant_id=target_tenant, - clock=lambda: fixed_now, - ) - - assert purge_res.tenant_id == target_tenant - assert purge_res.runs_deleted == 0 - assert purge_res.outbox_events_deleted == 1 - - async with db_session_factory() as session: - target_ev = ( - await session.execute( - select(OutboxEvent).where(OutboxEvent.id == orphan_target_event_id) - ) - ).scalar_one_or_none() - other_ev = ( - await session.execute( - select(OutboxEvent).where(OutboxEvent.id == orphan_other_event_id) - ) - ).scalar_one_or_none() - - assert target_ev is None - assert other_ev is not None - assert other_ev.id == orphan_other_event_id - - -@pytest.mark.asyncio -async def test_retention_sanitizes_effective_config_and_ai_metadata_sentinels( - db_session_factory: async_sessionmaker[AsyncSession], -): - fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - old_time = fixed_now - timedelta(days=95) - - run_id = str(uuid.uuid4()) - sentinel_config_secret = "SENTINEL_CONFIG_SECRET_KEY_999" - sentinel_ai_prompt = "SENTINEL_AI_PROMPT_SECRET_888" - - async with db_session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant_sentinel_test", - subject_ref="lead_sentinel", - scoring_config_version="1.0.0", - idempotency_key="sentinel-key", - payload_hash="hash-sentinel", - status="completed", - snapshot_data=_sample_snapshot_with_pii(), - effective_config={ - "version": "1.0.0", - "rule_weight": 0.5, - "ai_weight": 0.5, - "confidence_threshold": 0.8, - "sentinel_custom_property": sentinel_config_secret, - }, - ai_metadata={ - "model": "claude-3-5-sonnet", - "effort_profile": "medium", - "pass_count": 2, - "sentinel_custom_prompt_dump": sentinel_ai_prompt, - }, - rule_score=80, - ai_score=90, - final_score=85, - effective_score=85, - created_at=old_time, - updated_at=old_time, - ) - ) - await session.commit() - - result = await execute_retention_cleanup(db_session_factory, clock=lambda: fixed_now) - assert result.runs_redacted == 1 - - async with db_session_factory() as session: - run = ( - await session.execute(select(ScoringRun).where(ScoringRun.id == run_id)) - ).scalar_one() - - assert run.effective_config is not None - assert "sentinel_custom_property" not in run.effective_config - assert run.effective_config == { - "version": "1.0.0", - "rule_weight": 0.5, - "ai_weight": 0.5, - "confidence_threshold": 0.8, - } - - assert run.ai_metadata is not None - assert "sentinel_custom_prompt_dump" not in run.ai_metadata - assert run.ai_metadata == { - "model": "claude-3-5-sonnet", - "effort_profile": "medium", - "pass_count": 2, - } - - -@pytest.mark.asyncio -async def test_retention_redacts_reviewer_identity_in_pending_reviewed_outbox_events( - db_session_factory: async_sessionmaker[AsyncSession], -): - fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - old_time = fixed_now - timedelta(days=95) - - ev_id = str(uuid.uuid4()) - secret_reviewer = "secret_reviewer_identity_12345" - - async with db_session_factory() as session: - session.add( - OutboxEvent( - id=ev_id, - event_type="lead_scoring.run.reviewed", - event_version="1.0.0", - run_id=str(uuid.uuid4()), - payload={ - "event_id": ev_id, - "event_type": "lead_scoring.run.reviewed", - "tenant_id": "tenant_rev_test", - "data": { - "status": "completed", - "decision": "approve", - "version": 2, - "reviewed_by": secret_reviewer, - }, - }, - occurred_at=old_time, - delivery_status="pending", - ) - ) - await session.commit() - - result = await execute_retention_cleanup(db_session_factory, clock=lambda: fixed_now) - assert result.outbox_events_cleaned == 0 # Pending event not deleted - - async with db_session_factory() as session: - ev = ( - await session.execute(select(OutboxEvent).where(OutboxEvent.id == ev_id)) - ).scalar_one() - assert ev.delivery_status == "pending" - assert ev.payload["data"]["reviewed_by"] == "redacted" - assert secret_reviewer not in str(ev.payload) diff --git a/old_repo/tests/test_rule_scoring.py b/old_repo/tests/test_rule_scoring.py deleted file mode 100644 index 496ddd1..0000000 --- a/old_repo/tests/test_rule_scoring.py +++ /dev/null @@ -1,145 +0,0 @@ -from lead_scoring.schemas.snapshot import CanonicalSnapshot -from lead_scoring.scoring import ( - BehavioralMetric, - RuleMetric, - ScoringConfig, - ScoringRule, - compute_behavioral_metrics, - evaluate_rule_score, - extract_normalized_features, -) - - -def make_snapshot() -> CanonicalSnapshot: - return CanonicalSnapshot.model_validate( - { - "captured_at": "2026-08-27T12:00:00Z", - "lead_profile": { - "ems_lead_id": "lead-1", - "name": "Lead One", - "phone": "+84900000000", - }, - "conversations": [ - { - "conversation_id": "conversation-1", - "messages": [ - { - "message_id": "message-1", - "sender_type": "contact", - "content": "Tôi cần tư vấn", - "sent_at": "2026-08-27T11:00:00Z", - } - ], - } - ], - } - ) - - -def test_rule_score_is_deterministic_and_bounded_by_immutable_config() -> None: - snapshot = make_snapshot() - config = ScoringConfig( - version="fixture-1", - base_score=95, - confidence=0.75, - confidence_threshold=0.8, - hitl_policy_version="fixture-policy-1", - rules=( - ScoringRule(metric=RuleMetric.PROFILE_FIELD_COUNT, minimum=2, points=20), - ScoringRule(metric=RuleMetric.MESSAGE_COUNT, minimum=2, points=-10), - ), - ) - - assert evaluate_rule_score(snapshot, config) == 100 - assert evaluate_rule_score(snapshot, config) == 100 - assert config.model_config.get("frozen") is True - - -def test_rule_score_clamps_negative_total_to_zero() -> None: - config = ScoringConfig( - version="fixture-2", - confidence=0.5, - confidence_threshold=0.8, - hitl_policy_version="fixture-policy-2", - rules=(ScoringRule(metric=RuleMetric.CONVERSATION_COUNT, minimum=1, points=-100),), - ) - - assert evaluate_rule_score(make_snapshot(), config) == 0 - - -def test_compute_behavioral_metrics_deterministic_and_selective() -> None: - snapshot = CanonicalSnapshot.model_validate( - { - "captured_at": "2026-08-27T12:00:00Z", - "lead_profile": { - "ems_lead_id": "lead-behavioral-1", - "name": "Test Lead", - }, - "conversations": [ - { - "conversation_id": "conv-1", - "messages": [ - { - "message_id": "m1", - "sender_type": "agent", - "content": "Chào anh", - "sent_at": "2026-08-27T08:00:00Z", - }, - { - "message_id": "m2", - "sender_type": "contact", - "content": "Tôi muốn đăng ký", - "sent_at": "2026-08-27T08:05:00Z", - }, - { - "message_id": "m3", - "sender_type": "agent", - "content": "Vâng ạ", - "sent_at": "2026-08-27T08:06:00Z", - }, - ], - }, - { - "conversation_id": "conv-2", - "messages": [ - { - "message_id": "m4", - "sender_type": "contact", - "content": "Hôm nay tôi muốn hỏi thêm học phí", - "sent_at": "2026-08-27T10:00:00Z", - }, - ], - }, - ], - } - ) - - all_metrics = compute_behavioral_metrics(snapshot) - assert all_metrics["message_count"] == 4 - assert all_metrics["conversation_count"] == 2 - assert ( - all_metrics["conversation_turn_count"] == 4 - ) # conv1: 3 turns (agent->contact->agent) + conv2: 1 turn (contact) = 4 - assert all_metrics["customer_message_count"] == 2 - assert all_metrics["customer_initiated_return"] is True - - selected_metrics = compute_behavioral_metrics( - snapshot, - [ - BehavioralMetric.CONVERSATION_TURN_COUNT, - BehavioralMetric.CUSTOMER_INITIATED_RETURN, - ], - ) - assert set(selected_metrics.keys()) == { - "conversation_turn_count", - "customer_initiated_return", - } - assert selected_metrics["conversation_turn_count"] == 4 - assert selected_metrics["customer_initiated_return"] is True - - features = extract_normalized_features(snapshot) - assert features.message_count == 4 - assert features.conversation_count == 2 - assert features.conversation_turn_count == 4 - assert features.customer_message_count == 2 - assert features.customer_initiated_return is True diff --git a/old_repo/tests/test_scoring_runs_api.py b/old_repo/tests/test_scoring_runs_api.py deleted file mode 100644 index be8498e..0000000 --- a/old_repo/tests/test_scoring_runs_api.py +++ /dev/null @@ -1,1787 +0,0 @@ -import asyncio -import json -import logging -import uuid -from collections.abc import AsyncIterator -from datetime import UTC, datetime, timedelta -from pathlib import Path -from typing import Any - -import httpx -import pytest -from alembic import command -from alembic.config import Config -from httpx import ASGITransport, AsyncClient -from pydantic import SecretStr, ValidationError -from sqlalchemy import select, text -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine - -from lead_scoring.aihub.client import AIHubClient -from lead_scoring.api.v1.scoring_runs import _to_scoring_run_response -from lead_scoring.config import ServiceCallerConfig, Settings -from lead_scoring.db.models.outbox_event import OutboxEvent -from lead_scoring.db.models.scoring_run import ScoringRun -from lead_scoring.main import create_app -from lead_scoring.scoring import EffortProfile, ScoringConfig - - -def get_alembic_config(database_url: str) -> Config: - root_dir = Path(__file__).resolve().parent.parent - ini_path = root_dir / "alembic.ini" - alembic_cfg = Config(str(ini_path)) - alembic_cfg.attributes["sqlalchemy_url"] = database_url - return alembic_cfg - - -ALL_TEST_TENANTS = [ - "tenant_test_1", - "tenant_p1", - "tenant_replay_1", - "tenant_conflict_1", - "tenant_race_1", - "tenant_race_2", - "tenant_query_1", - "tenant_iso_1", - "tenant_iso_2", - "tenant_alpha", - "tenant_beta", - "tenant_sentinel_1", - "tenant_sentinel_leak", - "tenant_norm_1", - "tenant_allow_1", - "tenant_allow_2", - "allowed_tenant_only", - "t" * 255, -] - - -@pytest.fixture -async def migrated_postgres_app( - temp_postgres_db: str, -) -> AsyncIterator[tuple[Any, Settings, str]]: - alembic_cfg = get_alembic_config(temp_postgres_db) - await asyncio.to_thread(command.upgrade, alembic_cfg, "head") - - settings = Settings( - database_url=temp_postgres_db, - worker_lease_seconds=40.0, - service_callers=[ - ServiceCallerConfig( - caller_id="omni-caller", - token=SecretStr("valid-omni-token"), - allowed_apps=["omni", "captain"], - allowed_features=["lead-scoring"], - allowed_tenants=["*"], - ), - ServiceCallerConfig( - caller_id="scoped-caller", - token=SecretStr("scoped-tenant-token"), - allowed_apps=["omni"], - allowed_features=["lead-scoring"], - allowed_tenants=["tenant_allow_1"], - ), - ], - tenant_allowlist=list(ALL_TEST_TENANTS), - scoring_configs=[ - ScoringConfig( - version="1.0.0", - confidence=1.0, - confidence_threshold=0.8, - hitl_policy_version="1.0.0", - ), - ScoringConfig( - version="v" * 64, - confidence=1.0, - confidence_threshold=0.8, - hitl_policy_version="1.0.0", - ), - ScoringConfig( - version="light-config", - confidence=0.9, - confidence_threshold=0.8, - hitl_policy_version="1.0.0", - effort_profile=EffortProfile.LIGHT, - ), - ScoringConfig( - version="heavy-config", - confidence=0.9, - confidence_threshold=0.8, - hitl_policy_version="1.0.0", - effort_profile=EffortProfile.HEAVY, - ), - ], - ) - - captured_aihub_headers: list[dict[str, str]] = [] - - def aihub_handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/v1/chat/completions" - captured_aihub_headers.append(dict(request.headers)) - body = json.loads(request.content.decode("utf-8")) - schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") - if schema_name == "light_lead_scoring": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 82, - "confidence": 0.90, - "reasoning": "Light score", - "positive_factors": ["signal"], - "negative_factors": [], - } - ) - } - } - ] - }, - ) - elif schema_name == "evidence_extraction": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "summary": "Customer inquiry", - "intent": "high", - "key_signals": ["valid inquiry"], - } - ) - } - } - ] - }, - ) - elif schema_name == "evidence_critique": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "critique_summary": "Critique summary", - "risks": [], - "strengths": ["Strong signal"], - } - ) - } - } - ] - }, - ) - elif schema_name == "lead_scoring" or schema_name == "heavy_lead_scoring": - return httpx.Response( - 200, - json={ - "choices": [ - { - "message": { - "content": json.dumps( - { - "score": 85, - "confidence": 0.95, - "reasoning": "Strong match", - "positive_factors": ["clear intent"], - "negative_factors": [], - } - ) - } - } - ] - }, - ) - return httpx.Response(404) - - mock_transport = httpx.MockTransport(aihub_handler) - mock_http_client = httpx.AsyncClient(transport=mock_transport) - test_aihub_client = AIHubClient(base_url="https://aihub.test", http_client=mock_http_client) - - app = create_app(settings=settings, aihub_client=test_aihub_client) - app.state.captured_aihub_headers = captured_aihub_headers - yield app, settings, temp_postgres_db - - await mock_http_client.aclose() - - engine = create_async_engine(temp_postgres_db) - async with engine.begin() as conn: - await conn.execute( - text("TRUNCATE TABLE review_audits, outbox_events, scoring_runs CASCADE;") - ) - await engine.dispose() - - -def make_valid_snapshot_payload( - tenant_id: str = "tenant_test_1", - subject_ref: str = "ems_lead_1001", - scoring_config_version: str = "1.0.0", - message_content: str = "Xin chao, toi muon hoi ve khoa hoc DotB", - captured_at: str = "2026-08-27T12:00:00Z", - sent_at: str = "2026-08-27T10:00:00Z", - sender_type: str = "contact", -) -> dict[str, Any]: - return { - "tenant_id": tenant_id, - "subject_ref": subject_ref, - "scoring_config_version": scoring_config_version, - "snapshot": { - "version": "1.0.0", - "scope": "linked_contact_history", - "captured_at": captured_at, - "lead_profile": { - "ems_lead_id": subject_ref, - "name": "Nguyen Van A", - "phone": "+84901234567", - "email": "nva@example.com", - "status": "New", - "custom_fields": {"course_interest": "DotB Course"}, - }, - "conversations": [ - { - "conversation_id": "conv_1", - "channel": "zalo", - "assigned_agent_id": "agent_10", - "messages": [ - { - "message_id": "msg_1", - "sender_type": sender_type, - "content": message_content, - "sent_at": sent_at, - } - ], - } - ], - "identity_context": { - "match_quality": "phone_only", - "contact_id": "cnt_1001", - }, - "trigger_metadata": { - "trigger_type": "manual_omni", - "trigger_conversation_id": "conv_1", - "actor_id": "user_10", - }, - "hitl_policy": { - "confidence_threshold": 0.80, - "policy_version": "1.0.0", - }, - }, - } - - -async def wait_for_terminal_run(client: AsyncClient, run_id: str, tenant_id: str) -> dict[str, Any]: - for _ in range(50): - response = await client.get( - f"/v1/scoring-runs/{run_id}", - headers={ - "Authorization": "Bearer valid-omni-token", - "X-Tenant-Id": tenant_id, - }, - ) - data = response.json() - if data["status"] in {"completed", "review_required", "failed"}: - return data - await asyncio.sleep(0.01) - pytest.fail("Scoring run did not reach terminal state") - - -def make_valid_headers( - token: str = "valid-omni-token", - vkey: str = "vkey_secret_abc123", - idempotency_key: str | None = None, - app: str = "omni", - feature: str = "lead-scoring", -) -> dict[str, str]: - return { - "Authorization": f"Bearer {token}", - "X-Tenant-VKey": vkey, - "Idempotency-Key": idempotency_key or str(uuid.uuid4()), - "X-DotB-App": app, - "X-DotB-Feature": feature, - } - - -@pytest.mark.asyncio -async def test_auth_missing_token_returns_401(migrated_postgres_app): - app, _, _ = migrated_postgres_app - payload = make_valid_snapshot_payload() - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers() - del headers["Authorization"] - response = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert response.status_code == 401 - data = response.json() - assert data["error_code"] == "invalid_token" - assert data["detail"] == "Invalid or missing service token" - - -@pytest.mark.asyncio -async def test_auth_invalid_token_returns_401(migrated_postgres_app): - app, _, _ = migrated_postgres_app - payload = make_valid_snapshot_payload() - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers(token="unknown-unregistered-token") - response = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert response.status_code == 401 - assert response.json()["error_code"] == "invalid_token" - - -@pytest.mark.asyncio -async def test_auth_unauthorized_app_or_feature_returns_403(migrated_postgres_app): - app, _, _ = migrated_postgres_app - payload = make_valid_snapshot_payload() - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers_wrong_app = make_valid_headers(app="unauthorized-app") - res_app = await client.post("/v1/scoring-runs", json=payload, headers=headers_wrong_app) - assert res_app.status_code == 403 - assert res_app.json()["error_code"] == "forbidden" - assert res_app.json()["detail"] == "Caller is not authorized for requested application" - - headers_wrong_feat = make_valid_headers(feature="unauthorized-feature") - res_feat = await client.post( - "/v1/scoring-runs", json=payload, headers=headers_wrong_feat - ) - assert res_feat.status_code == 403 - assert res_feat.json()["error_code"] == "forbidden" - assert res_feat.json()["detail"] == "Caller is not authorized for requested feature" - - -@pytest.mark.asyncio -async def test_auth_tenant_allowlist_enforcement_returns_403(migrated_postgres_app): - app, settings, _ = migrated_postgres_app - settings.tenant_allowlist = ["allowed_tenant_only"] - payload = make_valid_snapshot_payload(tenant_id="forbidden_tenant") - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers() - response = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert response.status_code == 403 - data = response.json() - assert data["error_code"] == "forbidden" - assert data["detail"] == "Tenant is not in pilot allowlist" - - -@pytest.mark.asyncio -async def test_caller_tenant_restriction_returns_403(migrated_postgres_app): - app, _, _ = migrated_postgres_app - payload = make_valid_snapshot_payload(tenant_id="tenant_allow_2") - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers(token="scoped-tenant-token") - response = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert response.status_code == 403 - assert response.json()["error_code"] == "forbidden" - assert response.json()["detail"] == "Caller is not authorized for requested tenant" - - -@pytest.mark.asyncio -async def test_settings_fail_closed_and_secret_token_redaction(): - empty_settings = Settings(service_callers=[], tenant_allowlist=[]) - assert empty_settings.service_callers == [] - assert empty_settings.tenant_allowlist == [] - - secret_val = "very-secret-token-xyz" - custom_settings = Settings( - service_callers=[ - ServiceCallerConfig( - caller_id="test", - token=SecretStr(secret_val), - allowed_apps=["omni"], - allowed_features=["lead-scoring"], - allowed_tenants=["*"], - ) - ] - ) - assert secret_val not in str(custom_settings) - assert secret_val not in repr(custom_settings) - assert secret_val not in repr(custom_settings.service_callers[0]) - - -def test_settings_rejects_wildcard_tenant_allowlist(): - with pytest.raises(ValidationError) as exc_info: - Settings(tenant_allowlist=["*"]) - assert "Wildcard '*' is prohibited" in str(exc_info.value) - - -@pytest.mark.parametrize("legacy_status", ["review_required", "superseded"]) -def test_polling_response_preserves_historical_status(legacy_status: str): - now = datetime(2026, 9, 14, tzinfo=UTC) - run = ScoringRun( - id=str(uuid.uuid4()), - tenant_id="tenant_test_1", - subject_ref="lead-historical", - scoring_config_version="legacy-v1", - idempotency_key="historical-key", - payload_hash="0" * 64, - status=legacy_status, - snapshot_data={}, - version=1, - created_at=now, - updated_at=now, - ) - - assert _to_scoring_run_response(run).status.value == legacy_status - - -def test_settings_rejects_duplicate_caller_id_or_token(): - # Duplicate caller_id - with pytest.raises(ValidationError) as exc_info: - Settings( - service_callers=[ - ServiceCallerConfig( - caller_id="dup_id", - token=SecretStr("token1"), - allowed_apps=["omni"], - allowed_features=["lead-scoring"], - allowed_tenants=["*"], - ), - ServiceCallerConfig( - caller_id="dup_id", - token=SecretStr("token2"), - allowed_apps=["omni"], - allowed_features=["lead-scoring"], - allowed_tenants=["*"], - ), - ] - ) - assert "Duplicate caller_id 'dup_id'" in str(exc_info.value) - - # Duplicate raw token (must not leak raw secret in error message) - raw_dup = "duplicate-secret-token" - with pytest.raises(ValidationError) as exc_info: - Settings( - service_callers=[ - ServiceCallerConfig( - caller_id="id1", - token=SecretStr(raw_dup), - allowed_apps=["omni"], - allowed_features=["lead-scoring"], - allowed_tenants=["*"], - ), - ServiceCallerConfig( - caller_id="id2", - token=SecretStr(raw_dup), - allowed_apps=["omni"], - allowed_features=["lead-scoring"], - allowed_tenants=["*"], - ), - ] - ) - assert "Duplicate service token configured" in str(exc_info.value) - assert raw_dup not in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_contract_max_length_boundaries_and_over_boundary_rejection( - migrated_postgres_app, -): - app, _, db_url = migrated_postgres_app - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - # 1. Exact max length boundaries (255 for tenant_id, 64 for config_version) - boundary_tenant = "t" * 255 - boundary_subject = "s" * 255 - boundary_config_ver = "v" * 64 - boundary_idem_key = "k" * 255 - - boundary_payload = make_valid_snapshot_payload( - tenant_id=boundary_tenant, - subject_ref=boundary_subject, - ) - boundary_payload["scoring_config_version"] = boundary_config_ver - - headers = make_valid_headers(idempotency_key=boundary_idem_key) - resp = await client.post("/v1/scoring-runs", json=boundary_payload, headers=headers) - assert resp.status_code == 202 - run_id = resp.json()["run_id"] - - # GET boundary X-Tenant-Id (255 chars) - get_resp = await client.get( - f"/v1/scoring-runs/{run_id}", - headers={ - "Authorization": "Bearer valid-omni-token", - "X-Tenant-Id": boundary_tenant, - }, - ) - assert get_resp.status_code == 200 - - # 2. Over boundary cases (256 chars for tenant/idempotency, 65 for version) - # tenant_id over 255 - over_tenant_payload = make_valid_snapshot_payload(tenant_id="t" * 256) - resp_over_tenant = await client.post( - "/v1/scoring-runs", json=over_tenant_payload, headers=headers - ) - assert resp_over_tenant.status_code == 422 - assert resp_over_tenant.json()["error_code"] == "validation_error" - - # subject_ref over 255 - over_subject_payload = make_valid_snapshot_payload(subject_ref="s" * 256) - resp_over_sub = await client.post( - "/v1/scoring-runs", json=over_subject_payload, headers=headers - ) - assert resp_over_sub.status_code == 422 - assert resp_over_sub.json()["error_code"] == "validation_error" - - # config_version over 64 - over_ver_payload = make_valid_snapshot_payload() - over_ver_payload["scoring_config_version"] = "v" * 65 - resp_over_ver = await client.post( - "/v1/scoring-runs", json=over_ver_payload, headers=headers - ) - assert resp_over_ver.status_code == 422 - assert resp_over_ver.json()["error_code"] == "validation_error" - - # Idempotency-Key over 255 - headers_over_idem = make_valid_headers(idempotency_key="k" * 256) - resp_over_idem = await client.post( - "/v1/scoring-runs", - json=make_valid_snapshot_payload(), - headers=headers_over_idem, - ) - assert resp_over_idem.status_code == 422 - assert resp_over_idem.json()["error_code"] == "validation_error" - - # GET X-Tenant-Id over 255 - resp_over_get = await client.get( - f"/v1/scoring-runs/{run_id}", - headers={ - "Authorization": "Bearer valid-omni-token", - "X-Tenant-Id": "t" * 256, - }, - ) - assert resp_over_get.status_code == 422 - assert resp_over_get.json()["error_code"] == "validation_error" - - -@pytest.mark.asyncio -async def test_missing_required_headers_returns_422(migrated_postgres_app): - app, _, _ = migrated_postgres_app - payload = make_valid_snapshot_payload() - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - for required_header in [ - "Idempotency-Key", - "X-DotB-App", - "X-DotB-Feature", - ]: - headers = make_valid_headers() - del headers[required_header] - response = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert response.status_code == 422 - assert response.json()["error_code"] == "validation_error" - - -@pytest.mark.asyncio -async def test_missing_captured_at_returns_422(migrated_postgres_app): - app, _, _ = migrated_postgres_app - payload = make_valid_snapshot_payload() - del payload["snapshot"]["captured_at"] - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers() - response = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert response.status_code == 422 - assert response.json()["error_code"] == "validation_error" - assert "captured_at" in response.json()["detail"] - - -@pytest.mark.asyncio -async def test_structural_caps_max_conversations_exceeded_returns_422(migrated_postgres_app): - app, _, _ = migrated_postgres_app - payload = make_valid_snapshot_payload() - payload["snapshot"]["conversations"] = [ - { - "conversation_id": f"conv_{i}", - "messages": [ - { - "message_id": f"msg_{i}", - "sender_type": "contact", - "content": "hi", - "sent_at": "2026-08-27T10:00:00Z", - } - ], - } - for i in range(11) - ] - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers() - response = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert response.status_code == 422 - assert response.json()["error_code"] == "validation_error" - assert "exceeds maximum cap of 10" in response.json()["detail"] - - -@pytest.mark.asyncio -async def test_structural_caps_max_messages_exceeded_returns_422(migrated_postgres_app): - app, _, _ = migrated_postgres_app - payload = make_valid_snapshot_payload() - payload["snapshot"]["conversations"] = [ - { - "conversation_id": "conv_1", - "messages": [ - { - "message_id": f"msg_{i}", - "sender_type": "contact", - "content": f"msg {i}", - "sent_at": "2026-08-27T10:00:00Z", - } - for i in range(51) - ], - } - ] - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers() - response = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert response.status_code == 422 - assert response.json()["error_code"] == "validation_error" - assert "exceeds maximum cap of 50" in response.json()["detail"] - - -@pytest.mark.asyncio -async def test_structural_caps_max_characters_exceeded_returns_422(migrated_postgres_app): - app, _, _ = migrated_postgres_app - payload = make_valid_snapshot_payload() - payload["snapshot"]["conversations"] = [ - { - "conversation_id": "conv_1", - "messages": [ - { - "message_id": "msg_1", - "sender_type": "contact", - "content": "A" * 20001, - "sent_at": "2026-08-27T10:00:00Z", - } - ], - } - ] - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers() - response = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert response.status_code == 422 - assert response.json()["error_code"] == "validation_error" - assert "exceeds maximum cap of 20000" in response.json()["detail"] - - -@pytest.mark.asyncio -async def test_timeline_message_in_future_returns_422(migrated_postgres_app): - app, _, _ = migrated_postgres_app - payload = make_valid_snapshot_payload( - captured_at="2026-08-27T10:00:00Z", - sent_at="2026-08-27T11:00:00Z", - ) - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers() - response = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert response.status_code == 422 - assert response.json()["error_code"] == "validation_error" - assert "cannot be in future" in response.json()["detail"] - - -@pytest.mark.asyncio -async def test_timeline_message_older_than_90_days_returns_422(migrated_postgres_app): - app, _, _ = migrated_postgres_app - now = datetime.now(UTC) - captured = now.isoformat() - too_old_sent = (now - timedelta(days=91)).isoformat() - - payload = make_valid_snapshot_payload( - captured_at=captured, - sent_at=too_old_sent, - ) - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers() - response = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert response.status_code == 422 - assert response.json()["error_code"] == "validation_error" - assert "90-day retention" in response.json()["detail"] - - -@pytest.mark.asyncio -async def test_invalid_sender_type_returns_422(migrated_postgres_app): - app, _, _ = migrated_postgres_app - payload = make_valid_snapshot_payload(sender_type="system") - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers() - response = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert response.status_code == 422 - assert response.json()["error_code"] == "validation_error" - - -@pytest.mark.asyncio -async def test_successful_admission_persists_run_before_returning_202( - migrated_postgres_app, -): - app, _, db_url = migrated_postgres_app - payload = make_valid_snapshot_payload(tenant_id="tenant_p1", subject_ref="ems_lead_555") - idem_key = f"idem-{uuid.uuid4()}" - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers(idempotency_key=idem_key) - response = await client.post("/v1/scoring-runs", json=payload, headers=headers) - - assert response.status_code == 202 - data = response.json() - assert "run_id" in data - run_id = data["run_id"] - uuid.UUID(run_id) - assert data["status"] == "pending" - assert data["status_url"] == f"/v1/scoring-runs/{run_id}" - assert data["tenant_id"] == "tenant_p1" - assert data["subject_ref"] == "ems_lead_555" - assert data["scoring_config_version"] == "1.0.0" - terminal_data = await wait_for_terminal_run(client, run_id, "tenant_p1") - assert terminal_data["status"] == "completed" - - engine = create_async_engine(db_url) - async with AsyncSession(engine) as session: - stmt = select(ScoringRun).where(ScoringRun.id == run_id) - result = await session.execute(stmt) - persisted_run = result.scalar_one_or_none() - assert persisted_run is not None - assert persisted_run.id == run_id - assert persisted_run.tenant_id == "tenant_p1" - assert persisted_run.subject_ref == "ems_lead_555" - assert persisted_run.status == "completed" - assert persisted_run.idempotency_key == idem_key - assert persisted_run.dotb_app == "omni" - assert persisted_run.dotb_feature == "lead-scoring" - assert persisted_run.snapshot_data["lead_profile"]["ems_lead_id"] == "ems_lead_555" - event_result = await session.execute( - select(OutboxEvent).where(OutboxEvent.run_id == run_id) - ) - events = list(event_result.scalars()) - assert len(events) == 1 - assert events[0].payload["run_id"] == run_id - assert "vkey" not in str(events[0].payload).casefold() - await engine.dispose() - - # Verify AIHub invocation headers received x-bf-dim-app and x-bf-dim-feature - captured = app.state.captured_aihub_headers - assert len(captured) >= 1 - assert captured[0]["x-bf-dim-app"] == "omni" - assert captured[0]["x-bf-dim-feature"] == "lead-scoring" - assert "x-dotb-app" not in captured[0] - assert "x-dotb-feature" not in captured[0] - assert captured[0]["x-request-id"] == run_id - - -@pytest.mark.asyncio -async def test_admission_without_vkey_falls_back_to_rules_only_review( - migrated_postgres_app, -): - app, _, _ = migrated_postgres_app - payload = make_valid_snapshot_payload(tenant_id="tenant_p1", subject_ref="ems_lead_no_vkey") - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers() - del headers["X-Tenant-VKey"] - response = await client.post("/v1/scoring-runs", json=payload, headers=headers) - - assert response.status_code == 202 - run_id = response.json()["run_id"] - terminal = await wait_for_terminal_run(client, run_id, "tenant_p1") - - assert terminal["status"] == "completed" - assert terminal["rule_score"] == 0 - assert terminal["ai_score"] is None - assert terminal["final_score"] == terminal["rule_score"] - assert terminal["scoring_mode"] == "rules_only_fallback" - assert terminal["degraded"] is True - assert terminal["error_code"] == "aihub_vkey_missing" - assert terminal["ai_metadata"]["error_code"] == "aihub_vkey_missing" - assert terminal["ai_metadata"]["fallback_policy"] == "rules_only_review" - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - empty_vkey_headers = make_valid_headers(vkey="") - response = await client.post( - "/v1/scoring-runs", - json=make_valid_snapshot_payload( - tenant_id="tenant_p1", subject_ref="ems_lead_empty_vkey" - ), - headers=empty_vkey_headers, - ) - assert response.status_code == 202 - empty_terminal = await wait_for_terminal_run( - client, response.json()["run_id"], "tenant_p1" - ) - - assert empty_terminal["status"] == "completed" - assert empty_terminal["final_score"] == empty_terminal["rule_score"] - assert empty_terminal["degraded"] is True - assert empty_terminal["error_code"] == "aihub_vkey_missing" - - -@pytest.mark.asyncio -async def test_task_handoff_failure_preserves_config_and_terminalizes_run( - migrated_postgres_app, monkeypatch -): - app, _, db_url = migrated_postgres_app - payload = make_valid_snapshot_payload(tenant_id="tenant_p1", subject_ref="ems_lead_handoff") - - async with app.router.lifespan_context(app): - monkeypatch.setattr(app.state.execution_runtime, "schedule", lambda *args: False) - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - response = await client.post( - "/v1/scoring-runs", - json=payload, - headers=make_valid_headers(), - ) - assert response.status_code == 202 - run_id = response.json()["run_id"] - terminal = await wait_for_terminal_run(client, run_id, "tenant_p1") - assert terminal["status"] == "failed" - assert terminal["error_code"] == "worker_interrupted" - assert terminal["retryable"] is True - assert terminal["effective_config"] is None - assert terminal["confidence_threshold"] == 0.8 - - engine = create_async_engine(db_url) - async with AsyncSession(engine) as session: - run = await session.get(ScoringRun, run_id) - assert run is not None - assert run.effective_config is not None - assert run.effective_config["hitl_policy_version"] == "1.0.0" - events = list( - ( - await session.execute(select(OutboxEvent).where(OutboxEvent.run_id == run_id)) - ).scalars() - ) - assert len(events) == 1 - await engine.dispose() - - -@pytest.mark.asyncio -async def test_canonical_normalization_equivalent_timestamps_and_key_orders_replay_202( - migrated_postgres_app, -): - app, _, db_url = migrated_postgres_app - idem_key = f"idem-norm-{uuid.uuid4()}" - - # Payload 1 with ISO timestamp ending in Z - payload_1 = make_valid_snapshot_payload( - tenant_id="tenant_norm_1", - captured_at="2026-08-27T12:00:00Z", - sent_at="2026-08-27T10:00:00Z", - ) - - # Payload 2 with equivalent timezone offset +02:00 (14:00+02:00 is 12:00 UTC) - # and reversed top-level key order - payload_2 = { - "scoring_config_version": "1.0.0", - "subject_ref": "ems_lead_1001", - "tenant_id": "tenant_norm_1", - "snapshot": { - "trigger_metadata": { - "actor_id": "user_10", - "trigger_conversation_id": "conv_1", - "trigger_type": "manual_omni", - }, - "hitl_policy": { - "policy_version": "1.0.0", - "confidence_threshold": 0.80, - }, - "scope": "linked_contact_history", - "identity_context": { - "contact_id": "cnt_1001", - "match_quality": "phone_only", - }, - "captured_at": "2026-08-27T14:00:00+02:00", - "version": "1.0.0", - "lead_profile": { - "status": "New", - "email": "nva@example.com", - "phone": "+84901234567", - "name": "Nguyen Van A", - "ems_lead_id": "ems_lead_1001", - "custom_fields": {"course_interest": "DotB Course"}, - }, - "conversations": [ - { - "assigned_agent_id": "agent_10", - "channel": "zalo", - "conversation_id": "conv_1", - "messages": [ - { - "sent_at": "2026-08-27T12:00:00+02:00", - "content": "Xin chao, toi muon hoi ve khoa hoc DotB", - "sender_type": "contact", - "message_id": "msg_1", - } - ], - } - ], - }, - } - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers(idempotency_key=idem_key) - - resp1 = await client.post("/v1/scoring-runs", json=payload_1, headers=headers) - assert resp1.status_code == 202 - data1 = resp1.json() - - resp2 = await client.post("/v1/scoring-runs", json=payload_2, headers=headers) - assert resp2.status_code == 202 - data2 = resp2.json() - - assert data1["run_id"] == data2["run_id"] - - engine = create_async_engine(db_url) - async with AsyncSession(engine) as session: - stmt = select(ScoringRun).where( - ScoringRun.tenant_id == "tenant_norm_1", - ScoringRun.idempotency_key == idem_key, - ) - result = await session.execute(stmt) - rows = result.scalars().all() - assert len(rows) == 1 - await engine.dispose() - - -@pytest.mark.asyncio -async def test_idempotent_conflict_with_different_payload_returns_409( - migrated_postgres_app, -): - app, _, _ = migrated_postgres_app - payload1 = make_valid_snapshot_payload( - tenant_id="tenant_conflict_1", message_content="First payload content" - ) - payload2 = make_valid_snapshot_payload( - tenant_id="tenant_conflict_1", message_content="Second different payload content" - ) - idem_key = f"idem-conflict-{uuid.uuid4()}" - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers(idempotency_key=idem_key) - - resp1 = await client.post("/v1/scoring-runs", json=payload1, headers=headers) - assert resp1.status_code == 202 - - resp2 = await client.post("/v1/scoring-runs", json=payload2, headers=headers) - assert resp2.status_code == 409 - data2 = resp2.json() - assert data2["error_code"] == "idempotency_payload_mismatch" - assert "Idempotency key conflict" in data2["detail"] - - -@pytest.mark.asyncio -async def test_scoring_run_api_light_and_heavy_effort_profiles_execution( - migrated_postgres_app, -): - app, _, db_url = migrated_postgres_app - - # 1. Test Light profile execution via API - payload_light = make_valid_snapshot_payload( - tenant_id="tenant_p1", - subject_ref="ems_lead_light", - scoring_config_version="light-config", - ) - # 2. Test Heavy profile execution via API - payload_heavy = make_valid_snapshot_payload( - tenant_id="tenant_p1", - subject_ref="ems_lead_heavy", - scoring_config_version="heavy-config", - ) - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - resp_l = await client.post( - "/v1/scoring-runs", - json=payload_light, - headers=make_valid_headers(), - ) - assert resp_l.status_code == 202 - run_id_l = resp_l.json()["run_id"] - terminal_l = await wait_for_terminal_run(client, run_id_l, "tenant_p1") - assert terminal_l["status"] == "completed" - assert terminal_l["scoring_mode"] == "light_ai" - assert terminal_l["ai_metadata"]["pass_count"] == 1 - assert terminal_l["ai_metadata"]["effort_profile"] == "light" - - resp_h = await client.post( - "/v1/scoring-runs", - json=payload_heavy, - headers=make_valid_headers(), - ) - assert resp_h.status_code == 202 - run_id_h = resp_h.json()["run_id"] - terminal_h = await wait_for_terminal_run(client, run_id_h, "tenant_p1") - assert terminal_h["status"] == "completed" - assert terminal_h["scoring_mode"] == "heavy_ai" - assert terminal_h["ai_metadata"]["pass_count"] == 3 - assert terminal_h["ai_metadata"]["effort_profile"] == "heavy" - - -@pytest.mark.asyncio -async def test_concurrent_idempotent_admission_atomic_and_safe( - migrated_postgres_app, -): - app, _, db_url = migrated_postgres_app - payload = make_valid_snapshot_payload(tenant_id="tenant_race_1") - shared_idem_key = f"idem-race-{uuid.uuid4()}" - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers(idempotency_key=shared_idem_key) - - responses = await asyncio.gather( - *[client.post("/v1/scoring-runs", json=payload, headers=headers) for _ in range(10)] - ) - - for resp in responses: - assert resp.status_code == 202 - run_ids = {r.json()["run_id"] for r in responses} - assert len(run_ids) == 1 - - engine = create_async_engine(db_url) - async with AsyncSession(engine) as session: - stmt = select(ScoringRun).where( - ScoringRun.tenant_id == "tenant_race_1", - ScoringRun.idempotency_key == shared_idem_key, - ) - result = await session.execute(stmt) - rows = result.scalars().all() - assert len(rows) == 1 - await engine.dispose() - - -@pytest.mark.asyncio -async def test_concurrent_mismatched_payload_returns_409_without_500( - migrated_postgres_app, -): - app, _, _ = migrated_postgres_app - payload_a = make_valid_snapshot_payload( - tenant_id="tenant_race_2", message_content="Payload variant A" - ) - payload_b = make_valid_snapshot_payload( - tenant_id="tenant_race_2", message_content="Payload variant B" - ) - shared_idem_key = f"idem-race-mismatch-{uuid.uuid4()}" - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers(idempotency_key=shared_idem_key) - - responses = await asyncio.gather( - client.post("/v1/scoring-runs", json=payload_a, headers=headers), - client.post("/v1/scoring-runs", json=payload_b, headers=headers), - ) - - status_codes = [r.status_code for r in responses] - assert 202 in status_codes - assert 409 in status_codes - assert 500 not in status_codes - - -@pytest.mark.asyncio -async def test_list_scoring_runs_scoped_to_tenant(migrated_postgres_app): - app, _, _ = migrated_postgres_app - payload1 = make_valid_snapshot_payload( - tenant_id="tenant_query_1", subject_ref="ems_lead_list_1" - ) - payload2 = make_valid_snapshot_payload( - tenant_id="tenant_query_1", subject_ref="ems_lead_list_2" - ) - payload_other = make_valid_snapshot_payload( - tenant_id="tenant_iso_1", subject_ref="ems_lead_other" - ) - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - await client.post( - "/v1/scoring-runs", json=payload1, headers=make_valid_headers(idempotency_key="k1") - ) - await client.post( - "/v1/scoring-runs", json=payload2, headers=make_valid_headers(idempotency_key="k2") - ) - await client.post( - "/v1/scoring-runs", - json=payload_other, - headers=make_valid_headers(idempotency_key="k3"), - ) - - list_resp = await client.get( - "/v1/scoring-runs", - headers={ - "Authorization": "Bearer valid-omni-token", - "X-Tenant-Id": "tenant_query_1", - }, - ) - assert list_resp.status_code == 200 - assert list_resp.headers["x-total-count"] == "2" - data = list_resp.json() - assert isinstance(data, list) - assert len(data) == 2 - assert all(item["tenant_id"] == "tenant_query_1" for item in data) - assert {item["subject_ref"] for item in data} == {"ems_lead_list_1", "ems_lead_list_2"} - - paged_resp = await client.get( - "/v1/scoring-runs?limit=1&offset=1", - headers={ - "Authorization": "Bearer valid-omni-token", - "X-Tenant-Id": "tenant_query_1", - }, - ) - assert paged_resp.status_code == 200 - assert paged_resp.headers["x-total-count"] == "2" - paged_data = paged_resp.json() - assert len(paged_data) == 1 - assert paged_data[0]["subject_ref"] in {"ems_lead_list_1", "ems_lead_list_2"} - assert paged_data[0]["subject_ref"] != data[0]["subject_ref"] - - other_resp = await client.get( - "/v1/scoring-runs", - headers={ - "Authorization": "Bearer valid-omni-token", - "X-Tenant-Id": "tenant_iso_1", - }, - ) - assert other_resp.status_code == 200 - assert other_resp.headers["x-total-count"] == "1" - assert len(other_resp.json()) == 1 - - invalid_offset = await client.get( - "/v1/scoring-runs?offset=-1", - headers={ - "Authorization": "Bearer valid-omni-token", - "X-Tenant-Id": "tenant_query_1", - }, - ) - assert invalid_offset.status_code == 422 - - -@pytest.mark.asyncio -async def test_get_scoring_run_requires_x_tenant_id(migrated_postgres_app): - app, _, _ = migrated_postgres_app - payload = make_valid_snapshot_payload(tenant_id="tenant_query_1", subject_ref="ems_lead_q1") - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers() - post_resp = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert post_resp.status_code == 202 - run_id = post_resp.json()["run_id"] - - get_missing_tenant = await client.get( - f"/v1/scoring-runs/{run_id}", - headers={"Authorization": "Bearer valid-omni-token"}, - ) - assert get_missing_tenant.status_code == 422 - assert get_missing_tenant.json()["error_code"] == "validation_error" - - get_data = await wait_for_terminal_run(client, run_id, "tenant_query_1") - assert get_data["run_id"] == run_id - assert get_data["tenant_id"] == "tenant_query_1" - assert get_data["subject_ref"] == "ems_lead_q1" - assert get_data["status"] == "completed" - assert get_data["scoring_config_version"] == "1.0.0" - assert get_data["rule_score"] == 0 - assert get_data["ai_score"] == 85 - assert get_data["final_score"] == 51 - assert get_data["scoring_mode"] == "medium_ai" - assert get_data["degraded"] is False - assert get_data["confidence"] == 0.97 - assert get_data["confidence_threshold"] == 0.8 - assert get_data["effective_config"] is None - assert get_data["started_at"] is not None - assert get_data["completed_at"] is not None - assert "created_at" in get_data - assert "updated_at" in get_data - - -@pytest.mark.asyncio -async def test_get_scoring_run_tenant_isolation_and_allowlist(migrated_postgres_app): - app, settings, _ = migrated_postgres_app - payload = make_valid_snapshot_payload(tenant_id="tenant_iso_1", subject_ref="ems_lead_iso1") - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers() - post_resp = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert post_resp.status_code == 202 - run_id = post_resp.json()["run_id"] - - mismatch_resp = await client.get( - f"/v1/scoring-runs/{run_id}", - headers={ - "Authorization": "Bearer valid-omni-token", - "X-Tenant-Id": "tenant_iso_2", - }, - ) - assert mismatch_resp.status_code == 404 - assert mismatch_resp.json()["error_code"] == "not_found" - - restricted_resp = await client.get( - f"/v1/scoring-runs/{run_id}", - headers={ - "Authorization": "Bearer scoped-tenant-token", - "X-Tenant-Id": "tenant_iso_1", - }, - ) - assert restricted_resp.status_code == 403 - assert restricted_resp.json()["error_code"] == "forbidden" - - settings.tenant_allowlist = ["other_allowed_tenant"] - forbidden_resp = await client.get( - f"/v1/scoring-runs/{run_id}", - headers={ - "Authorization": "Bearer valid-omni-token", - "X-Tenant-Id": "tenant_iso_1", - }, - ) - assert forbidden_resp.status_code == 403 - assert forbidden_resp.json()["error_code"] == "forbidden" - - -@pytest.mark.asyncio -async def test_tenant_isolation_cross_tenant_idempotency_scoping( - migrated_postgres_app, -): - app, _, _ = migrated_postgres_app - payload_a = make_valid_snapshot_payload(tenant_id="tenant_alpha", subject_ref="ems_lead_a") - shared_idem_key = "shared-idem-key-1" - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers_a = make_valid_headers(idempotency_key=shared_idem_key) - resp_a = await client.post("/v1/scoring-runs", json=payload_a, headers=headers_a) - assert resp_a.status_code == 202 - run_id_a = resp_a.json()["run_id"] - - payload_b = make_valid_snapshot_payload( - tenant_id="tenant_beta", subject_ref="ems_lead_b" - ) - headers_b = make_valid_headers(idempotency_key=shared_idem_key) - resp_b = await client.post("/v1/scoring-runs", json=payload_b, headers=headers_b) - assert resp_b.status_code == 202 - run_id_b = resp_b.json()["run_id"] - assert run_id_a != run_id_b - - -@pytest.mark.asyncio -async def test_sentinel_raw_vkey_never_persisted_in_db_or_responses_or_logs( - migrated_postgres_app, caplog -): - app, _, db_url = migrated_postgres_app - sentinel_vkey = "sentinel-vkey-secret-99999-never-leak-into-db-or-logs" - payload = make_valid_snapshot_payload(tenant_id="tenant_sentinel_1") - - with caplog.at_level(logging.DEBUG): - async with app.router.lifespan_context(app): - async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test" - ) as client: - headers = make_valid_headers(vkey=sentinel_vkey) - post_resp = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert post_resp.status_code == 202 - run_id = post_resp.json()["run_id"] - - assert sentinel_vkey not in post_resp.text - assert sentinel_vkey not in str(post_resp.headers) - - get_resp = await client.get( - f"/v1/scoring-runs/{run_id}", - headers={ - "Authorization": "Bearer valid-omni-token", - "X-Tenant-Id": "tenant_sentinel_1", - }, - ) - assert get_resp.status_code == 200 - assert sentinel_vkey not in get_resp.text - - engine = create_async_engine(db_url) - async with AsyncSession(engine) as session: - stmt = select(ScoringRun).where(ScoringRun.id == run_id) - result = await session.execute(stmt) - run = result.scalar_one() - assert sentinel_vkey not in str(run.snapshot_data) - - raw_result = await session.execute( - text(f"SELECT * FROM scoring_runs WHERE id = '{run_id}';") - ) - row = raw_result.fetchone() - assert row is not None - for col_val in row: - assert sentinel_vkey not in str(col_val) - - search_result = await session.execute( - text( - "SELECT COUNT(*) FROM scoring_runs " - f"WHERE snapshot_data::text LIKE '%{sentinel_vkey}%';" - ) - ) - assert search_result.scalar() == 0 - - await engine.dispose() - - assert sentinel_vkey not in caplog.text - - -@pytest.mark.asyncio -async def test_sentinel_raw_vkey_in_payload_rejected_with_422_and_no_db_row( - migrated_postgres_app, caplog -): - app, _, db_url = migrated_postgres_app - sentinel_vkey = "sentinel-in-payload-secret-99999" - - # Test cases where sentinel V-Key is injected in various nested payload locations - test_locations = [ - ( - "message content", - lambda p: p["snapshot"]["conversations"][0]["messages"][0].update( - {"content": f"Here is my key: {sentinel_vkey}"} - ), - ), - ( - "custom fields value", - lambda p: ( - p["snapshot"]["lead_profile"] - .setdefault("custom_fields", {}) - .update({"crm_secret": sentinel_vkey}) - ), - ), - ( - "custom fields key", - lambda p: ( - p["snapshot"]["lead_profile"] - .setdefault("custom_fields", {}) - .update({f"key_{sentinel_vkey}": "some_value"}) - ), - ), - ( - "identity details", - lambda p: ( - p["snapshot"]["identity_context"] - .setdefault("details", {}) - .update({"meta_key": f"prefix_{sentinel_vkey}_suffix"}) - ), - ), - ] - - with caplog.at_level(logging.DEBUG): - async with app.router.lifespan_context(app): - async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test" - ) as client: - for loc_name, mutate_fn in test_locations: - payload = make_valid_snapshot_payload(tenant_id="tenant_sentinel_leak") - mutate_fn(payload) - - headers = make_valid_headers(vkey=sentinel_vkey) - resp = await client.post("/v1/scoring-runs", json=payload, headers=headers) - - assert resp.status_code == 422, f"Failed for {loc_name}" - data = resp.json() - assert data["error_code"] == "credential_in_payload" - assert data["detail"] == "Payload must not contain raw tenant credential" - - # Verify response body and headers never echo the secret - assert sentinel_vkey not in resp.text - assert sentinel_vkey not in str(resp.headers) - - # Verify no database row was ever persisted - engine = create_async_engine(db_url) - async with AsyncSession(engine) as session: - stmt = select(ScoringRun).where(ScoringRun.tenant_id == "tenant_sentinel_leak") - result = await session.execute(stmt) - rows = result.scalars().all() - assert len(rows) == 0 - await engine.dispose() - - # Verify logs do not leak the credential - assert sentinel_vkey not in caplog.text - - -@pytest.mark.asyncio -async def test_middleware_pre_body_authentication_rejects_malformed_json_with_401( - migrated_postgres_app, -): - app, _, db_url = migrated_postgres_app - malformed_body = b'{"invalid_json_payload: not closed' - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers_no_auth = { - "X-Tenant-VKey": "some_vkey", - "Idempotency-Key": "some_key", - "X-DotB-App": "omni", - "X-DotB-Feature": "lead-scoring", - "Content-Type": "application/json", - } - - # 1. Missing Authorization on malformed JSON -> must return 401 (not 422) - resp_no_auth = await client.post( - "/v1/scoring-runs", content=malformed_body, headers=headers_no_auth - ) - assert resp_no_auth.status_code == 401 - assert resp_no_auth.headers.get("www-authenticate") == "Bearer" - assert resp_no_auth.json()["error_code"] == "invalid_token" - - # 2. Invalid token on malformed JSON -> must return 401 (not 422) - headers_invalid_auth = dict(headers_no_auth) - headers_invalid_auth["Authorization"] = "Bearer invalid-unregistered-token" - resp_invalid_auth = await client.post( - "/v1/scoring-runs", content=malformed_body, headers=headers_invalid_auth - ) - assert resp_invalid_auth.status_code == 401 - assert resp_invalid_auth.json()["error_code"] == "invalid_token" - - # 3. Valid token on malformed JSON -> enters body parsing and returns 422 - headers_valid_auth = dict(headers_no_auth) - headers_valid_auth["Authorization"] = "Bearer valid-omni-token" - resp_valid_auth = await client.post( - "/v1/scoring-runs", content=malformed_body, headers=headers_valid_auth - ) - assert resp_valid_auth.status_code == 422 - assert resp_valid_auth.json()["error_code"] == "validation_error" - - # Verify DB is completely untouched - engine = create_async_engine(db_url) - async with AsyncSession(engine) as session: - result = await session.execute(text("SELECT COUNT(*) FROM scoring_runs;")) - assert result.scalar() == 0 - await engine.dispose() - - -@pytest.mark.asyncio -async def test_sentinel_vkey_in_unauthorized_tenant_id_rejected_with_422_not_403( - migrated_postgres_app, caplog -): - app, _, db_url = migrated_postgres_app - sentinel_vkey = "sentinel-vkey-in-tenant-id-secret-99999" - payload = make_valid_snapshot_payload(tenant_id=f"forbidden_{sentinel_vkey}") - - with caplog.at_level(logging.DEBUG): - async with app.router.lifespan_context(app): - async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test" - ) as client: - headers = make_valid_headers(vkey=sentinel_vkey) - resp = await client.post("/v1/scoring-runs", json=payload, headers=headers) - - assert resp.status_code == 422 - data = resp.json() - assert data["error_code"] == "credential_in_payload" - assert data["detail"] == "Payload must not contain raw tenant credential" - - assert sentinel_vkey not in resp.text - assert sentinel_vkey not in str(resp.headers) - - engine = create_async_engine(db_url) - async with AsyncSession(engine) as session: - result = await session.execute(text("SELECT COUNT(*) FROM scoring_runs;")) - assert result.scalar() == 0 - await engine.dispose() - - assert sentinel_vkey not in caplog.text - - -@pytest.mark.asyncio -async def test_sentinel_vkey_in_idempotency_key_rejected_with_422_and_no_db_row( - migrated_postgres_app, caplog -): - app, _, db_url = migrated_postgres_app - sentinel_vkey = "sentinel-vkey-in-idem-key-secret-99999" - payload = make_valid_snapshot_payload() - idem_key = f"key_{sentinel_vkey}" - - with caplog.at_level(logging.DEBUG): - async with app.router.lifespan_context(app): - async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test" - ) as client: - headers = make_valid_headers(vkey=sentinel_vkey, idempotency_key=idem_key) - resp = await client.post("/v1/scoring-runs", json=payload, headers=headers) - - assert resp.status_code == 422 - data = resp.json() - assert data["error_code"] == "credential_in_payload" - assert data["detail"] == "Payload must not contain raw tenant credential" - - assert sentinel_vkey not in resp.text - assert sentinel_vkey not in str(resp.headers) - - engine = create_async_engine(db_url) - async with AsyncSession(engine) as session: - result = await session.execute(text("SELECT COUNT(*) FROM scoring_runs;")) - assert result.scalar() == 0 - await engine.dispose() - - assert sentinel_vkey not in caplog.text - - -@pytest.mark.asyncio -async def test_sentinel_vkey_in_app_or_feature_headers_rejected_with_422_not_403( - migrated_postgres_app, caplog -): - app, _, db_url = migrated_postgres_app - sentinel_vkey = "sentinel-vkey-in-headers-secret-99999" - payload = make_valid_snapshot_payload() - - with caplog.at_level(logging.DEBUG): - async with app.router.lifespan_context(app): - async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test" - ) as client: - # 1. V-Key in X-DotB-App - headers_app = make_valid_headers(vkey=sentinel_vkey, app=f"app_{sentinel_vkey}") - resp_app = await client.post("/v1/scoring-runs", json=payload, headers=headers_app) - assert resp_app.status_code == 422 - assert resp_app.json()["error_code"] == "credential_in_payload" - assert sentinel_vkey not in resp_app.text - - # 2. V-Key in X-DotB-Feature - headers_feat = make_valid_headers( - vkey=sentinel_vkey, feature=f"feat_{sentinel_vkey}" - ) - resp_feat = await client.post( - "/v1/scoring-runs", json=payload, headers=headers_feat - ) - assert resp_feat.status_code == 422 - assert resp_feat.json()["error_code"] == "credential_in_payload" - assert sentinel_vkey not in resp_feat.text - - engine = create_async_engine(db_url) - async with AsyncSession(engine) as session: - result = await session.execute(text("SELECT COUNT(*) FROM scoring_runs;")) - assert result.scalar() == 0 - await engine.dispose() - - assert sentinel_vkey not in caplog.text - - -@pytest.mark.asyncio -async def test_sentinel_vkey_in_message_id_with_invalid_timestamp_does_not_leak( - migrated_postgres_app, caplog -): - app, _, db_url = migrated_postgres_app - sentinel_vkey = "sentinel-vkey-in-msg-id-secret-99999" - - # Future timestamp triggers timeline validation error - payload = make_valid_snapshot_payload( - captured_at="2026-08-27T10:00:00Z", - sent_at="2026-08-27T12:00:00Z", - ) - payload["snapshot"]["conversations"][0]["messages"][0]["message_id"] = f"msg_{sentinel_vkey}" - - with caplog.at_level(logging.DEBUG): - async with app.router.lifespan_context(app): - async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test" - ) as client: - headers = make_valid_headers(vkey=sentinel_vkey) - resp = await client.post("/v1/scoring-runs", json=payload, headers=headers) - - assert resp.status_code == 422 - data = resp.json() - assert data["error_code"] == "validation_error" - # Assert generic error message does not echo message_id - assert sentinel_vkey not in resp.text - assert sentinel_vkey not in str(resp.headers) - - engine = create_async_engine(db_url) - async with AsyncSession(engine) as session: - result = await session.execute(text("SELECT COUNT(*) FROM scoring_runs;")) - assert result.scalar() == 0 - await engine.dispose() - - assert sentinel_vkey not in caplog.text - - -@pytest.mark.asyncio -async def test_scope_errors_never_echo_service_token_or_caller_inputs( - migrated_postgres_app, caplog -): - app, _, _ = migrated_postgres_app - token_str = "valid-omni-token" - - with caplog.at_level(logging.DEBUG): - async with app.router.lifespan_context(app): - async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test" - ) as client: - # 1. Scope error with token in unauthorized app - headers_bad_app = make_valid_headers(app=f"unauth_app_{token_str}") - resp_app = await client.post( - "/v1/scoring-runs", - json=make_valid_snapshot_payload(), - headers=headers_bad_app, - ) - assert resp_app.status_code == 403 - assert resp_app.json()["error_code"] == "forbidden" - assert token_str not in resp_app.text - - # 2. Scope error with token in unauthorized feature - headers_bad_feat = make_valid_headers(feature=f"unauth_feat_{token_str}") - resp_feat = await client.post( - "/v1/scoring-runs", - json=make_valid_snapshot_payload(), - headers=headers_bad_feat, - ) - assert resp_feat.status_code == 403 - assert resp_feat.json()["error_code"] == "forbidden" - assert token_str not in resp_feat.text - - # 3. Scope error with token in unauthorized tenant - payload_bad_tenant = make_valid_snapshot_payload( - tenant_id=f"unauth_tenant_{token_str}" - ) - resp_tenant = await client.post( - "/v1/scoring-runs", - json=payload_bad_tenant, - headers=make_valid_headers(), - ) - assert resp_tenant.status_code == 403 - assert resp_tenant.json()["error_code"] == "forbidden" - assert token_str not in resp_tenant.text - - -@pytest.mark.asyncio -async def test_subject_ref_mismatch_with_snapshot_lead_profile_rejected_422( - migrated_postgres_app, -): - app, _, db_url = migrated_postgres_app - payload = make_valid_snapshot_payload(subject_ref="ems_lead_outer_1001") - # Mismatch with snapshot lead profile ems_lead_id - payload["snapshot"]["lead_profile"]["ems_lead_id"] = "ems_lead_inner_9999" - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers() - resp = await client.post("/v1/scoring-runs", json=payload, headers=headers) - - assert resp.status_code == 422 - data = resp.json() - assert data["error_code"] == "validation_error" - assert "subject_ref must match" in data["detail"] - # Assert IDs are not echoed in detail - assert "ems_lead_outer_1001" not in data["detail"] - assert "ems_lead_inner_9999" not in data["detail"] - - # Verify no DB row persisted - engine = create_async_engine(db_url) - async with AsyncSession(engine) as session: - result = await session.execute(text("SELECT COUNT(*) FROM scoring_runs;")) - assert result.scalar() == 0 - await engine.dispose() - - -@pytest.mark.asyncio -async def test_auth_bearer_scheme_case_insensitive( - migrated_postgres_app, -): - app, _, _ = migrated_postgres_app - payload = make_valid_snapshot_payload() - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - # 1. Lowercase bearer - headers_lower = make_valid_headers() - headers_lower["Authorization"] = "bearer valid-omni-token" - resp_lower = await client.post("/v1/scoring-runs", json=payload, headers=headers_lower) - assert resp_lower.status_code == 202 - - # 2. Uppercase BEARER - headers_upper = make_valid_headers() - headers_upper["Authorization"] = "BEARER valid-omni-token" - resp_upper = await client.post("/v1/scoring-runs", json=payload, headers=headers_upper) - assert resp_upper.status_code == 202 - - # 3. Mixed case bEaReR - headers_mixed = make_valid_headers() - headers_mixed["Authorization"] = "bEaReR valid-omni-token" - resp_mixed = await client.post("/v1/scoring-runs", json=payload, headers=headers_mixed) - assert resp_mixed.status_code == 202 - - -@pytest.mark.asyncio -async def test_worker_lease_boundary_rejection_on_admission( - migrated_postgres_app, -): - app, settings, _ = migrated_postgres_app - payload = make_valid_snapshot_payload() - - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - headers = make_valid_headers() - - # min_lease = 2 * 10.0 + 5.0 = 25.0s - # 1. worker_lease_seconds < 25.0 (e.g. 20.0) -> rejected with 422 - settings.worker_lease_seconds = 20.0 - resp_less = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert resp_less.status_code == 422 - assert resp_less.json()["error_code"] == "worker_lease_too_short" - - # 2. worker_lease_seconds == 25.0 (exact equality) -> MUST also be rejected with 422 - settings.worker_lease_seconds = 25.0 - resp_equal = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert resp_equal.status_code == 422 - assert resp_equal.json()["error_code"] == "worker_lease_too_short" - - # 3. worker_lease_seconds > 25.0 (e.g. 30.0) -> accepted with 202 - settings.worker_lease_seconds = 30.0 - resp_valid = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert resp_valid.status_code == 202 - - -@pytest.mark.asyncio -async def test_kill_switch_blocks_new_admission_but_toggle_allows_it(migrated_postgres_app): - app, settings, _ = migrated_postgres_app - payload = make_valid_snapshot_payload() - - headers = make_valid_headers() - async with app.router.lifespan_context(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - settings.kill_switch_enabled = True - blocked = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert blocked.status_code == 503 - assert blocked.json() == { - "error_code": "scoring_admission_disabled", - "detail": "Scoring run initiation is temporarily disabled", - } - - settings.kill_switch_enabled = False - accepted = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert accepted.status_code == 202 - - settings.kill_switch_enabled = True - replay = await client.post("/v1/scoring-runs", json=payload, headers=headers) - assert replay.status_code == 202 - assert replay.json()["run_id"] == accepted.json()["run_id"] - - assert app.state.telemetry.snapshot()["counters"]["scoring_admission_blocked_total"] == 1 diff --git a/old_repo/tests/test_webhook_contract_cli.py b/old_repo/tests/test_webhook_contract_cli.py deleted file mode 100644 index 257f2fd..0000000 --- a/old_repo/tests/test_webhook_contract_cli.py +++ /dev/null @@ -1,171 +0,0 @@ -import json -from pathlib import Path - -import pytest -from pydantic import ValidationError - -from lead_scoring.cli.webhook_contract import ( - TEST_SIGNING_SECRET, - canonical_json, - generate_contract_files, - run_webhook_contract_cli, - sign_webhook, -) -from lead_scoring.schemas.review import ( - REVIEW_EVENT_TYPE, - LeadScoringReviewedEvent, - ReviewDecision, -) -from lead_scoring.schemas.scoring_run import ScoringRunStatus -from lead_scoring.schemas.webhook import LeadScoringTerminalEvent, WebhookIngestErrorResponse - - -def test_contract_files_are_deterministic_and_validate_against_models(): - files = generate_contract_files() - - assert files == generate_contract_files() - assert set(files) == { - "contract.json", - "lead-scoring-terminal-event.schema.json", - "lead-scoring-reviewed-event.schema.json", - "webhook-ingest-response.schema.json", - "webhook-ingest-error-response.schema.json", - "fixtures/completed.json", - "fixtures/tenant-unavailable.json", - "fixtures/review_required.json", - "fixtures/worker_interrupted.json", - "fixtures/reviewed_approve.json", - "fixtures/reviewed_override.json", - "fixtures/reviewed_reevaluate.json", - "signature-vectors.json", - } - - manifest = json.loads(files["contract.json"]) - assert manifest["contract_pack_version"] == "0.1.0" - assert manifest["endpoint_path"] == "/api/v1/internal/lead-scoring/events" - assert manifest["event_types"] == [ - "lead_scoring.run.terminal", - "lead_scoring.run.reviewed", - ] - assert manifest["supported_event_types"] == [ - "lead_scoring.run.terminal", - "lead_scoring.run.reviewed", - ] - assert manifest["method"] == "POST" - assert manifest["replay_window_seconds"] == 300 - assert manifest["signature_algorithm"] == "HMAC-SHA256" - assert manifest["signature_version"] == "v1" - - terminal_schema = json.loads(files["lead-scoring-terminal-event.schema.json"]) - assert "event_type" in terminal_schema["required"] - assert "event_version" in terminal_schema["required"] - - reviewed_schema = json.loads(files["lead-scoring-reviewed-event.schema.json"]) - assert "event_type" in reviewed_schema["required"] - assert "event_version" in reviewed_schema["required"] - - completed = LeadScoringTerminalEvent.model_validate( - json.loads(files["fixtures/completed.json"]) - ) - review_required = LeadScoringTerminalEvent.model_validate( - json.loads(files["fixtures/review_required.json"]) - ) - worker_interrupted = LeadScoringTerminalEvent.model_validate( - json.loads(files["fixtures/worker_interrupted.json"]) - ) - - assert completed.data.status is ScoringRunStatus.COMPLETED - assert review_required.data.status is ScoringRunStatus.COMPLETED - assert worker_interrupted.data.status is ScoringRunStatus.FAILED - assert worker_interrupted.data.error_code == "worker_interrupted" - assert worker_interrupted.data.retryable is True - - reviewed_appr = LeadScoringReviewedEvent.model_validate( - json.loads(files["fixtures/reviewed_approve.json"]) - ) - assert reviewed_appr.event_type == REVIEW_EVENT_TYPE - assert reviewed_appr.data.decision is ReviewDecision.APPROVE - assert reviewed_appr.data.effective_score == 61 - assert reviewed_appr.data.version == 2 - assert reviewed_appr.data.reviewed_by == "agent_10" - - reviewed_over = LeadScoringReviewedEvent.model_validate( - json.loads(files["fixtures/reviewed_override.json"]) - ) - assert reviewed_over.event_type == REVIEW_EVENT_TYPE - assert reviewed_over.data.decision is ReviewDecision.OVERRIDE - assert reviewed_over.data.calculated_score == 61 - assert reviewed_over.data.effective_score == 90 - assert reviewed_over.data.version == 2 - assert reviewed_over.data.reviewed_by == "agent_10" - - reviewed_reeval = LeadScoringReviewedEvent.model_validate( - json.loads(files["fixtures/reviewed_reevaluate.json"]) - ) - assert reviewed_reeval.event_type == REVIEW_EVENT_TYPE - assert reviewed_reeval.data.status == "superseded" - assert reviewed_reeval.data.decision is ReviewDecision.RE_EVALUATE - assert reviewed_reeval.data.calculated_score == 82 - assert reviewed_reeval.data.effective_score == 82 - assert reviewed_reeval.data.version == 2 - assert reviewed_reeval.data.reviewed_by == "agent_10" - - -def test_tenant_unavailable_error_contract_is_exact(): - files = generate_contract_files() - response_body = json.loads(files["fixtures/tenant-unavailable.json"]) - response = WebhookIngestErrorResponse.model_validate(response_body) - - assert response_body == { - "error_code": "subscriber_tenant_unavailable", - "detail": "Webhook destination is not available", - } - assert response.model_dump(mode="json") == response_body - - schema = json.loads(files["webhook-ingest-error-response.schema.json"]) - assert schema["additionalProperties"] is False - assert schema["required"] == ["error_code", "detail"] - - with pytest.raises(ValidationError): - WebhookIngestErrorResponse.model_validate({**response_body, "tenant_id": "hidden"}) - with pytest.raises(ValidationError): - WebhookIngestErrorResponse.model_validate( - {**response_body, "error_code": "tenant_not_registered"} - ) - - -def test_signature_vectors_match_fixture_bytes(): - files = generate_contract_files() - vectors = json.loads(files["signature-vectors.json"]) - - assert vectors["replay_window_seconds"] == 300 - assert vectors["signature_version"] == "v1" - assert vectors["test_secret"] == TEST_SIGNING_SECRET - for vector in vectors["vectors"]: - body = files[vector["body_file"]].encode() - assert vector["signature"] == sign_webhook( - TEST_SIGNING_SECRET, - vector["event_id"], - vector["timestamp"], - body, - ) - - -def test_webhook_contract_cli_generates_and_checks_contract_pack(tmp_path: Path): - output_dir = tmp_path / "contract" - - assert run_webhook_contract_cli(["--output", str(output_dir)]) == 0 - assert run_webhook_contract_cli(["--output", str(output_dir), "--check"]) == 0 - - -def test_webhook_contract_cli_rejects_stale_or_unexpected_artifacts(tmp_path: Path): - output_dir = tmp_path / "contract" - assert run_webhook_contract_cli(["--output", str(output_dir)]) == 0 - - fixture = output_dir / "fixtures" / "completed.json" - fixture.write_text(canonical_json({"stale": True}), encoding="utf-8") - assert run_webhook_contract_cli(["--output", str(output_dir), "--check"]) == 1 - - assert run_webhook_contract_cli(["--output", str(output_dir)]) == 0 - (output_dir / "unexpected.json").write_text("{}\n", encoding="utf-8") - assert run_webhook_contract_cli(["--output", str(output_dir), "--check"]) == 1 diff --git a/old_repo/tests/test_webhook_dispatcher.py b/old_repo/tests/test_webhook_dispatcher.py deleted file mode 100644 index fceca97..0000000 --- a/old_repo/tests/test_webhook_dispatcher.py +++ /dev/null @@ -1,1394 +0,0 @@ -import asyncio -import json -from datetime import UTC, datetime, timedelta -from pathlib import Path - -import httpx -import pytest -from alembic import command -from alembic.config import Config -from pydantic import SecretStr -from sqlalchemy import select, update -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine - -from lead_scoring.config import Settings -from lead_scoring.db.models.outbox_event import OutboxEvent -from lead_scoring.db.models.scoring_run import ScoringRun -from lead_scoring.main import create_app -from lead_scoring.schemas.scoring_run import ScoringRunStatus -from lead_scoring.schemas.webhook import ( - OutboxDeliveryStatus, -) -from lead_scoring.webhook.dispatcher import ( - DeliveryClassification, - DeliveryResult, - WebhookDispatcher, - calculate_backoff, - claim_outbox_events, - dispatch_single_event, - record_delivery_result, - resolve_webhook_target_url, -) -from lead_scoring.webhook.signing import ( - TEST_SIGNING_SECRET, - sanitize_secret, - sign_webhook, -) - -PROJECT_ROOT = Path(__file__).resolve().parent.parent - - -def get_alembic_config(database_url: str) -> Config: - ini_path = PROJECT_ROOT / "alembic.ini" - alembic_cfg = Config(str(ini_path)) - alembic_cfg.attributes["sqlalchemy_url"] = database_url - return alembic_cfg - - -def test_calculate_backoff_deterministic() -> None: - assert calculate_backoff(0, base_seconds=2.0, max_seconds=60.0) == 0.0 - assert calculate_backoff(1, base_seconds=2.0, max_seconds=60.0) == 2.0 - assert calculate_backoff(2, base_seconds=2.0, max_seconds=60.0) == 4.0 - assert calculate_backoff(3, base_seconds=2.0, max_seconds=60.0) == 8.0 - assert calculate_backoff(4, base_seconds=2.0, max_seconds=60.0) == 16.0 - assert calculate_backoff(5, base_seconds=2.0, max_seconds=60.0) == 32.0 - assert calculate_backoff(6, base_seconds=2.0, max_seconds=60.0) == 60.0 - assert calculate_backoff(10, base_seconds=2.0, max_seconds=60.0) == 60.0 - - -def test_resolve_webhook_target_url() -> None: - assert ( - resolve_webhook_target_url("http://localhost:8080") - == "http://localhost:8080/api/v1/internal/lead-scoring/events" - ) - assert ( - resolve_webhook_target_url("http://localhost:8080/") - == "http://localhost:8080/api/v1/internal/lead-scoring/events" - ) - assert ( - resolve_webhook_target_url("http://localhost:8080/api/v1/internal/lead-scoring/events") - == "http://localhost:8080/api/v1/internal/lead-scoring/events" - ) - with pytest.raises(ValueError, match="path must be empty"): - resolve_webhook_target_url("https://omni.dotb.vn/custom/path") - - -def test_settings_rejects_arbitrary_webhook_endpoint_path() -> None: - with pytest.raises(ValueError, match="path must be empty"): - Settings( - webhook_subscriber_url="https://omni.dotb.vn/custom/path", - webhook_signing_secret=SecretStr("test-secret"), - ) - - -def test_sign_webhook_matches_released_contract_signature_vectors() -> None: - vectors_file = ( - PROJECT_ROOT / "contracts" / "lead-scoring-webhook" / "v1" / "signature-vectors.json" - ) - vectors_data = json.loads(vectors_file.read_text(encoding="utf-8")) - test_secret = vectors_data["test_secret"] - assert test_secret == TEST_SIGNING_SECRET - - for vector in vectors_data["vectors"]: - body_file = PROJECT_ROOT / "contracts" / "lead-scoring-webhook" / "v1" / vector["body_file"] - body_bytes = body_file.read_bytes() - calculated_sig = sign_webhook( - secret=test_secret, - event_id=vector["event_id"], - timestamp=vector["timestamp"], - body=body_bytes, - ) - assert calculated_sig == vector["signature"] - - -def test_signature_changes_with_fresh_timestamp_on_retry() -> None: - event_id = "11111111-1111-4111-8111-111111111111" - body = b'{"hello": "world"}\n' - sig1 = sign_webhook(TEST_SIGNING_SECRET, event_id, "1787832600", body) - sig2 = sign_webhook(TEST_SIGNING_SECRET, event_id, "1787832660", body) - assert sig1 != sig2 - assert sig1.startswith("v1=") - assert sig2.startswith("v1=") - - -def test_secret_sanitization() -> None: - secret = "whsec_super_confidential_key_12345" - raw_error = f"Failed to authenticate with secret {secret} against endpoint" - sanitized = sanitize_secret(raw_error, secret) - assert secret not in sanitized - assert "[REDACTED]" in sanitized - assert sanitize_secret("No secret here", None) == "No secret here" - - -@pytest.fixture -async def migrated_db(temp_postgres_db: str): - alembic_cfg = get_alembic_config(temp_postgres_db) - await asyncio.to_thread(command.upgrade, alembic_cfg, "head") - engine = create_async_engine(temp_postgres_db) - session_factory = async_sessionmaker(engine, expire_on_commit=False) - try: - yield session_factory, engine, temp_postgres_db - finally: - await engine.dispose() - await asyncio.to_thread(command.downgrade, alembic_cfg, "base") - - -@pytest.mark.asyncio -async def test_claim_outbox_events_single_and_concurrent(migrated_db): - session_factory, _, _ = migrated_db - now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - - # Insert 5 events (3 pending, 1 delivering with active lease, 1 delivering with expired lease) - async with session_factory() as session: - for i in range(1, 4): - session.add( - OutboxEvent( - id=f"00000000-0000-0000-0000-00000000000{i}", - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id=f"run-{i}", - payload={"status": "completed"}, - occurred_at=now - timedelta(minutes=10 - i), - delivery_status=OutboxDeliveryStatus.PENDING.value, - ) - ) - # Active delivering event - session.add( - OutboxEvent( - id="00000000-0000-0000-0000-000000000004", - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id="run-4", - payload={"status": "completed"}, - occurred_at=now - timedelta(minutes=5), - delivery_status=OutboxDeliveryStatus.DELIVERING.value, - lease_expires_at=now + timedelta(seconds=20), - ) - ) - # Expired delivering event (crash recovery) - session.add( - OutboxEvent( - id="00000000-0000-0000-0000-000000000005", - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id="run-5", - payload={"status": "completed"}, - occurred_at=now - timedelta(minutes=4), - delivery_status=OutboxDeliveryStatus.DELIVERING.value, - lease_expires_at=now - timedelta(seconds=5), - ) - ) - await session.commit() - - # Claim batch of 10 - async with session_factory() as session: - claimed = await claim_outbox_events( - session=session, batch_size=10, lease_seconds=30.0, now=now - ) - await session.commit() - - claimed_ids = {e.id for e in claimed} - # Should claim 1, 2, 3 (pending) and 5 (expired lease), but NOT 4 (active lease) - assert len(claimed) == 4 - assert claimed_ids == { - "00000000-0000-0000-0000-000000000001", - "00000000-0000-0000-0000-000000000002", - "00000000-0000-0000-0000-000000000003", - "00000000-0000-0000-0000-000000000005", - } - for e in claimed: - assert e.delivery_status == OutboxDeliveryStatus.DELIVERING.value - assert e.lease_expires_at == now + timedelta(seconds=30.0) - - -@pytest.mark.asyncio -async def test_concurrent_dispatchers_no_duplicate_claims(migrated_db): - session_factory, _, _ = migrated_db - now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - - # Insert 20 pending events - async with session_factory() as session: - for i in range(20): - session.add( - OutboxEvent( - id=f"00000000-0000-0000-0000-{i:012d}", - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id=f"run-{i}", - payload={"status": "completed"}, - occurred_at=now + timedelta(seconds=i), - delivery_status=OutboxDeliveryStatus.PENDING.value, - ) - ) - await session.commit() - - # 4 concurrent workers claim batches of 5 concurrently - async def worker_claim(): - async with session_factory() as session: - claimed = await claim_outbox_events( - session=session, batch_size=5, lease_seconds=30.0, now=now - ) - await session.commit() - return [e.id for e in claimed] - - results = await asyncio.gather( - worker_claim(), - worker_claim(), - worker_claim(), - worker_claim(), - ) - - all_claimed_ids = [item for sublist in results for item in sublist] - assert len(all_claimed_ids) == 20 - # No event claimed twice - assert len(set(all_claimed_ids)) == 20 - - -@pytest.mark.asyncio -async def test_dispatch_all_200_ingest_statuses_mark_delivered(migrated_db): - session_factory, _, _ = migrated_db - now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - - for ingest_status in ["accepted", "duplicate", "stale", "ordering_conflict"]: - event_id = f"00000000-0000-0000-0000-0000000000{ingest_status[:2]}" - run_id = f"run-200-{ingest_status}" - event = OutboxEvent( - id=event_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id=run_id, - payload={"event_id": event_id, "data": {"status": "completed"}}, - occurred_at=now, - delivery_status=OutboxDeliveryStatus.DELIVERING.value, - ) - - mock_transport = httpx.MockTransport( - lambda request, status=ingest_status: httpx.Response( - 200, - json={"status": status, "event_id": "00000000-0000-0000-0000-000000000001"}, - ) - ) - async with httpx.AsyncClient(transport=mock_transport) as client: - result = await dispatch_single_event( - event=event, - target_url="http://localhost:8080/api/v1/internal/lead-scoring/events", - signing_key_id="test-key", - signing_secret=TEST_SIGNING_SECRET, - timeout_seconds=5.0, - http_client=client, - now=now, - ) - - assert result.classification == DeliveryClassification.DELIVERED - assert result.http_status == 200 - assert result.ingest_status == ingest_status - - # Test recording result in DB - async with session_factory() as session: - session.add(event) - await session.commit() - - await record_delivery_result( - session=session, - event=event, - result=result, - max_attempts=5, - backoff_base_seconds=2.0, - backoff_max_seconds=60.0, - now=now, - ) - await session.commit() - - async with session_factory() as check_session: - updated = await check_session.get(OutboxEvent, event_id) - assert updated is not None - assert updated.delivery_status == OutboxDeliveryStatus.DELIVERED.value - assert updated.delivered_at == now - assert updated.lease_expires_at is None - assert updated.last_http_status == 200 - assert updated.last_error_code is None - - -@pytest.mark.asyncio -async def test_dispatch_terminal_400_and_401_go_to_dlq(migrated_db): - session_factory, _, _ = migrated_db - now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - - cases = [ - (400, "receiver_bad_request", "Bad JSON format"), - (401, "receiver_unauthorized", "Invalid signature"), - ] - - for status_code, expected_err, response_text in cases: - event_id = f"00000000-0000-0000-0000-000000000{status_code}" - run_id = f"run-err-{status_code}" - event = OutboxEvent( - id=event_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id=run_id, - payload={"status": "completed"}, - occurred_at=now, - delivery_status=OutboxDeliveryStatus.DELIVERING.value, - attempt_count=1, - ) - - mock_transport = httpx.MockTransport( - lambda request, sc=status_code, txt=response_text: httpx.Response(sc, text=txt) - ) - async with httpx.AsyncClient(transport=mock_transport) as client: - result = await dispatch_single_event( - event=event, - target_url="http://localhost:8080/api/v1/internal/lead-scoring/events", - signing_key_id="test-key", - signing_secret=TEST_SIGNING_SECRET, - timeout_seconds=5.0, - http_client=client, - now=now, - ) - - assert result.classification == DeliveryClassification.DEAD_LETTER - assert result.http_status == status_code - assert result.error_code == expected_err - - async with session_factory() as session: - session.add(event) - await session.commit() - - await record_delivery_result( - session=session, - event=event, - result=result, - max_attempts=5, - backoff_base_seconds=2.0, - backoff_max_seconds=60.0, - now=now, - ) - await session.commit() - - async with session_factory() as check_session: - updated = await check_session.get(OutboxEvent, event_id) - assert updated is not None - assert updated.delivery_status == OutboxDeliveryStatus.DEAD_LETTER.value - assert updated.attempt_count == 1 - assert updated.last_http_status == status_code - assert updated.last_error_code == expected_err - assert updated.last_error_detail == response_text - assert updated.lease_expires_at is None - - -@pytest.mark.asyncio -async def test_dispatch_terminal_404_tenant_unavailable_goes_to_dlq(migrated_db): - session_factory, _, _ = migrated_db - now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - event_id = "00000000-0000-0000-0000-000000000404" - response_text = ( - '{"error_code":"subscriber_tenant_unavailable",' - '"detail":"Webhook destination is not available"}' - ) - event = OutboxEvent( - id=event_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id="run-tenant-unavailable", - payload={"status": "completed"}, - occurred_at=now, - delivery_status=OutboxDeliveryStatus.DELIVERING.value, - attempt_count=1, - ) - - mock_transport = httpx.MockTransport(lambda request: httpx.Response(404, text=response_text)) - async with httpx.AsyncClient(transport=mock_transport) as client: - result = await dispatch_single_event( - event=event, - target_url="http://localhost:8080/api/v1/internal/lead-scoring/events", - signing_key_id="test-key", - signing_secret=TEST_SIGNING_SECRET, - timeout_seconds=5.0, - http_client=client, - now=now, - ) - - assert result.classification == DeliveryClassification.DEAD_LETTER - assert result.http_status == 404 - assert result.error_code == "receiver_client_error" - - async with session_factory() as session: - session.add(event) - await session.commit() - recorded = await record_delivery_result( - session=session, - event=event, - result=result, - max_attempts=5, - backoff_base_seconds=2.0, - backoff_max_seconds=60.0, - now=now, - ) - await session.commit() - - assert recorded is True - async with session_factory() as session: - updated = await session.get(OutboxEvent, event_id) - assert updated is not None - assert updated.delivery_status == OutboxDeliveryStatus.DEAD_LETTER.value - assert updated.attempt_count == 1 - assert updated.next_attempt_at is None - assert updated.claim_token is None - assert updated.lease_expires_at is None - assert updated.last_http_status == 404 - assert updated.last_error_code == "receiver_client_error" - assert updated.last_error_detail == response_text - - claimed = await claim_outbox_events( - session=session, - batch_size=1, - lease_seconds=30.0, - now=now + timedelta(minutes=1), - max_attempts=5, - ) - assert claimed == [] - - -@pytest.mark.asyncio -async def test_dispatch_retryable_429_500_and_timeout(migrated_db): - session_factory, _, _ = migrated_db - now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - - # 1. 429 Rate limited on attempt 1 - event_id = "00000000-0000-0000-0000-000000000429" - event = OutboxEvent( - id=event_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id="run-429", - payload={"status": "completed"}, - occurred_at=now, - delivery_status=OutboxDeliveryStatus.DELIVERING.value, - claim_token="token-429", - attempt_count=1, - ) - mock_transport = httpx.MockTransport( - lambda req: httpx.Response(429, text="Rate limit exceeded") - ) - async with httpx.AsyncClient(transport=mock_transport) as client: - result = await dispatch_single_event( - event=event, - target_url="http://localhost:8080/api/v1/internal/lead-scoring/events", - signing_key_id="test-key", - signing_secret=TEST_SIGNING_SECRET, - timeout_seconds=5.0, - http_client=client, - now=now, - ) - assert result.classification == DeliveryClassification.RETRYABLE - assert result.http_status == 429 - - async with session_factory() as session: - session.add(event) - await session.commit() - await record_delivery_result( - session=session, - event=event, - result=result, - max_attempts=5, - backoff_base_seconds=2.0, - backoff_max_seconds=60.0, - now=now, - claim_token="token-429", - ) - await session.commit() - - async with session_factory() as check_session: - updated = await check_session.get(OutboxEvent, event_id) - assert updated is not None - assert updated.delivery_status == OutboxDeliveryStatus.PENDING.value - assert updated.attempt_count == 1 - assert updated.next_attempt_at == now + timedelta(seconds=2.0) - assert updated.last_error_code == "receiver_rate_limited" - - # 2. 500 Server error on attempt 2 -> backoff (4.0s) - event_500_id = "00000000-0000-0000-0000-000000000500" - event_500 = OutboxEvent( - id=event_500_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id="run-500", - payload={"status": "completed"}, - occurred_at=now, - delivery_status=OutboxDeliveryStatus.DELIVERING.value, - claim_token="token-500", - attempt_count=2, - ) - mock_transport_500 = httpx.MockTransport( - lambda req: httpx.Response(500, text="Internal Server Error") - ) - async with httpx.AsyncClient(transport=mock_transport_500) as client: - result_500 = await dispatch_single_event( - event=event_500, - target_url="http://localhost:8080/api/v1/internal/lead-scoring/events", - signing_key_id="test-key", - signing_secret=TEST_SIGNING_SECRET, - timeout_seconds=5.0, - http_client=client, - now=now, - ) - assert result_500.classification == DeliveryClassification.RETRYABLE - - async with session_factory() as session: - session.add(event_500) - await session.commit() - await record_delivery_result( - session=session, - event=event_500, - result=result_500, - max_attempts=5, - backoff_base_seconds=2.0, - backoff_max_seconds=60.0, - now=now, - claim_token="token-500", - ) - await session.commit() - - async with session_factory() as check_session: - updated_500 = await check_session.get(OutboxEvent, event_500_id) - assert updated_500 is not None - assert updated_500.delivery_status == OutboxDeliveryStatus.PENDING.value - assert updated_500.attempt_count == 2 - assert updated_500.next_attempt_at == now + timedelta(seconds=4.0) - - # 3. Timeout on attempt 5 -> reaches max_attempts 5 -> DEAD_LETTER - event_timeout_id = "00000000-0000-0000-0000-000000000504" - event_timeout = OutboxEvent( - id=event_timeout_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id="run-timeout", - payload={"status": "completed"}, - occurred_at=now, - delivery_status=OutboxDeliveryStatus.DELIVERING.value, - claim_token="token-timeout", - attempt_count=5, - ) - timeout_result = DeliveryResult( - classification=DeliveryClassification.RETRYABLE, - error_code="delivery_timeout", - detail="Webhook delivery timed out", - ) - async with session_factory() as session: - session.add(event_timeout) - await session.commit() - await record_delivery_result( - session=session, - event=event_timeout, - result=timeout_result, - max_attempts=5, - backoff_base_seconds=2.0, - backoff_max_seconds=60.0, - now=now, - claim_token="token-timeout", - ) - await session.commit() - - async with session_factory() as check_session: - updated_timeout = await check_session.get(OutboxEvent, event_timeout_id) - assert updated_timeout is not None - assert updated_timeout.delivery_status == OutboxDeliveryStatus.DEAD_LETTER.value - assert updated_timeout.attempt_count == 5 - assert updated_timeout.last_error_code == "retries_exhausted" - - -@pytest.mark.asyncio -async def test_webhook_dispatcher_runtime_batch_processing(migrated_db): - session_factory, _, _ = migrated_db - now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - - settings = Settings( - webhook_subscriber_url="http://localhost:8080/api/v1/internal/lead-scoring/events", - webhook_signing_secret=SecretStr(TEST_SIGNING_SECRET), - webhook_signing_key_id="pilot-test-2026-01", - webhook_max_attempts=3, - webhook_batch_size=10, - ) - - received_requests = [] - - def handle_request(request: httpx.Request): - received_requests.append( - { - "url": str(request.url), - "headers": dict(request.headers), - "body": json.loads(request.content.decode("utf-8")), - } - ) - return httpx.Response( - 200, - json={ - "status": "accepted", - "event_id": request.headers.get( - "x-dotb-webhook-id", "00000000-0000-0000-0000-000000000000" - ), - }, - ) - - mock_client = httpx.AsyncClient(transport=httpx.MockTransport(handle_request)) - - # Insert 2 pending events - async with session_factory() as session: - session.add( - OutboxEvent( - id="00000000-0000-0000-0000-000000000101", - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id="run-101", - payload={ - "event_id": "00000000-0000-0000-0000-000000000101", - "event_type": "lead_scoring.run.terminal", - "event_version": "1.0.0", - "tenant_id": "tenant_1", - "subject_ref": "lead_101", - "scoring_config_version": "1.0.0", - "run_id": "00000000-0000-0000-0000-000000000101", - "run_created_at": "2026-08-28T12:00:00Z", - "occurred_at": "2026-08-28T12:00:02Z", - "data": {"status": "completed", "rule_score": 85}, - }, - occurred_at=now, - delivery_status=OutboxDeliveryStatus.PENDING.value, - ) - ) - session.add( - OutboxEvent( - id="00000000-0000-0000-0000-000000000102", - event_type="lead_scoring.run.reviewed", - event_version="1.0.0", - run_id="run-102", - payload={ - "event_id": "00000000-0000-0000-0000-000000000102", - "event_type": "lead_scoring.run.reviewed", - "event_version": "1.0.0", - "tenant_id": "tenant_1", - "subject_ref": "lead_102", - "scoring_config_version": "1.0.0", - "run_id": "00000000-0000-0000-0000-000000000102", - "run_created_at": "2026-08-28T12:00:00Z", - "occurred_at": "2026-08-28T12:05:00Z", - "data": { - "status": "completed", - "decision": "approve", - "calculated_score": 80, - "effective_score": 80, - "version": 2, - "reviewed_by": "admin_1", - }, - }, - occurred_at=now + timedelta(seconds=1), - delivery_status=OutboxDeliveryStatus.PENDING.value, - ) - ) - await session.commit() - - dispatcher = WebhookDispatcher( - session_factory=session_factory, - settings=settings, - http_client=mock_client, - clock=lambda: now, - ) - - processed_count = await dispatcher.dispatch_batch() - assert processed_count == 2 - assert len(received_requests) == 2 - - # Verify headers and HMAC signature on received request - req1 = received_requests[0] - assert req1["headers"]["x-dotb-webhook-id"] == "00000000-0000-0000-0000-000000000101" - assert req1["headers"]["x-dotb-webhook-key-id"] == "pilot-test-2026-01" - assert "x-dotb-webhook-signature" in req1["headers"] - assert req1["headers"]["x-dotb-webhook-signature"].startswith("v1=") - - # Verify both rows in DB transitioned to delivered - async with session_factory() as session: - events = (await session.execute(select(OutboxEvent))).scalars().all() - assert len(events) == 2 - for ev in events: - assert ev.delivery_status == OutboxDeliveryStatus.DELIVERED.value - assert ev.delivered_at is not None - - await dispatcher.stop() - await mock_client.aclose() - - -@pytest.mark.asyncio -async def test_fastapi_lifespan_starts_and_stops_dispatcher(migrated_db): - session_factory, engine, db_url = migrated_db - settings = Settings( - webhook_subscriber_url="http://localhost:8080/api/v1/internal/lead-scoring/events", - webhook_signing_secret=SecretStr(TEST_SIGNING_SECRET), - database_url=db_url, - ) - - mock_client = httpx.AsyncClient( - transport=httpx.MockTransport( - lambda req: httpx.Response( - 200, json={"status": "accepted", "event_id": "00000000-0000-0000-0000-000000000001"} - ) - ) - ) - - app = create_app(settings=settings, webhook_http_client=mock_client) - - # Trigger lifespan - async with app.router.lifespan_context(app): - # Check app state has dispatcher - dispatcher = getattr(app.state, "webhook_dispatcher", None) - assert dispatcher is not None - assert isinstance(dispatcher, WebhookDispatcher) - - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), - base_url="http://testserver", - ) as client: - # Health endpoint should be healthy - resp = await client.get("/health") - assert resp.status_code == 200 - - await mock_client.aclose() - - -@pytest.mark.asyncio -async def test_e2e_scoring_run_execution_outbox_to_webhook_delivery(migrated_db): - session_factory, engine, db_url = migrated_db - now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - - from lead_scoring.config import ServiceCallerConfig - from lead_scoring.execution import execute_scoring_run - from lead_scoring.scoring import ScoringConfig - - scoring_config = ScoringConfig( - version="1.0.0", - base_score=85, - rule_weight=1.0, - ai_weight=0.0, - confidence=0.9, - confidence_threshold=0.8, - hitl_policy_version="1.0.0", - rules=(), - ) - - settings = Settings( - webhook_subscriber_url="http://localhost:8080/api/v1/internal/lead-scoring/events", - webhook_signing_secret=SecretStr(TEST_SIGNING_SECRET), - webhook_signing_key_id="pilot-test-2026-01", - database_url=db_url, - scoring_configs=[scoring_config], - service_callers=[ - ServiceCallerConfig( - caller_id="test_caller", - token=SecretStr("valid_token"), - allowed_apps=["omni"], - allowed_features=["scoring"], - allowed_tenants=["tenant_1"], - ) - ], - ) - - run_id = "00000000-0000-0000-0000-000000000999" - - # 1. Insert a pending scoring run - async with session_factory() as session: - session.add( - ScoringRun( - id=run_id, - tenant_id="tenant_1", - subject_ref="lead_999", - scoring_config_version="1.0.0", - idempotency_key="idem-999", - payload_hash="hash-999", - status=ScoringRunStatus.PENDING.value, - snapshot_data={ - "version": "1.0.0", - "captured_at": "2026-08-28T12:00:00Z", - "lead_profile": { - "ems_lead_id": "lead_999", - "name": "CEO", - }, - }, - effective_config=scoring_config.model_dump(mode="json"), - confidence_threshold=scoring_config.confidence_threshold, - created_at=now, - updated_at=now, - ) - ) - await session.commit() - - # 2. Execute scoring run -> writes terminal outbox event atomically - executed = await execute_scoring_run( - session_factory=session_factory, - run_id=run_id, - tenant_vkey="test-vkey", - lease_seconds=30.0, - clock=lambda: now, - ) - assert executed is True - - # Verify outbox event written - async with session_factory() as session: - events = ( - (await session.execute(select(OutboxEvent).where(OutboxEvent.run_id == run_id))) - .scalars() - .all() - ) - assert len(events) == 1 - outbox_entry = events[0] - assert outbox_entry.event_type == "lead_scoring.run.terminal" - assert outbox_entry.delivery_status == OutboxDeliveryStatus.PENDING.value - - # 3. Setup mock Omni subscriber that verifies HMAC signature and timestamp - received_requests = [] - - def mock_omni_receiver(request: httpx.Request): - event_id = request.headers["x-dotb-webhook-id"] - timestamp = request.headers["x-dotb-webhook-timestamp"] - signature = request.headers["x-dotb-webhook-signature"] - body = request.content - - expected_sig = sign_webhook( - secret=TEST_SIGNING_SECRET, - event_id=event_id, - timestamp=timestamp, - body=body, - ) - assert signature == expected_sig - - parsed_body = json.loads(body.decode("utf-8")) - received_requests.append(parsed_body) - - return httpx.Response( - 200, - json={"status": "accepted", "event_id": event_id}, - ) - - mock_client = httpx.AsyncClient(transport=httpx.MockTransport(mock_omni_receiver)) - - dispatcher = WebhookDispatcher( - session_factory=session_factory, - settings=settings, - http_client=mock_client, - clock=lambda: now, - ) - - # 4. Dispatch batch - dispatched_count = await dispatcher.dispatch_batch() - assert dispatched_count == 1 - assert len(received_requests) == 1 - assert received_requests[0]["tenant_id"] == "tenant_1" - assert received_requests[0]["subject_ref"] == "lead_999" - assert received_requests[0]["data"]["status"] == "completed" - assert received_requests[0]["data"]["rule_score"] == 85 - - # 5. Verify outbox marked delivered - async with session_factory() as session: - updated_event = ( - await session.execute(select(OutboxEvent).where(OutboxEvent.run_id == run_id)) - ).scalar_one() - assert updated_event.delivery_status == OutboxDeliveryStatus.DELIVERED.value - assert updated_event.delivered_at == now - assert updated_event.last_http_status == 200 - - await dispatcher.stop() - await mock_client.aclose() - - -@pytest.mark.asyncio -async def test_scoring_request_cannot_contain_callback_url(migrated_db): - session_factory, engine, db_url = migrated_db - - from lead_scoring.config import ServiceCallerConfig - from lead_scoring.scoring import ScoringConfig - - scoring_config = ScoringConfig( - version="1.0.0", - base_score=0, - rule_weight=1.0, - ai_weight=0.0, - confidence=0.9, - confidence_threshold=0.8, - hitl_policy_version="1.0.0", - rules=(), - ) - - settings = Settings( - database_url=db_url, - tenant_allowlist=["tenant_1"], - scoring_configs=[scoring_config], - service_callers=[ - ServiceCallerConfig( - caller_id="test_caller", - token=SecretStr("valid_token"), - allowed_apps=["omni"], - allowed_features=["scoring"], - allowed_tenants=["tenant_1"], - ) - ], - ) - - app = create_app(settings=settings) - - async with app.router.lifespan_context(app): - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), - base_url="http://testserver", - ) as client: - headers = { - "Authorization": "Bearer valid_token", - "X-Tenant-Id": "tenant_1", - "Idempotency-Key": "test-idem-callback", - "X-DotB-App": "omni", - "X-DotB-Feature": "scoring", - "X-Tenant-VKey": "dummy-vkey", - } - # Attempt to inject callback_url in create request - payload_with_callback = { - "tenant_id": "tenant_1", - "subject_ref": "lead_123", - "scoring_config_version": "1.0.0", - "snapshot": { - "version": "1.0.0", - "captured_at": "2026-08-28T12:00:00Z", - "lead_profile": { - "ems_lead_id": "lead_123", - "custom_fields": {"course_interest": "DotB Course"}, - }, - }, - "callback_url": "https://attacker.com/webhook", - } - resp = await client.post( - "/v1/scoring-runs", json=payload_with_callback, headers=headers - ) - assert resp.status_code == 202 - run_id = resp.json()["run_id"] - - # Verify that in database, no callback_url is stored on scoring_runs - async with session_factory() as session: - run_row = await session.get(ScoringRun, run_id) - assert run_row is not None - assert "callback_url" not in run_row.snapshot_data - assert "callback_url" not in (run_row.effective_config or {}) - - -@pytest.mark.asyncio -async def test_stale_claim_cannot_overwrite_newer_delivery_state(migrated_db): - session_factory, _, _ = migrated_db - now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - - event_id = "00000000-0000-0000-0000-000000000777" - - # Worker 1 claims the event with token_1 - worker_1_token = "claim-token-worker-1" - - async with session_factory() as session: - event = OutboxEvent( - id=event_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id="run-777", - payload={"status": "completed"}, - occurred_at=now, - delivery_status=OutboxDeliveryStatus.DELIVERING.value, - claim_token=worker_1_token, - lease_expires_at=now + timedelta(seconds=10), - attempt_count=0, - ) - session.add(event) - await session.commit() - - # Time passes, Worker 1 stalls. Worker 2 reclaims and marks delivered - async with session_factory() as session: - # Worker 2 successfully delivers with token_2 - await session.execute( - update(OutboxEvent) - .where(OutboxEvent.id == event_id) - .values( - delivery_status=OutboxDeliveryStatus.DELIVERED.value, - delivered_at=now + timedelta(seconds=15), - claim_token=None, - lease_expires_at=None, - last_http_status=200, - ) - ) - await session.commit() - - # Worker 1 wakes up and attempts to record a failure using stale token_1 - stale_result = DeliveryResult( - classification=DeliveryClassification.DEAD_LETTER, - error_code="receiver_bad_request", - http_status=400, - detail="Stale worker error", - ) - - async with session_factory() as session: - recorded = await record_delivery_result( - session=session, - event=event, - result=stale_result, - max_attempts=5, - backoff_base_seconds=2.0, - backoff_max_seconds=60.0, - now=now + timedelta(seconds=20), - claim_token=worker_1_token, - ) - await session.commit() - # Should return False due to claim fence mismatch - assert recorded is False - - # Verify event remains DELIVERED and was NOT overwritten by stale worker - async with session_factory() as check_session: - updated = await check_session.get(OutboxEvent, event_id) - assert updated is not None - assert updated.delivery_status == OutboxDeliveryStatus.DELIVERED.value - assert updated.last_http_status == 200 - assert updated.last_error_code is None - - -@pytest.mark.asyncio -async def test_webhook_max_attempts_setting_controls_retry_limit(migrated_db): - session_factory, _, _ = migrated_db - now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - - event_id = "00000000-0000-0000-0000-000000000888" - token = "claim-token-max-attempts" - - async with session_factory() as session: - event = OutboxEvent( - id=event_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id="run-888", - payload={"status": "completed"}, - occurred_at=now, - delivery_status=OutboxDeliveryStatus.DELIVERING.value, - claim_token=token, - attempt_count=2, - ) - session.add(event) - await session.commit() - - # Setting max_attempts = 2: attempt_count 2 reaches max_attempts -> DEAD_LETTER - retry_result = DeliveryResult( - classification=DeliveryClassification.RETRYABLE, - error_code="receiver_rate_limited", - http_status=429, - detail="Rate limited", - ) - - async with session_factory() as session: - recorded = await record_delivery_result( - session=session, - event=event, - result=retry_result, - max_attempts=2, - backoff_base_seconds=2.0, - backoff_max_seconds=60.0, - now=now, - claim_token=token, - ) - await session.commit() - assert recorded is True - - async with session_factory() as check_session: - updated = await check_session.get(OutboxEvent, event_id) - assert updated is not None - assert updated.delivery_status == OutboxDeliveryStatus.DEAD_LETTER.value - assert updated.attempt_count == 2 - assert updated.last_error_code == "retries_exhausted" - - -@pytest.mark.asyncio -async def test_dispatch_non_200_2xx_classified_as_dlq(): - now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - - for status_code in [201, 202, 204]: - event = OutboxEvent( - id=f"00000000-0000-0000-0000-000000000{status_code}", - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id=f"run-{status_code}", - payload={"status": "completed"}, - occurred_at=now, - delivery_status=OutboxDeliveryStatus.DELIVERING.value, - ) - - mock_transport = httpx.MockTransport( - lambda request, sc=status_code: httpx.Response(sc, text="Non-200 2xx response") - ) - async with httpx.AsyncClient(transport=mock_transport) as client: - result = await dispatch_single_event( - event=event, - target_url="http://localhost:8080/api/v1/internal/lead-scoring/events", - signing_key_id="test-key", - signing_secret=TEST_SIGNING_SECRET, - timeout_seconds=5.0, - http_client=client, - now=now, - ) - - assert result.classification == DeliveryClassification.DEAD_LETTER - assert result.http_status == status_code - assert result.error_code == "invalid_receiver_response" - - -@pytest.mark.asyncio -async def test_crash_reclaims_are_bounded_and_eventually_dlq(migrated_db): - session_factory, _, _ = migrated_db - t0 = datetime(2026, 8, 28, 12, 20, tzinfo=UTC) - event_id = "00000000-0000-0000-0000-000000001201" - expected_max_attempts = 3 - - event = OutboxEvent( - id=event_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id="run-crash-test", - payload={ - "event_id": event_id, - "event_type": "lead_scoring.run.terminal", - "event_version": "1.0.0", - "tenant_id": "tenant-1", - "subject_ref": "lead-1", - "scoring_config_version": "1.0.0", - "run_id": event_id, - "run_created_at": t0.isoformat().replace("+00:00", "Z"), - "occurred_at": (t0 + timedelta(seconds=1)).isoformat().replace("+00:00", "Z"), - "data": {"status": "completed", "rule_score": 80}, - }, - occurred_at=t0, - ) - - async with session_factory() as session: - session.add(event) - await session.commit() - - # Simulate 5 claim/crash cycles (process death before result recording) - for index in range(5): - async with session_factory() as session: - await claim_outbox_events( - session, - batch_size=1, - lease_seconds=1, - now=t0 + timedelta(seconds=index * 2), - max_attempts=expected_max_attempts, - ) - await session.commit() - - async with session_factory() as session: - stored = await session.get(OutboxEvent, event_id) - assert stored is not None - assert stored.delivery_status == OutboxDeliveryStatus.DEAD_LETTER.value - assert stored.attempt_count >= expected_max_attempts - assert stored.last_error_code == "retries_exhausted" - assert stored.next_attempt_at is None - assert stored.claim_token is None - assert stored.lease_expires_at is None - - -@pytest.mark.asyncio -async def test_retry_followed_by_success_clears_next_attempt_at_and_errors(migrated_db): - session_factory, _, _ = migrated_db - t0 = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - event_id = "00000000-0000-0000-0000-000000000654" - - # Step 1: Initial event in PENDING state - event = OutboxEvent( - id=event_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id="run-retry-then-success", - payload={"status": "completed"}, - occurred_at=t0, - delivery_status=OutboxDeliveryStatus.PENDING.value, - ) - async with session_factory() as session: - session.add(event) - await session.commit() - - # Step 2: Claim event (Attempt 1) - async with session_factory() as session: - claimed = await claim_outbox_events( - session=session, - batch_size=1, - lease_seconds=30.0, - now=t0, - max_attempts=5, - ) - await session.commit() - assert len(claimed) == 1 - event_claimed = claimed[0] - assert event_claimed.attempt_count == 1 - token_1 = event_claimed.claim_token - - # Step 3: Attempt 1 fails with 429 Retryable - retry_result = DeliveryResult( - classification=DeliveryClassification.RETRYABLE, - http_status=429, - error_code="receiver_rate_limited", - detail="Rate limited", - ) - async with session_factory() as session: - recorded = await record_delivery_result( - session=session, - event=event_claimed, - result=retry_result, - max_attempts=5, - backoff_base_seconds=2.0, - backoff_max_seconds=60.0, - now=t0, - claim_token=token_1, - ) - await session.commit() - assert recorded is True - - # Check that next_attempt_at is set to t0 + 2s - async with session_factory() as session: - ev_after_retry = await session.get(OutboxEvent, event_id) - assert ev_after_retry is not None - assert ev_after_retry.delivery_status == OutboxDeliveryStatus.PENDING.value - assert ev_after_retry.next_attempt_at == t0 + timedelta(seconds=2.0) - assert ev_after_retry.last_error_code == "receiver_rate_limited" - - # Step 4: Claim event at next_attempt_at (Attempt 2) - t1 = t0 + timedelta(seconds=2.0) - async with session_factory() as session: - claimed_2 = await claim_outbox_events( - session=session, - batch_size=1, - lease_seconds=30.0, - now=t1, - max_attempts=5, - ) - await session.commit() - assert len(claimed_2) == 1 - event_claimed_2 = claimed_2[0] - assert event_claimed_2.attempt_count == 2 - token_2 = event_claimed_2.claim_token - - # Step 5: Attempt 2 succeeds with HTTP 200 DELIVERED - success_result = DeliveryResult( - classification=DeliveryClassification.DELIVERED, - http_status=200, - ingest_status="accepted", - ) - async with session_factory() as session: - recorded_2 = await record_delivery_result( - session=session, - event=event_claimed_2, - result=success_result, - max_attempts=5, - backoff_base_seconds=2.0, - backoff_max_seconds=60.0, - now=t1, - claim_token=token_2, - ) - await session.commit() - assert recorded_2 is True - - # Step 6: Verify observable state: DELIVERED, next_attempt_at is None, error metadata cleared - async with session_factory() as session: - ev_final = await session.get(OutboxEvent, event_id) - assert ev_final is not None - assert ev_final.delivery_status == OutboxDeliveryStatus.DELIVERED.value - assert ev_final.next_attempt_at is None - assert ev_final.lease_expires_at is None - assert ev_final.claim_token is None - assert ev_final.last_error_code is None - assert ev_final.last_error_detail is None - assert ev_final.delivered_at == t1 - assert ev_final.last_http_status == 200 - - -@pytest.mark.asyncio -async def test_slow_first_item_does_not_cause_lease_exhaustion_for_subsequent_items(migrated_db): - session_factory, _, _ = migrated_db - t0 = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) - - event_1_id = "00000000-0000-0000-0000-000000000901" - event_2_id = "00000000-0000-0000-0000-000000000902" - - async with session_factory() as session: - session.add( - OutboxEvent( - id=event_1_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id="run-slow-1", - payload={"status": "completed"}, - occurred_at=t0, - delivery_status=OutboxDeliveryStatus.PENDING.value, - ) - ) - session.add( - OutboxEvent( - id=event_2_id, - event_type="lead_scoring.run.terminal", - event_version="1.0.0", - run_id="run-slow-2", - payload={"status": "completed"}, - occurred_at=t0 + timedelta(seconds=1), - delivery_status=OutboxDeliveryStatus.PENDING.value, - ) - ) - await session.commit() - - simulated_time = t0 - - def current_time() -> datetime: - return simulated_time - - settings = Settings( - webhook_subscriber_url="http://localhost:8080/api/v1/internal/lead-scoring/events", - webhook_signing_secret=SecretStr(TEST_SIGNING_SECRET), - webhook_signing_key_id="test-key", - webhook_lease_seconds=5.0, - webhook_max_attempts=2, - webhook_batch_size=2, - ) - - # Mock receiver where first item takes 10s (exceeding lease 5s of first item) - def handle_request(request: httpx.Request) -> httpx.Response: - nonlocal simulated_time - body = json.loads(request.content.decode("utf-8")) - if body.get("event_id") == event_1_id: - # Advance time by 10s during dispatch of item 1 - simulated_time += timedelta(seconds=10.0) - return httpx.Response( - 200, - json={"status": "accepted", "event_id": request.headers.get("x-dotb-webhook-id", "")}, - ) - - mock_client = httpx.AsyncClient(transport=httpx.MockTransport(handle_request)) - - dispatcher = WebhookDispatcher( - session_factory=session_factory, - settings=settings, - http_client=mock_client, - clock=current_time, - ) - - processed = await dispatcher.dispatch_batch() - assert processed == 2 - - # Verify both events delivered successfully without premature exhaustion - async with session_factory() as session: - ev1 = await session.get(OutboxEvent, event_1_id) - assert ev1 is not None - assert ev1.delivery_status == OutboxDeliveryStatus.DELIVERED.value - assert ev1.attempt_count == 1 - - ev2 = await session.get(OutboxEvent, event_2_id) - assert ev2 is not None - assert ev2.delivery_status == OutboxDeliveryStatus.DELIVERED.value - # Item 2 was claimed at t0 + 10s, attempt_count was 1 (not reclaimed/exhausted) - assert ev2.attempt_count == 1 - assert ev2.next_attempt_at is None - - await dispatcher.stop() - await mock_client.aclose() diff --git a/old_repo/uv.lock b/old_repo/uv.lock deleted file mode 100644 index 2ee5c66..0000000 --- a/old_repo/uv.lock +++ /dev/null @@ -1,1165 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.13" - -[[package]] -name = "alembic" -version = "1.19.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mako" }, - { name = "sqlalchemy" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/16/2b/e4153978368de59918115c9e01d3ebf58a558a7285efa7e960c383c4b59a/alembic-1.19.1.tar.gz", hash = "sha256:e0fca0518118c78acc493e31bcb5402f190057aaf6df8b5b95ce94c4789cf648", size = 2070816, upload-time = "2026-08-08T16:32:01.565Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/89/e62cc37b69ad357cc8ecd6e7367f5245f523d3cbb338a66197212bdf6749/alembic-1.19.1-py3-none-any.whl", hash = "sha256:b39018cb3d9413a19cbd54cf3c02ad33998641f0538eb77413a488a21c3e14be", size = 265946, upload-time = "2026-08-08T16:32:03.153Z" }, -] - -[[package]] -name = "annotated-doc" -version = "0.0.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, -] - -[[package]] -name = "annotated-types" -version = "0.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, -] - -[[package]] -name = "anyio" -version = "4.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, -] - -[[package]] -name = "asyncpg" -version = "0.31.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, - { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, - { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, - { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, - { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, - { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, - { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, - { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, - { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, - { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, - { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, - { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, - { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, - { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, - { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, - { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, - { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, - { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, - { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, - { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, - { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, - { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, -] - -[[package]] -name = "certifi" -version = "2026.7.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, - { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, - { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, - { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, - { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, - { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, - { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, - { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, - { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, - { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, - { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, - { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, - { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, - { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, - { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, - { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, - { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, - { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, - { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, - { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, - { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, - { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, - { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, - { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, - { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, - { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, - { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, - { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, - { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, - { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, - { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, - { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, - { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, - { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, - { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, - { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, - { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, - { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, - { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, - { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, - { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, - { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, - { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, - { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, - { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, - { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, - { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, - { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, - { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, - { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, - { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, - { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, - { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, - { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, - { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, - { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, - { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, - { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, - { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, - { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, - { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, - { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, - { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, - { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, - { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, - { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, - { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, - { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, - { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, - { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, - { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, - { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, - { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, - { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, - { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, - { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, - { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, - { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, - { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, - { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, - { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, - { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, - { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, - { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, - { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, - { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, - { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, -] - -[[package]] -name = "click" -version = "8.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "docker" -version = "7.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "requests" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/88/7f/731ff914b0255d3d065f45fd4e626d4b8c95dbcbaada049f337a6ac16410/docker-7.2.0.tar.gz", hash = "sha256:cebb93773d334f778e023a7ee352a8d6e13ab1bd3b863a4d4a59dec897df43ac", size = 118731, upload-time = "2026-07-09T14:53:46.39Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775, upload-time = "2026-07-09T14:53:45.224Z" }, -] - -[[package]] -name = "fastapi" -version = "0.141.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, -] - -[[package]] -name = "greenlet" -version = "3.5.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, - { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, - { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, - { url = "https://files.pythonhosted.org/packages/0e/84/eaa476d6bf3816828d0d70e80dcc36bf30a058233bd889e707e693f6e860/greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42", size = 632726, upload-time = "2026-08-10T14:30:09.874Z" }, - { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, - { url = "https://files.pythonhosted.org/packages/d0/f2/0cc2849ede68579291e9c59b3ab6ec1958f98681cca5b14d8fc75bf674a4/greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b", size = 434966, upload-time = "2026-08-10T14:30:03.729Z" }, - { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, - { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, - { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, - { url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260, upload-time = "2026-08-10T13:27:50.795Z" }, - { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, - { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, - { url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" }, - { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" }, - { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, - { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, - { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, - { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, - { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, - { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" }, - { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, - { url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" }, - { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, - { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, - { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, - { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, - { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, - { url = "https://files.pythonhosted.org/packages/65/53/4e13642efc4d7ad6554ecb2242a5be42666b2e1a067323e88dfc0124a04b/greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76", size = 681436, upload-time = "2026-08-10T14:30:14.839Z" }, - { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, - { url = "https://files.pythonhosted.org/packages/cd/32/188447c9a468d6977d2989397226b0c6b65ab6f4cf943f931643328512fc/greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474", size = 487404, upload-time = "2026-08-10T14:30:08.903Z" }, - { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, - { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, - { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, - { url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" }, - { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, - { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, - { url = "https://files.pythonhosted.org/packages/ce/f4/e450a68a152f819491d8c7df6a8254e761d87e6a78759268961f8c5bd4dd/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1", size = 686022, upload-time = "2026-08-10T14:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, - { url = "https://files.pythonhosted.org/packages/18/23/17e63d6bf3b9c9b9dbea981b7f643a71f79603bdfb4f1c3a9cf353e22aed/greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f", size = 516951, upload-time = "2026-08-10T14:30:10.907Z" }, - { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, - { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, - { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, - { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httptools" -version = "0.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, - { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, - { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, - { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, - { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, - { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, - { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, - { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, - { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, - { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, - { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, - { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "idna" -version = "3.19" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "lead-scoring" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "alembic" }, - { name = "asyncpg" }, - { name = "fastapi" }, - { name = "httpx" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "sqlalchemy", extra = ["asyncio"] }, - { name = "uvicorn", extra = ["standard"] }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "ruff" }, - { name = "testcontainers" }, - { name = "ty" }, -] - -[package.metadata] -requires-dist = [ - { name = "alembic", specifier = ">=1.13.0" }, - { name = "asyncpg", specifier = ">=0.29.0" }, - { name = "fastapi", specifier = ">=0.115.0" }, - { name = "httpx", specifier = ">=0.27.0" }, - { name = "pydantic", specifier = ">=2.8.0" }, - { name = "pydantic-settings", specifier = ">=2.4.0" }, - { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.30" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.370" }, - { name = "pytest", specifier = ">=8.0.0" }, - { name = "pytest-asyncio", specifier = ">=0.23.0" }, - { name = "ruff", specifier = ">=0.5.0" }, - { name = "testcontainers", extras = ["postgres"], specifier = ">=4.0.0" }, - { name = "ty", specifier = ">=0.0.74" }, -] - -[[package]] -name = "mako" -version = "1.4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, -] - -[[package]] -name = "nodeenv" -version = "1.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, -] - -[[package]] -name = "packaging" -version = "26.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pydantic" -version = "2.13.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.46.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, - { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, - { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, - { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, - { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, - { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, - { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, - { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, - { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, - { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, - { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, - { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, - { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, - { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, - { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, - { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, - { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, - { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, - { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, - { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, - { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, - { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, - { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, - { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, - { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, - { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, - { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, - { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, - { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, - { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, - { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, - { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, -] - -[[package]] -name = "pydantic-settings" -version = "2.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, -] - -[[package]] -name = "pygments" -version = "2.21.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, -] - -[[package]] -name = "pyright" -version = "1.1.411" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nodeenv" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, -] - -[[package]] -name = "pytest" -version = "9.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, -] - -[[package]] -name = "pytest-asyncio" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, -] - -[[package]] -name = "pywin32" -version = "312" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, - { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, - { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, - { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, - { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, - { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, - { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, - { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - -[[package]] -name = "requests" -version = "2.34.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, -] - -[[package]] -name = "ruff" -version = "0.16.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, - { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, - { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, - { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, - { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, - { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, - { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, - { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, - { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, - { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, - { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, - { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, - { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, -] - -[[package]] -name = "sqlalchemy" -version = "2.0.52" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/18/e30c6fe1eca1bf34a39fbdd6066121cc9974c850faf6f349eac563697a26/sqlalchemy-2.0.52-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43", size = 2167724, upload-time = "2026-08-11T20:58:12.679Z" }, - { url = "https://files.pythonhosted.org/packages/d0/56/2e17d161a4f7ecc1c2ffb93e607b4e1898bb551b451b283235acb8f6ce47/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1", size = 3321189, upload-time = "2026-08-11T21:02:41.932Z" }, - { url = "https://files.pythonhosted.org/packages/cf/b8/8490916e893f3f8d74dc9cc54c078619364999dee37047a188e73abbc852/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c", size = 3338185, upload-time = "2026-08-11T21:17:02.597Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f7/752cc8ee453da222829b3f5c4613614bf750d97429363b70414fa10478e4/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15", size = 3271698, upload-time = "2026-08-11T21:02:43.963Z" }, - { url = "https://files.pythonhosted.org/packages/51/e6/074ade0c07b9e4c8e8bca46820320ed94df9702afdb6f2af06623068d2e6/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057", size = 3308936, upload-time = "2026-08-11T21:17:04.172Z" }, - { url = "https://files.pythonhosted.org/packages/66/07/557c0d04716705599227945ac14e0a17ad0338e899f37d8c2ddff4dcc663/sqlalchemy-2.0.52-cp313-cp313-win32.whl", hash = "sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d", size = 2127308, upload-time = "2026-08-11T21:14:53.879Z" }, - { url = "https://files.pythonhosted.org/packages/96/4e/226eda27654318ce525d043025221f689abef883da2c7126f9065121618c/sqlalchemy-2.0.52-cp313-cp313-win_amd64.whl", hash = "sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751", size = 2153876, upload-time = "2026-08-11T21:14:55.527Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f5/71cb30af58c9b80a4e1fac0b73bb48f86d497a774a6a2eb6d2f1e657bb73/sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9", size = 2169537, upload-time = "2026-08-11T20:58:13.855Z" }, - { url = "https://files.pythonhosted.org/packages/4c/93/d07ebd645d1b07b6b5ed63450a70f063a346a7e0f2c8810daf2e532400cb/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e", size = 3319606, upload-time = "2026-08-11T21:02:45.829Z" }, - { url = "https://files.pythonhosted.org/packages/ae/5c/290c84c7c2566ecd3b65baaae0fddec9bc33b033b398a06123bb86fbfc6e/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437", size = 3323642, upload-time = "2026-08-11T21:17:05.675Z" }, - { url = "https://files.pythonhosted.org/packages/13/f5/2cc160590ca49173359557880b92a0572293ccb899e8f6cedf150c5a3ddf/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8", size = 3268125, upload-time = "2026-08-11T21:02:47.649Z" }, - { url = "https://files.pythonhosted.org/packages/35/f3/ea8933fc9f7d1353e9c2ff9965eae687c4cef181120574591ed2fa0633e1/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd", size = 3289516, upload-time = "2026-08-11T21:17:07.267Z" }, - { url = "https://files.pythonhosted.org/packages/45/67/05cf86541c1e1716fca1e4a996954a439cd74501707cda607fb7cb02ef50/sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2", size = 2130249, upload-time = "2026-08-11T21:14:57.273Z" }, - { url = "https://files.pythonhosted.org/packages/96/d7/8ac6ffa1e36169e762ef65bd835046abb2251b1bc17f8f6708e14ed8d31f/sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d", size = 2156718, upload-time = "2026-08-11T21:14:58.667Z" }, - { url = "https://files.pythonhosted.org/packages/dc/4b/e01a737eef378e734cc6394a82248a6ce13b167dfa36c731075ce9fc9c64/sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582", size = 2190344, upload-time = "2026-08-11T19:53:21.393Z" }, - { url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" }, -] - -[package.optional-dependencies] -asyncio = [ - { name = "greenlet" }, -] - -[[package]] -name = "starlette" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, -] - -[[package]] -name = "testcontainers" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docker" }, - { name = "python-dotenv" }, - { name = "typing-extensions" }, - { name = "urllib3" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4b/13/2cc466bddf26d0085f30a2b2bd56b7f8708b54a54db833eec97c5c69129b/testcontainers-4.15.0.tar.gz", hash = "sha256:085cde086337632e19002719460b7b80bbab2bdd51bb3ea04f77d0de96504706", size = 95340, upload-time = "2026-07-24T23:08:01.731Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/7e/424aac8b355597835deb333e757a0e94b5ccf38ad00f07fe6ed1f4e17c88/testcontainers-4.15.0-py3-none-any.whl", hash = "sha256:8796c14e76604031ad39cf0ed3b8e9806283a1fbf5270965c2b1c594caa31b74", size = 160771, upload-time = "2026-07-24T23:08:00.13Z" }, -] - -[[package]] -name = "ty" -version = "0.0.74" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/0f/c767853e88567a2ec7e996dd95e3105b1bc62c95d103689311ef0f4a603c/ty-0.0.74.tar.gz", hash = "sha256:da14344fc8625fc9ff359bafb856ad575636ea86d9bb6a629b146bff27b380e6", size = 6786318, upload-time = "2026-08-22T15:05:54.054Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/95/6ded58bc97885c6d88fa1f9cd815031489200738f961cbf0466663213f80/ty-0.0.74-py3-none-linux_armv6l.whl", hash = "sha256:8969ef4e508debf00cf58f9ea85a539f799b1732c59cdfcecd037630b9755b30", size = 12790043, upload-time = "2026-08-22T15:05:05.015Z" }, - { url = "https://files.pythonhosted.org/packages/d9/8a/5e323603b6ab8731144421877ee8a0f8ac5a5511e67857127caa09f6730e/ty-0.0.74-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:51fb6cf5b98e1e1140825b2430943f78d744876a735231656eafbb4c3f7eca3c", size = 12371748, upload-time = "2026-08-22T15:05:08.609Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/ee72e08cb705281e8d8c42917dd577aa598a8a098008495fda5176ee3f6e/ty-0.0.74-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ebe60b1f0a948c793d6c77fc9e9ddda599e4f023c04ab16e8e03bcb428c3fa0", size = 12282403, upload-time = "2026-08-22T15:05:11.448Z" }, - { url = "https://files.pythonhosted.org/packages/da/b3/fd935b694ff68bc278af50f7ad04770b36ce6306399baef7e1847b553a9d/ty-0.0.74-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa97f407a695c890a53615966a663c7d2167e2cabe88db7ca1a24d62635cdfc8", size = 12345164, upload-time = "2026-08-22T15:05:14.19Z" }, - { url = "https://files.pythonhosted.org/packages/54/5c/5b5825268e029ebb164c909780103dbbae367f069801410068bf1cef29b3/ty-0.0.74-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:673ddb733d4a0db31385ba1ed9ff1f6bd9dc5565413ce57b1ca5ac4c7803da5d", size = 12556646, upload-time = "2026-08-22T15:05:16.994Z" }, - { url = "https://files.pythonhosted.org/packages/56/e7/515914e571d62ce0101744fed3f881936eeb1b30dc37beb72b4f7ca1e289/ty-0.0.74-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1028e7c6b4f6145e9704552f43a5fffdcd51b42263ffdcd9c9677762bc395a4a", size = 13311653, upload-time = "2026-08-22T15:05:20.254Z" }, - { url = "https://files.pythonhosted.org/packages/b0/07/d1452babb6f9266c2122cabc095180b70ed306fb770b2996753814d2237d/ty-0.0.74-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79841a8890493021fb308772474983316eb91f7b56cb227a6a05a06b262a36f0", size = 13768284, upload-time = "2026-08-22T15:05:23.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/60/8d4a2fc7842a47210a1cb0a16a187d9de39ad5d509a00fb74c1c073afcde/ty-0.0.74-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94859d321f3c6a6c8f7bfc3f40e8319cda7e6e012e613440f3dfd145d5010e2e", size = 13422306, upload-time = "2026-08-22T15:05:26.248Z" }, - { url = "https://files.pythonhosted.org/packages/de/76/ebbc269a8c4efcc4d44624993bd188145f20d60ebda9680b15aaec42cc50/ty-0.0.74-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:970a8b2c09ff3be04c8a1c6767332d861be4fce85efe7bb205e4ade7c8655274", size = 12970637, upload-time = "2026-08-22T15:05:29.15Z" }, - { url = "https://files.pythonhosted.org/packages/9e/dd/b99f7236acbf856780ca1779a48143d2d9f2c24d7f531a0ce15a022b8a87/ty-0.0.74-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:795f763b3ded85574c2c2846a6fb8acf2aa76e9e83d761143e92b1f0c7ffa2cd", size = 13344891, upload-time = "2026-08-22T15:05:32.033Z" }, - { url = "https://files.pythonhosted.org/packages/0b/d7/9ff7449a4c7e6428f2c6f298e74cf24b70668f29d45c249507a723ff3782/ty-0.0.74-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:dc086db5367d912c31c0cc872deb7387290e779a4b9b54fcb944673a7cd52c7b", size = 12395272, upload-time = "2026-08-22T15:05:34.702Z" }, - { url = "https://files.pythonhosted.org/packages/b1/dd/b23a5b6b35d37df89dc8dc5daa09efd9245a668b50c4c81c25de21567dc1/ty-0.0.74-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0314d7b391cf684e47c2fa093d2ce4c597cfc9b01d9a315fe204aed6359b271b", size = 12573079, upload-time = "2026-08-22T15:05:37.683Z" }, - { url = "https://files.pythonhosted.org/packages/23/c5/ccba16239d6129533c8b3603458d0f4dd2ba69478e47059073968e74261d/ty-0.0.74-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c4a45dd2e991e8bdae82ba78c8cd051b253f60bc71a6536598fa3ef580b4fc9b", size = 12832506, upload-time = "2026-08-22T15:05:40.505Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1c/2390912634dff4f341f97b397f2aee341ff062be0a66cda37d59375454f2/ty-0.0.74-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:210e2eac6b018fb934e2b8dac3956a0ba076a3fb1fa6f135058c825e5b759b81", size = 13154752, upload-time = "2026-08-22T15:05:43.355Z" }, - { url = "https://files.pythonhosted.org/packages/c4/33/a8c12188227e6f74f91853a7374e01ed81d6ad21c16c8b70e92dbebfe46a/ty-0.0.74-py3-none-win32.whl", hash = "sha256:db0bb6a8f098ef9bd1be861f73b4f7c0320d40d4c05c7ae0a8677d4e7aa4f6e5", size = 12130002, upload-time = "2026-08-22T15:05:46.058Z" }, - { url = "https://files.pythonhosted.org/packages/21/5c/064f28ccb9c234cfce5a2f7aa69a256663d5ae5bb0290b3a9706cc4d1e4c/ty-0.0.74-py3-none-win_amd64.whl", hash = "sha256:bebff181515255b3c78bd2e7693ae66fab6064ad4feea2065c68bc01022aa678", size = 12771435, upload-time = "2026-08-22T15:05:48.811Z" }, - { url = "https://files.pythonhosted.org/packages/fe/06/d6becdaca0315346c26b6df97cb0eafa81de4f870945d6989e88704374ed/ty-0.0.74-py3-none-win_arm64.whl", hash = "sha256:1a3469eaaf8c85b1c0a15bede25d36daea4b09fce1d913e965b24e24b3f1d6c6", size = 12558299, upload-time = "2026-08-22T15:05:51.543Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, -] - -[[package]] -name = "urllib3" -version = "2.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.52.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, -] - -[package.optional-dependencies] -standard = [ - { name = "httptools" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, - { name = "watchfiles" }, - { name = "websockets" }, -] - -[[package]] -name = "uvloop" -version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, - { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, - { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, - { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, - { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, - { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, - { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, - { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, - { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, - { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, - { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, - { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, -] - -[[package]] -name = "watchfiles" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, - { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, - { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, - { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, - { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, - { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, - { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, - { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, - { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, - { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, - { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, - { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, - { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, - { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, - { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, - { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, - { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, - { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, - { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, - { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, - { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, - { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, - { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, - { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, - { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, - { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, - { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, - { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, - { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, - { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, - { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, - { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, - { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, - { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, - { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, - { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, - { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, - { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, - { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, - { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, - { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, - { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, - { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, - { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, - { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, - { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, - { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, - { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, - { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, -] - -[[package]] -name = "websockets" -version = "17.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/31/5f6450a7879f4f063ef08897cc385ea3ce3f1fe17f08b11e3fd959abdf27/websockets-17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a0162a6372110a5601cb5c9fd826635cedf69f3e110c545dd19774e040b970e", size = 217006, upload-time = "2026-08-26T14:56:10.509Z" }, - { url = "https://files.pythonhosted.org/packages/d0/2a/c1b006fc861695d2aa4e35327b842015ce1d98cf8f99241829b3d6460bfc/websockets-17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:829dba1bc049779de9b332088c1a6a9858e96bd67e50b6b644a95e02b67836bc", size = 214690, upload-time = "2026-08-26T14:56:11.681Z" }, - { url = "https://files.pythonhosted.org/packages/46/69/66e5b7d01445e0eeb1d4ab419c30315f2c90cf7a8a8cd4ecc47f894dba54/websockets-17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd8f47dbf2e8adb15c847215f83436de3fdb120b51fdae0fbbdf69fd97a3ad80", size = 214947, upload-time = "2026-08-26T14:56:12.923Z" }, - { url = "https://files.pythonhosted.org/packages/07/ce/033cafe2d2538562efa876b9149a2c7a0f7787870a4b1bb6e28adc9ceb6b/websockets-17.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f4c0377a83e163a303514fdfab501dbe379bdc13e5b9312a91d112658b29dce", size = 224329, upload-time = "2026-08-26T14:56:14.212Z" }, - { url = "https://files.pythonhosted.org/packages/34/c7/e1c2e8a67f6cc0aa43abe0046fb3b7a020980649e6a843751dc7ce9eb170/websockets-17.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c3241d684a76eaaef8b2dc789afde4343cd3aad55ea81e4e8ab3605b529bae51", size = 224611, upload-time = "2026-08-26T14:56:15.702Z" }, - { url = "https://files.pythonhosted.org/packages/be/de/07c6d48eb3d2069709410c851e7de10ab83d752c4bd09862899627c2729b/websockets-17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5f5c7a893507d0e83a80b88aefd6522f7e882cd53f9722c6f23f5a020c9557c", size = 225848, upload-time = "2026-08-26T14:56:16.962Z" }, - { url = "https://files.pythonhosted.org/packages/f3/dd/3c68572d20509648cc2fb6f50ccf3deeb4b87270f2c8966e99476e278ea3/websockets-17.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00bf34b64501e3477e81fc281532ff3cbf4da26633c10b63979d5085d46602d3", size = 227290, upload-time = "2026-08-26T14:56:18.204Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4a/8f6651c8a22093539c9215af0c5bbf217b87b382c99d2112039b92d593c2/websockets-17.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ce0305b702b20d1e1d60a9aaace6bc89970e1753565543f310d549eab22c2435", size = 226476, upload-time = "2026-08-26T14:56:19.459Z" }, - { url = "https://files.pythonhosted.org/packages/f5/be/f6fc33cea86b1127fd1297b18c107e81580ab55a73a39f9a934441ef321f/websockets-17.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29176d8b429cfa0fa443c473878d37a5c06cfd0cb36b71ba4314accc71e05906", size = 225233, upload-time = "2026-08-26T14:56:20.939Z" }, - { url = "https://files.pythonhosted.org/packages/cb/83/65edaf05f7c9b1dea82f4d252fdc37706a84571646f06119a27b0a16fe19/websockets-17.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3709a1ab30b4b922027d22f68d2b61a0656a91680ac894a537624e6be7dd7f7c", size = 222488, upload-time = "2026-08-26T14:56:22.208Z" }, - { url = "https://files.pythonhosted.org/packages/07/42/d1169c2f7f1f0032b0d4b0c00f0711a070cd7c735de37bfeb876bc0f9606/websockets-17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43bd0c1ceb924d67f5c1a5254d8361dd9d94246e6331a726064dfa2917880780", size = 225295, upload-time = "2026-08-26T14:56:23.445Z" }, - { url = "https://files.pythonhosted.org/packages/a6/f4/64e2a386c3899b917c2933225c9b47887874229d159797f3bf1a11c20d51/websockets-17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:1fce0f43e0d41422e0b2cad6561e1970df22f212f4c7e884967df7cf591b031c", size = 223891, upload-time = "2026-08-26T14:56:24.647Z" }, - { url = "https://files.pythonhosted.org/packages/26/b3/dfb5c482f7e310a3432fdbb045ddfe6d34114680e89a233d4ff900a32961/websockets-17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4031152769179ab8dcdeafc7b0e58052a49117560a28671700b47b2c7b717aad", size = 224661, upload-time = "2026-08-26T14:56:26.027Z" }, - { url = "https://files.pythonhosted.org/packages/a4/cf/94865130a336029f46412adc127c4fbe380f46172b90ce251369e35c4302/websockets-17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a06f3b5085176763182449559e20391d7ce616a8972a9f7a33deda87ea6d4f3c", size = 225766, upload-time = "2026-08-26T14:56:27.455Z" }, - { url = "https://files.pythonhosted.org/packages/96/34/eb8c658f86dfe562ed49a887a27424bfe9e618c26ea6f865b093d075d3a6/websockets-17.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:77b37cceca17291897c3c73bd30a7c7c7909593554b5da574ec852af83c1742a", size = 223323, upload-time = "2026-08-26T14:56:28.807Z" }, - { url = "https://files.pythonhosted.org/packages/1b/7e/2629609652ece5ca0c7ac235927dd4511b08131e3a5d53439b798fddf002/websockets-17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d8e83333385cac6030a5167fd18bf96cc6c58b914c308e683f05b0cf94bc8dd0", size = 224276, upload-time = "2026-08-26T14:56:29.991Z" }, - { url = "https://files.pythonhosted.org/packages/a1/6b/8525737fe840b38e5f40956c198fb586a4fac1e07144d41a5b949b989cf8/websockets-17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:073c5c3f7e127041fa9d34a9e29ceefee8c3cafbd267ed2927318f425144380d", size = 224558, upload-time = "2026-08-26T14:56:31.184Z" }, - { url = "https://files.pythonhosted.org/packages/74/ab/3a958c6cbcf74b118f601c20a80ac8bd5e8dfec0bcf7345116feaeefb121/websockets-17.1-cp313-cp313-win32.whl", hash = "sha256:2afb58c7ba48b329d56769f8dfd89f394efe587b65ef806bae810a484d6d3608", size = 217475, upload-time = "2026-08-26T14:56:32.431Z" }, - { url = "https://files.pythonhosted.org/packages/22/36/fb521f0f2994c25509651f169efe5582dddd8713d57a0757ba87859372ef/websockets-17.1-cp313-cp313-win_amd64.whl", hash = "sha256:0340bbef6bfbe16da888b3983d666a4db4954ac3253c38f13bc7aba0c7db5a2f", size = 217784, upload-time = "2026-08-26T14:56:33.608Z" }, - { url = "https://files.pythonhosted.org/packages/68/92/9b8419584681a12a7534b746dfb2737c466efe2455483e2fbf8b941a04ec/websockets-17.1-cp313-cp313-win_arm64.whl", hash = "sha256:7a72efa3bf4fa3a6669a54420a472ad056da3973d827f10e3a536da463f926c2", size = 217715, upload-time = "2026-08-26T14:56:34.865Z" }, - { url = "https://files.pythonhosted.org/packages/90/0d/500cf5daea09d4669dff3a7d67159094a0bd6c4ef130381404f6edd3eb5f/websockets-17.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0c9982938980e086da59f70d05f9418cd143401a601a0faac10fa48f7bb1cd3e", size = 217048, upload-time = "2026-08-26T14:56:36.03Z" }, - { url = "https://files.pythonhosted.org/packages/97/12/5b12c6168aa269cffbfd24d177cd492b130120403a418c7e89462e27b4ac/websockets-17.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:57b39dc8541cf7ed3f639da82bf7451060483967f9e733da1f8173e4095f0642", size = 214737, upload-time = "2026-08-26T14:56:37.43Z" }, - { url = "https://files.pythonhosted.org/packages/0c/36/e453e5106e4e2416f008ac222837c2f1637a063b08008afcd1088889b631/websockets-17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:96abdecbaae746851b87c3a36cb4a661df93ca3d92f114270f79228bf1d00de6", size = 214955, upload-time = "2026-08-26T14:56:38.71Z" }, - { url = "https://files.pythonhosted.org/packages/dd/30/0204bb86176db02cdfc678ce65ed808a66fab87d250ce61a8790800a60b0/websockets-17.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9fc873e239c5abeb150bc24dbd1a7af23a9254526383ce0a077f5e20adbeb19", size = 224331, upload-time = "2026-08-26T14:56:39.924Z" }, - { url = "https://files.pythonhosted.org/packages/46/c8/d8372256e00c4e3cab1115c45075d1eeedb642a3f2b42bd70c4deae03f06/websockets-17.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f42912fa9eb4cb7c7ec9fde9b3332ba339eb8a8811981043d4029599f3d950b", size = 224685, upload-time = "2026-08-26T14:56:41.169Z" }, - { url = "https://files.pythonhosted.org/packages/12/7d/650355b8f67f908ff99603351d4458d1a0b787d627950a47c38db7e25308/websockets-17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f98bf378d7a5be047a044a1a27c987a8f355e10e3b5754617dbe756248cbc5ce", size = 225927, upload-time = "2026-08-26T14:56:42.359Z" }, - { url = "https://files.pythonhosted.org/packages/34/6c/a9ffa5b903579eed76017870f055d75ecc73988d9d0c9b65a92ba0bf2a27/websockets-17.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d334d11398086bb5559606cb42d51c013ea7c061c7db701521392373d3c087f5", size = 227300, upload-time = "2026-08-26T14:56:43.538Z" }, - { url = "https://files.pythonhosted.org/packages/9b/5d/4551c2269066af7481ee44605a0813770961615b5b5da3e87a8f5cb859ea/websockets-17.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c27336b1a0ac56569493e858497870347854372395f50483725f8cdacc5a45c", size = 226533, upload-time = "2026-08-26T14:56:44.669Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/237a99233e5c445759a613831b3a92e91905afc064dc3bd0ad33c35fd1e2/websockets-17.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67258b00302a5aaf0b267771c7014b13429abd7ea17eebc4c55bd935ff101555", size = 225280, upload-time = "2026-08-26T14:56:45.83Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b5/e9407a91613d1d1cd932414143a1012096b26674a782fc55a0bd23217ee4/websockets-17.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:455ffeea0879d313205df1e745e5883e1feb7f31ecd26be882f5f0babd3db04f", size = 222540, upload-time = "2026-08-26T14:56:47.053Z" }, - { url = "https://files.pythonhosted.org/packages/db/d2/db76628db0577b783205d9779f64d8e373416b04c62d1546be4b75dc8540/websockets-17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f7233eaf441a345a5943a929fd4b5ea3278f11aed35a9ed0f3106b8cb3ca846a", size = 225354, upload-time = "2026-08-26T14:56:48.32Z" }, - { url = "https://files.pythonhosted.org/packages/a9/4c/2174181c067b89a74ae18e2650c2ac29959f4b796afe876ab3f4d30d642c/websockets-17.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c65da239a5ad553619804c1f9d65c1a0b3005381c6158ee14da2c7444cbd0c78", size = 223867, upload-time = "2026-08-26T14:56:49.579Z" }, - { url = "https://files.pythonhosted.org/packages/df/75/274decb9a8253561b5be3261e02a6676fc8ecdf31e95b722e53d5bfb8fd2/websockets-17.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fa1ffa08c81a4f809cdab6129f8e55bee4650b9d6d3461019dda73aacd146b6", size = 224652, upload-time = "2026-08-26T14:56:50.885Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e6/49824f1fb4db7656d2f7492b1d8be16147b759d909490e32f4776843ee64/websockets-17.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:406b8107943a43ef4649b1e0cb0cdc052bbf08fe1c8905a623c4af9586e5cebb", size = 225822, upload-time = "2026-08-26T14:56:52.356Z" }, - { url = "https://files.pythonhosted.org/packages/b8/6a/5dc43838c0b02a95f42c47a0de33c5ddd7767a9feeb4d0d8777ac1cfefe4/websockets-17.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4e8ffcb486c8490a34a4cef5e4409d8da5a1cb1681e5bf7d786ce5e84aa8540d", size = 223379, upload-time = "2026-08-26T14:56:53.699Z" }, - { url = "https://files.pythonhosted.org/packages/c2/62/585637cf06d6b321232f79c55dc14d65518d12cf87c94c44f5864068810e/websockets-17.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fb88076df585b69c5761c387c0081aa87d7b9eb1b205a6535ca4777e25650d81", size = 224330, upload-time = "2026-08-26T14:56:55.184Z" }, - { url = "https://files.pythonhosted.org/packages/de/68/c3b234a6a1366b6ab5bbfaa4434a1b946e1dc4e8ddd6824bfd93a8835b7f/websockets-17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5d4724255fb8398acd9e583b97eb2279cec20e0bd0f9a94bf75f6056ef9f13da", size = 224622, upload-time = "2026-08-26T14:56:56.393Z" }, - { url = "https://files.pythonhosted.org/packages/6a/d4/84cf3d1376f5d8207f55f43c1c818babd6b89447f5dcd01f18a6d5526796/websockets-17.1-cp314-cp314-win32.whl", hash = "sha256:be3f0129c5654517b2abf07dcb75bb1d9479759a4ccfb569e8293579e9fc029a", size = 217036, upload-time = "2026-08-26T14:56:57.652Z" }, - { url = "https://files.pythonhosted.org/packages/d0/0f/9e7ac63c5d7cb642952200814f584318e65146df008b7d375d5d9c6b2c97/websockets-17.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a4dc6ef83f4559e0d05f313a375cb38f63c986096a9da99fe94fdd779d313e5", size = 217382, upload-time = "2026-08-26T14:56:59.065Z" }, - { url = "https://files.pythonhosted.org/packages/54/bb/1ae6b91f7f3ac05f5c9f14a72dc2181c115ff370bcd8a7f10f02c174adfd/websockets-17.1-cp314-cp314-win_arm64.whl", hash = "sha256:46c0331c9eaaf73a559f3a9e388466be0df96eb83d40f06f1ca6ab6613b35c82", size = 217268, upload-time = "2026-08-26T14:57:00.654Z" }, - { url = "https://files.pythonhosted.org/packages/b3/f0/f65644d0e0b2b90918a8c41503841cc4072a58f2bf76c09bc36e751fc0dd/websockets-17.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d411ea5ca18ac1b12c0c94be88b60c18ca641ac43bcdfdf1c9f79d46cdbe1603", size = 217379, upload-time = "2026-08-26T14:57:02.181Z" }, - { url = "https://files.pythonhosted.org/packages/ff/35/4c46d1f620ac1a30f92b6eae78ee40a772a93f568647ca7ccdc5ea283cf8/websockets-17.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:07fa3e7c30e2c577928d359b56bf872a3e0cbcc15553eaa0907c1ee86344b56f", size = 214911, upload-time = "2026-08-26T14:57:03.478Z" }, - { url = "https://files.pythonhosted.org/packages/04/6e/4587e8406d7c1188e97b9cf466c081e93399380d447f885bfce81626cd37/websockets-17.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6de9acef07e3a78e9567fcd26c29011a4da8f050b13004bbf880a0fd82a6eea5", size = 215115, upload-time = "2026-08-26T14:57:04.692Z" }, - { url = "https://files.pythonhosted.org/packages/ec/06/1381c8fff525041025909eb80ace32489194a00ba22a0a8d428030afcc84/websockets-17.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ea0ed9373b880115911d9d39634bccc95b8ce590c9c42e8589f5cacc3ef3cee2", size = 224696, upload-time = "2026-08-26T14:57:05.899Z" }, - { url = "https://files.pythonhosted.org/packages/36/9d/9034e867dc85340be058619751742b895f722326e83100d110063461ca07/websockets-17.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50903d335bfda026c2fa11dd9aed09d8cbee0c451e3a85122a9acb041b7dc69b", size = 224975, upload-time = "2026-08-26T14:57:07.262Z" }, - { url = "https://files.pythonhosted.org/packages/40/eb/ed03aa3cae748ebf6397e5d44028f433f746bad09dc568ff754fda3a3c9b/websockets-17.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a74531ce81af587f906ab42f194032388fcff8fc7938402e5917c9147a39441", size = 226151, upload-time = "2026-08-26T14:57:08.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/c9/cc1964a096d16f3b73cb1ee5f14f277f5a3bcac07c6e8f9a1dcded99f4c8/websockets-17.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8fbf28e639544503b7d1c96452a5e5e043e4108d89b1f3fa02910603622d19db", size = 228292, upload-time = "2026-08-26T14:57:09.846Z" }, - { url = "https://files.pythonhosted.org/packages/1a/26/46da6dd0363c2db2e4876fd59a40fd40c1943a82d7018d0a33afbce47d52/websockets-17.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f612dc57f00c07cf4aa2673f7cbceabd654ad2457b7e639f061b794d6e11f9fd", size = 226722, upload-time = "2026-08-26T14:57:11.118Z" }, - { url = "https://files.pythonhosted.org/packages/78/98/ecd8f5e1c5d0e54c08ebc5c66852271112166db68107cb0e17ca1bf25009/websockets-17.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c7ac77401227212dc6e849182feee50d57cf456ec6329ffd6979c94bb136c5c", size = 225451, upload-time = "2026-08-26T14:57:12.601Z" }, - { url = "https://files.pythonhosted.org/packages/65/4d/da8d2760db53e17aae763738b6ba834b1fcf16813d3632f3edb6951e1ec8/websockets-17.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32a2a68d989d6e5b74a9d5095415c51189ebae29fceb7cf2b64a1c0318a81256", size = 223003, upload-time = "2026-08-26T14:57:13.875Z" }, - { url = "https://files.pythonhosted.org/packages/a4/40/ea401c141a79c5b1d0021a0dab9d0df2051c108f1620fbb39a6e7c714c3b/websockets-17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:aec00f018d34c67500ff0438dc314b40277be4a1b983cbacbf53ccf7db63e257", size = 225704, upload-time = "2026-08-26T14:57:15.091Z" }, - { url = "https://files.pythonhosted.org/packages/e1/8e/07ab3f44215d89840d5385fdcaaab1fed8caeffa67c6899e15062957c12c/websockets-17.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0014eaff8ad5b3b43feda2279f9d34bf2eaae040720b9fbbb55944b10f40b14d", size = 224192, upload-time = "2026-08-26T14:57:16.3Z" }, - { url = "https://files.pythonhosted.org/packages/58/93/ccf1af0a23e5748d4e22292a377d78d15cf294d7e707bbb11a8990ae6bd5/websockets-17.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:db9d7ee47f3ba531e278be539af39e2c7c7d28fb94897b6cd1120d63b0ef5922", size = 225082, upload-time = "2026-08-26T14:57:17.531Z" }, - { url = "https://files.pythonhosted.org/packages/e2/db/e32200f99ce282e728d2929f2c429db353cf3282db7d0eba99eb32c9fec1/websockets-17.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ff3e2ba7a9f0a110b0555452e9b5a03a34e11662544e01beea15f144b48ba7b7", size = 226101, upload-time = "2026-08-26T14:57:18.802Z" }, - { url = "https://files.pythonhosted.org/packages/28/3d/e7a6e9777b29433620167c98f3caaff0d6b08b1239a273ef7f7fd1393349/websockets-17.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6da17fc94bd270f5987b10bee113461ac36a36a98b0481ddcc98056e5a90001a", size = 223794, upload-time = "2026-08-26T14:57:20.313Z" }, - { url = "https://files.pythonhosted.org/packages/48/05/ac569090726dedd6656f3ee28b0c02dfb1ba76e898dceaccc2987a237cef/websockets-17.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:e8dc3fa6d6b7ead3f9de57895f41b116a28787548e066365d9d90f7356bcaad2", size = 224567, upload-time = "2026-08-26T14:57:21.634Z" }, - { url = "https://files.pythonhosted.org/packages/14/50/4ef62941111db6b31193f4fabbb65f845a5177579040cb8fe0d774d25034/websockets-17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b65d5fe48219dc2d5e158de9e6514e75600f379cc7e37108d35f31764c155566", size = 224993, upload-time = "2026-08-26T14:57:22.86Z" }, - { url = "https://files.pythonhosted.org/packages/28/42/2b95ada4ea19bf3a2072b68669ce4f4afb212690b727d31640576287fd68/websockets-17.1-cp314-cp314t-win32.whl", hash = "sha256:2cce251f3e2469b99b6802b55435bcdd07123b41870f54c87b336183af9d7e68", size = 217168, upload-time = "2026-08-26T14:57:24.466Z" }, - { url = "https://files.pythonhosted.org/packages/32/0a/67d5ee08dd8060a37d612fd40a625b5376ad19ae48fe1c8ad428c278b817/websockets-17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f6c38cdcaf98a911d7acc25577f2f9e710f3a2fc2bde1563556784320196b51", size = 217508, upload-time = "2026-08-26T14:57:25.983Z" }, - { url = "https://files.pythonhosted.org/packages/76/a3/822005d0c674451d2411027b878cdc128a2b7ea5a30d337d9e279da22eba/websockets-17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d1e2f5fa2b6d01f0d85b4f223fea7ed1d504be282a02a81bd2be4817ef7a2f03", size = 217425, upload-time = "2026-08-26T14:57:27.324Z" }, - { url = "https://files.pythonhosted.org/packages/de/d5/99a6c6a1eb5d5ae9f45f59a3c97f4e3b21f310eb404a547fb3e7d2fc054c/websockets-17.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:88381602e379165b66244b2ebc29f9b23ea0851fbe63ae157f91ca324f072d6f", size = 216970, upload-time = "2026-08-26T14:57:28.575Z" }, - { url = "https://files.pythonhosted.org/packages/a6/0e/1e7f6e833728193958d3ed3d67b5d57c3c7cfa948abf94d4bc553257c954/websockets-17.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:88bc5138e53903a85c354e59df7ba73ce306f7b09724cef74dba121e60a88ce2", size = 214699, upload-time = "2026-08-26T14:57:29.862Z" }, - { url = "https://files.pythonhosted.org/packages/07/00/95d39549f86e34425a0412bcbe61708dd1fc46af654e2134a6c4389102ad/websockets-17.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3546ef55b3a074494106508bc6505c73825970d2d9505f7bf53882b3e88b0d1e", size = 214927, upload-time = "2026-08-26T14:57:31.148Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ff/b442415fc4f7f9943b0fc8e8eebaa13923ca73361e167c439ba634eecbd9/websockets-17.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9ae55d24241fc055f22aea3ac924559069848bd0ad4ea065fdd72d2194685fe8", size = 224373, upload-time = "2026-08-26T14:57:32.833Z" }, - { url = "https://files.pythonhosted.org/packages/a8/dd/b83537aae4cf61615b9d8b2dbb235c0030ba85457a6d934798273814600f/websockets-17.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d7b349265fad6244013eecd99df8d83c12bf3013943e431f4fadd5bffc37db42", size = 224801, upload-time = "2026-08-26T14:57:34.041Z" }, - { url = "https://files.pythonhosted.org/packages/76/83/5ab0abed58454909e8dbab45086ac68ee4556d7a8ada26735addc909b903/websockets-17.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc5789e5ea182b77a38881383ada5347202a6c66f4857d054e075290e80b604b", size = 225967, upload-time = "2026-08-26T14:57:35.292Z" }, - { url = "https://files.pythonhosted.org/packages/4b/26/e2412f2b998a8c1dfc00c0709ff6ee0c634dd0b0b4f92bdfe9667876b71c/websockets-17.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce13c7d233239e739600a57d4a73c1192ad8259e655a4d55aa1a454242bc809d", size = 227664, upload-time = "2026-08-26T14:57:36.493Z" }, - { url = "https://files.pythonhosted.org/packages/ec/25/0dd4495df3c0e02f6db705312ba85ab9b2dd42257dc23eb0da10066e4844/websockets-17.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1036189bd34b0bc1b10a4679321e2c7968af317efe6e8e4c1c5141c4254fb5bb", size = 226447, upload-time = "2026-08-26T14:57:37.781Z" }, - { url = "https://files.pythonhosted.org/packages/be/67/6df3f63ffc48f08126ed0cd2fd2a41092967c3e364f8ec100deae90b6d77/websockets-17.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e78fd4b7b2c5086a38671c9c882c1e643385eccea360b5b1fda4a105e590087e", size = 225343, upload-time = "2026-08-26T14:57:39.133Z" }, - { url = "https://files.pythonhosted.org/packages/b1/8d/a8479bbb09ff054907d141123d8f52fb6ae5ac39c6dbe39e6a02a8408309/websockets-17.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46e7a10bf04318c7b0c0273791925ae5e1cbe4a11e34aa934d2ef27862058a80", size = 222748, upload-time = "2026-08-26T14:57:40.478Z" }, - { url = "https://files.pythonhosted.org/packages/40/fb/4c3d2a3269cde3f3087916de9c3d9fc5d7196b46846d8c3a9ae59ad0a884/websockets-17.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:33e45c7ea38428e740a7f233555d71df0b875cef7fc080acebc9654475e35335", size = 225453, upload-time = "2026-08-26T14:57:41.859Z" }, - { url = "https://files.pythonhosted.org/packages/7f/1c/6467b401d19408f34e1c7389c222c2c7e1dfdf08c551190269b5eabc726c/websockets-17.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6e63c01803be425ff062b7f7fc201a74def1d49fc94a2410dd17375df75936e9", size = 224112, upload-time = "2026-08-26T14:57:43.136Z" }, - { url = "https://files.pythonhosted.org/packages/c5/5f/744e032ac80e11039a7447657ebabb46e9b5c2dbcec83be571335212932f/websockets-17.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:722ec21717eec6477bce582147a28acdfe034e604239466a6a95daedb863e774", size = 224646, upload-time = "2026-08-26T14:57:44.871Z" }, - { url = "https://files.pythonhosted.org/packages/9f/47/bcb9128d9afc4d0934d9192e2a24897ca2f7a63df2654904915349c6c46d/websockets-17.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:e74e41f0ad12ff1e8983e349daef79d37cc8280c743ce9d134d6c74c18dab5d6", size = 225797, upload-time = "2026-08-26T14:57:46.338Z" }, - { url = "https://files.pythonhosted.org/packages/c7/e0/b058047b7cf565e1105b10ef6b6b24a6ebe3575678c7dc75a645334705a7/websockets-17.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:12fe8984a32dbfd084e0603f1a8d740c0180cb85b3174585c54a80d2455a8394", size = 223605, upload-time = "2026-08-26T14:57:48.175Z" }, - { url = "https://files.pythonhosted.org/packages/b9/69/fc1555bff884de363f1bf9eebf2836dbeb29fa7e4f957debb7bbcf43abba/websockets-17.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:01dcb47deebc40b38fd4a493b9b9f4d0a704b7bec6f35e4d34085b329abce71a", size = 224508, upload-time = "2026-08-26T14:57:49.407Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f9/648d4e68621688b19093b06f7b497d520952e68cdea1c1b54371fe9491de/websockets-17.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f4c45ee2512d3757b5e6c67c5a34e435143f2ecb7df3324f9fd888688c45c0f4", size = 224767, upload-time = "2026-08-26T14:57:50.799Z" }, - { url = "https://files.pythonhosted.org/packages/58/93/f8342b55864f71df13eb8e9ef7dce691b87a87f04f75bb8a1385b3336e7c/websockets-17.1-cp315-cp315-win32.whl", hash = "sha256:0f4f50dfe2cc810fc4e2de979b35e83bf8bb4bccdc6fe472d93762ea7b1d5927", size = 217003, upload-time = "2026-08-26T14:57:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f0/7b5fdb774c245e0b6217009e2a24d2105c1a64923949f33be41aa7959302/websockets-17.1-cp315-cp315-win_amd64.whl", hash = "sha256:4af784f3e436f65b355c117c6497320f2b5cf6a559295cb1c4c7338e335d45cc", size = 217300, upload-time = "2026-08-26T14:57:53.492Z" }, - { url = "https://files.pythonhosted.org/packages/76/33/1fe6ed1b5087516115ca451b2c240314b010647071f8fc3bd78a21e4dddb/websockets-17.1-cp315-cp315-win_arm64.whl", hash = "sha256:d58159af7835fde09c462394293c0d7aaf8fb4557d8f8e5699f5e722ccae013d", size = 217214, upload-time = "2026-08-26T14:57:54.88Z" }, - { url = "https://files.pythonhosted.org/packages/94/ca/ed02e75996a266d76c5fcb5dd9b930db4cf2b388ca5fa3d2a72086f81568/websockets-17.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1a5cf4e7bbe3ca499e6a289206cb4fcb7444b09919e129bd517f57d5fa192c13", size = 217282, upload-time = "2026-08-26T14:57:56.108Z" }, - { url = "https://files.pythonhosted.org/packages/bd/7d/d536f5bc89ea5b52fd1c1727c59fabafee6bc41f5ce92c3bd2f83047908c/websockets-17.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:416b4bc8789a1865a3ff643ec4ee073a5f52402d0dbeafd27b1798d5dd6b6a51", size = 214863, upload-time = "2026-08-26T14:57:57.355Z" }, - { url = "https://files.pythonhosted.org/packages/37/37/944cf17bad668e9be1247e6314f88a48b9faf7c250e383410db8b38af0b9/websockets-17.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:259f45358c76d3b18489e3e80636cdbe807e05ecf1b10fdf1a779106d23d0c8e", size = 215073, upload-time = "2026-08-26T14:57:58.719Z" }, - { url = "https://files.pythonhosted.org/packages/74/bf/3267966cc1bbc2b8fa62fd329651b0af502df1f5d1c0eed027ff339d6aa8/websockets-17.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9d01e8ede41fea4f5a847dad9d628355f74905f437a5b6856d67aa66d193800", size = 225229, upload-time = "2026-08-26T14:58:00.235Z" }, - { url = "https://files.pythonhosted.org/packages/7f/d8/85ea722f483510abb39fc71aafb4465d17cf9051a275ab036874ff3c300c/websockets-17.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7b35181a14cbfcae163b4de545d22abfd07d06c2c41ca69cfcd99251d6888ab", size = 225500, upload-time = "2026-08-26T14:58:01.994Z" }, - { url = "https://files.pythonhosted.org/packages/50/ce/64c7d00005bd0d15ecb5c5fcb7fb2597b6b92ddd16c4fa6bbc3d2835ad63/websockets-17.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a8e768a048c2220697477ce2e67e4345dc9f693d0ee6af53945b5e30227c6a7", size = 226829, upload-time = "2026-08-26T14:58:03.327Z" }, - { url = "https://files.pythonhosted.org/packages/b4/dc/096c67940fb957e667ca3c542818150434eb0388c6fdc90b3a502f3c3e96/websockets-17.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:880069d21cc33a558dcf180924a546d1ecf8ada5be3e4e70acee87019d706a24", size = 228457, upload-time = "2026-08-26T14:58:04.78Z" }, - { url = "https://files.pythonhosted.org/packages/51/fe/f2331b6b7ccc67589891da354fa46a5cb79e95f83b9fd0e734d77f1f2140/websockets-17.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cec1bb8f22abccc8d20f8ca63df9be41600c26c190f4b97ee86c675fd4a863a6", size = 227265, upload-time = "2026-08-26T14:58:06.102Z" }, - { url = "https://files.pythonhosted.org/packages/47/a5/fb1642302f8ec77ca922203074f155a9831a5128ad75e725059a476d1227/websockets-17.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3a1d577e081667dda7f8e5b4796e6e32f9713c93e2a3d930669519840a3c623", size = 226143, upload-time = "2026-08-26T14:58:07.464Z" }, - { url = "https://files.pythonhosted.org/packages/d7/41/7133fcfb63f5562750b269d6a845c689dde6a2c6407286da395beea19ddd/websockets-17.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc053f9e95a76213c5eb7ed95779f7daf0d2bf0e4e03073629ebfa43a033f151", size = 223501, upload-time = "2026-08-26T14:58:08.766Z" }, - { url = "https://files.pythonhosted.org/packages/64/b1/82b36bfabc79ff2d383a1fc043cee6a13f794ef4f6bf1b4810ad6988cf6f/websockets-17.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:bb0efe019480a1c93e168ce96479273aaebd672fc8c350d5eed1e507ababb1b8", size = 226330, upload-time = "2026-08-26T14:58:09.987Z" }, - { url = "https://files.pythonhosted.org/packages/41/7d/5b511b9bf6e9ad331e6ff902fcbcc71c3794d10ef3b5efe80ccb8f0a7861/websockets-17.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:615746b12b26a3fd4077bc6fbeb277a1c192a45dd57b531d07ad9ed5c52a9a7a", size = 224980, upload-time = "2026-08-26T14:58:11.303Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/aed08f25301f8eef23be903ff9319fcf35630ca2bdec9d226f7d804dd5b3/websockets-17.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:1a20136d61f9ca3a31493732762661fafc2c20e8861930214e21afc6a8a692a2", size = 225478, upload-time = "2026-08-26T14:58:12.543Z" }, - { url = "https://files.pythonhosted.org/packages/3e/47/0d63d4168536b4682c9d19b7399443b1176f25dbb68878374fa716670230/websockets-17.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2786cbd273ab69c22612db8a41229ddf2c158060b17b5928884bf388d07887f3", size = 226588, upload-time = "2026-08-26T14:58:14.457Z" }, - { url = "https://files.pythonhosted.org/packages/b3/dd/844bd0b6386fc81ed6a55f4b6dd26f01c6987eda205afa10175ea12b2164/websockets-17.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c323fc3be1dc3f87f6c59458cb7d9e13dcbbf971d6c3f3e2bbaf58d3bfcdfe", size = 224336, upload-time = "2026-08-26T14:58:15.778Z" }, - { url = "https://files.pythonhosted.org/packages/96/18/03709c84bc88ec4dcea68d4be4ccd07d611073dec111203a5bf45af8809d/websockets-17.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12c8e2b25df59755954a04dfa09c990b96691025aaf7eafd19ed6da24b09c18d", size = 225197, upload-time = "2026-08-26T14:58:17.141Z" }, - { url = "https://files.pythonhosted.org/packages/27/cf/0d1c694b6466c89e875b85b32b51312c472cf6708eee91914866f5087dde/websockets-17.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f58f58b4b29bbea2a3635e2c56eff4a3adab011fe383802a9e542e31b97085fc", size = 225493, upload-time = "2026-08-26T14:58:18.521Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f5/99857c3dd9676749f33e3668665a34ad6099505fb8d75eb084f49f7807a9/websockets-17.1-cp315-cp315t-win32.whl", hash = "sha256:f78a3ffb1994304db2c0c4588e4d1a518079b557054fa3bb985a6f5e50ff49a3", size = 217130, upload-time = "2026-08-26T14:58:20.037Z" }, - { url = "https://files.pythonhosted.org/packages/2c/84/77599922ab441bfe61508f97dab2c71f8e114d31793993ea54011db16199/websockets-17.1-cp315-cp315t-win_amd64.whl", hash = "sha256:ad68c28a27246fed109a4409393d677b7e1388345cbbd2f5aee5c182d8506110", size = 217448, upload-time = "2026-08-26T14:58:21.382Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3c/8b9a225b523f06a9389be81f1b0ab07c49bec6014742e6aa359c1f920f1f/websockets-17.1-cp315-cp315t-win_arm64.whl", hash = "sha256:e552e0037230ac16e5f568de7012041344d1b18c9feed30ec2891b8eba55af81", size = 217372, upload-time = "2026-08-26T14:58:22.807Z" }, - { url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" }, -] - -[[package]] -name = "wrapt" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/b0/c1f5a970721f06b85c0cd5142e0ff8fe067708abd779b0c4f4be7d61d09f/wrapt-2.3.0.tar.gz", hash = "sha256:681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107", size = 131509, upload-time = "2026-07-28T06:06:14.895Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/6e/0f88a072483e76b881e3fdcd6b6ffb4a5791002514fe541e72b1b73c859a/wrapt-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d3fb71e65b001adfc42684522eeccd9c21d8ba679945abc993439567b66e59f", size = 81960, upload-time = "2026-07-28T06:04:49.622Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ff/b7e2776e7c294075eb712cc9ef573d1b818f393006d09787262b8fc871c4/wrapt-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51a7a4181c1295774812271fbcd7c909df372bc25579d4ed9eb875caaf0ae86f", size = 82435, upload-time = "2026-07-28T06:04:50.9Z" }, - { url = "https://files.pythonhosted.org/packages/d8/90/343bb5d0f1f9669bc252a6073f085b4abf862511bd5c9c9eaec754341f1d/wrapt-2.3.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9045917809c63fdf7abe3a2ceaed3d670b8ee4500ddd9291192d30aeb34467c5", size = 170350, upload-time = "2026-07-28T06:04:52.187Z" }, - { url = "https://files.pythonhosted.org/packages/59/f8/13b79a392930bd0dd6b86cbfbfe1c40944110456e1dc6d809e5c46ece904/wrapt-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54ca1d5573f69b5fe1d74f1f65799c68015e82f685efec9fd8cfa40a094c44d0", size = 170022, upload-time = "2026-07-28T06:04:53.599Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fc/4f1b6918f5290db959d6e0c07f77385d87cede29c39c9cf8f145e9c82954/wrapt-2.3.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:242b60c21e30866e6a2fa606c612b47c553fa60c0eaeeeb7797fb842ac0ce609", size = 161043, upload-time = "2026-07-28T06:04:54.936Z" }, - { url = "https://files.pythonhosted.org/packages/01/e1/45d3cf74414780bdff6d0380467e003f6eb0f028b6c9403db868dbc7209c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3f3d7ec0a51fbfe00d3aef047641ff2c58b25565b4717fc1f90e050be01cba8", size = 168576, upload-time = "2026-07-28T06:04:56.261Z" }, - { url = "https://files.pythonhosted.org/packages/f3/73/2fa58dd97f191c997755e2c6d569a68f0c433db4e4b36099bdd7227b6cac/wrapt-2.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:261f53870cd4fb2bf38f9f972c56c728fd224cb7c65721307de59d9e7e6741ae", size = 159140, upload-time = "2026-07-28T06:04:57.754Z" }, - { url = "https://files.pythonhosted.org/packages/29/a8/08a56e2000a8816d449dcbad8c8b081697acbbd490821ceca0f9d8e8d20c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8159ec0b0cb7608175eb150de94c19e34f4d47ac655f5ca9baf45df6b688ffd3", size = 169263, upload-time = "2026-07-28T06:04:59.161Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d4/354e1725e35a73b2af4fa70a3e024c7a5d1bf1802dfb862dcb668aae0253/wrapt-2.3.0-cp313-cp313-win32.whl", hash = "sha256:10461884b3014fbfc8eb7d09a93c5f246363e6711d9d881f95eb8c27fdef049f", size = 78241, upload-time = "2026-07-28T06:05:00.507Z" }, - { url = "https://files.pythonhosted.org/packages/6c/7e/34c87fa2174848dfee820322aaa318bab08913998ccecc8d2f57b4ad4639/wrapt-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac870cc97b73bb00ac353329e9559a4bebc47c4c86792ed9b23b58c15b6ad838", size = 81113, upload-time = "2026-07-28T06:05:01.839Z" }, - { url = "https://files.pythonhosted.org/packages/11/86/fcc9a530579e008c9478bb565a6cdfbfd33536660f069c8b91a6607c5050/wrapt-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:a65e8db2b4e90c2e7ade931086351c98ef420bf7a94ee08c95ac8a3cbbc43579", size = 80182, upload-time = "2026-07-28T06:05:03.152Z" }, - { url = "https://files.pythonhosted.org/packages/96/50/3864848b95b28ef73e17551fc8dccbff2628a834f52cf26a57f9c419fb83/wrapt-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fd1f2f557dd3491fe75905e578f4db967393d40d1a8f468edc4d40ac7f2d5944", size = 83921, upload-time = "2026-07-28T06:05:04.476Z" }, - { url = "https://files.pythonhosted.org/packages/3b/4c/3d1921a60c3e8c71c540ff136e6a47a1fbccf7f671e818394889f7871d9c/wrapt-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9f5d2aec29dfc76c37e23897dee92766a3fd4f3bff3ae7fc9c6b4bf37d8c1360", size = 84412, upload-time = "2026-07-28T06:05:05.921Z" }, - { url = "https://files.pythonhosted.org/packages/fa/1a/4a796ff7adb26ada6d4b758c94d47a38320b085e7099afc088efbbcdb006/wrapt-2.3.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:646d20d413ffcd1b0a2f700076e2d0252d872dcb7754860a73e45a59ea883614", size = 207168, upload-time = "2026-07-28T06:05:07.256Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3e/d7777776806c579b761bac2f91721dda9f04c7a1b380213c5935cc750ae6/wrapt-2.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:379f670f45b7bb8993edd9f6fc36c6cc65edb81cffa0b504be34acb0303fff0a", size = 214351, upload-time = "2026-07-28T06:05:08.945Z" }, - { url = "https://files.pythonhosted.org/packages/63/27/2d64d394df7bf181955b3bb562bf33c4492fb4be113f53071106d43ad8b5/wrapt-2.3.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6208f302f110295d64b22a7ac96500c791bf492dce4366e622e4912b077c9687", size = 199020, upload-time = "2026-07-28T06:05:10.418Z" }, - { url = "https://files.pythonhosted.org/packages/3e/3d/fb31d3db7d9834d265fb1a27a2adf0ddf51557c67458c97b22439ad6ae3d/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ed635a9ca4f3a5a2b900c10c69e823373bc00ebc114b459383596d3487da3570", size = 209969, upload-time = "2026-07-28T06:05:11.983Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d1/8724b5da582e62070dc9bf4d8bf1972f317297eefd7ba1f2b5c6393ccf6c/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e3b9eaa742ae7a0aaaaad4ca4b69469d757af2d6e6663ef1dadc47adec0aeb41", size = 196324, upload-time = "2026-07-28T06:05:13.557Z" }, - { url = "https://files.pythonhosted.org/packages/0d/5c/3d9ef411149543016ee6bcf3af707f787cebd946527452b94bf122e9b7b4/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0f7284f88f4833705132d06d3b425a43095c2cbd07c58166aac3ab646ba12a4", size = 202610, upload-time = "2026-07-28T06:05:15.048Z" }, - { url = "https://files.pythonhosted.org/packages/13/9b/4fc042ceb757866dd4a5fc057b3b736f2b360d3703ce9f830d83dc9226e0/wrapt-2.3.0-cp313-cp313t-win32.whl", hash = "sha256:7ebb274aba688b043429eb1500ff8a76ce0cb8ac0812ca3e301f06247b8722b3", size = 79178, upload-time = "2026-07-28T06:05:16.469Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ff/b94878f8eed809ca042685276bcea9f24e8c2ca7c9653bb80bbb920a68a5/wrapt-2.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c4bded758ad6f03b965830944a2f0bc5b2eb3767fe5a7310134315d1a6610e98", size = 82634, upload-time = "2026-07-28T06:05:18.026Z" }, - { url = "https://files.pythonhosted.org/packages/80/fb/663e1de5332a71685a729754312d327d4cada767c36e1c5a2db4c8de49e6/wrapt-2.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:d2cc64539da63e39ffb9c7ede849b6e8ddaaf7b3876b5cfb04efd85a5f3f4eb6", size = 81387, upload-time = "2026-07-28T06:05:19.417Z" }, - { url = "https://files.pythonhosted.org/packages/58/10/b073beaea89bc0d3670a75ff51139430a54b6af7ba7796507730634536dd/wrapt-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea52a0d0f08c584943d5764be0e84efa912c8da23c23e1e285ff2f5641c18fcc", size = 81978, upload-time = "2026-07-28T06:05:21.133Z" }, - { url = "https://files.pythonhosted.org/packages/b3/31/0916d9cebf848ed3f1a0c1888faee421747df77331e4db2bc527a9a85988/wrapt-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd85b0aa88efdb189d6ae2f35f4526943a8f091c38599c9c31478241c819e6a1", size = 82518, upload-time = "2026-07-28T06:05:22.562Z" }, - { url = "https://files.pythonhosted.org/packages/f5/73/31c1bf0f3384062751c2094dadb314916d70aa9b6bfd26d994b4a7b393fa/wrapt-2.3.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:141ed6211286a9660d8d6702de598b43f0934b4f0eda16393f100a80f501d945", size = 170187, upload-time = "2026-07-28T06:05:23.904Z" }, - { url = "https://files.pythonhosted.org/packages/ed/25/fce087d54b79b8905f3c3c9dd5f454bbd8d8acb80b960c4a6aee5b4659b3/wrapt-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e49885a62ec4ee854d1b9e6371fda6afd219917225752abf729a3f36d4df9a5", size = 169288, upload-time = "2026-07-28T06:05:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/c7/30/0d09e6dddc6b7a7230ac77f50254b5980ab4fcd22976f72f8cc8a0404458/wrapt-2.3.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d6159c9b2fefec02314e1332dbbbfaf960e369dfd26bcf7f8b258b5732065b3", size = 160932, upload-time = "2026-07-28T06:05:27.022Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ca/0913af0d2ec0c43865d32d615f518fea66c13c5c930e489e9b0de248e9a8/wrapt-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24da48596326ef8e448cfa837b454f638713d3531262375f00e5a9681682fc07", size = 169017, upload-time = "2026-07-28T06:05:28.501Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f2/3d1e47ea81b822210f5df1bf942fd90780a75c055243d569b664529dea88/wrapt-2.3.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cd3a2edf0427013736b8127955cec62608c56e53ea47e82812ea32059cda407f", size = 159065, upload-time = "2026-07-28T06:05:30.01Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/ef2066ced8e5fca204e2b361e9708e36555b40949c583d997ea3b590817d/wrapt-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0df3bff4e7ce45759f33fd39335fe2f60477bb9ecf7b8aa41e7d07ee36a23", size = 168821, upload-time = "2026-07-28T06:05:31.649Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e1/016104650d4e572fa91506eb396b3dd8efbccc9284fdc1c9479c3d21db28/wrapt-2.3.0-cp314-cp314-win32.whl", hash = "sha256:2935d5454b3f179a29b12cf390ee47246740ba2c3a7545b1b46ba31a5f2a4a0b", size = 78700, upload-time = "2026-07-28T06:05:33.391Z" }, - { url = "https://files.pythonhosted.org/packages/3d/97/6fdc20a9f2ca304748b3f0819cbf377d55260562777bf0b615431bc3c181/wrapt-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:cc2cea812e5cb179a796b766747e7d3b21088760d8deb95676d482b8c8e6fa7d", size = 81422, upload-time = "2026-07-28T06:05:34.774Z" }, - { url = "https://files.pythonhosted.org/packages/5e/a4/9cbd53bf05746bea2c392af39cb052427a8ec95cbd494d930733d8f44681/wrapt-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:22cc5c0a717bd4da87018ae0bffd4c19c6fb679d3ff357216ba566ab26c76cab", size = 80639, upload-time = "2026-07-28T06:05:36.228Z" }, - { url = "https://files.pythonhosted.org/packages/43/bb/6c5e4a0f66ea0d2b2dd267e8dd05a0014eea56840b3c8595d40b0a5d1f91/wrapt-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a6b5984cd65dd639546f0eb4b8eacf1c31cb2fe9fb5c27bffe240987cdb2cf84", size = 84030, upload-time = "2026-07-28T06:05:37.714Z" }, - { url = "https://files.pythonhosted.org/packages/6a/eb/a1aedf03283bc9cbf8a1783995ddc54e3c5a86878f19002d2c428494f4c5/wrapt-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c88abcf53daef80e01a75c7530e727fa6e2c1888fe83e3dcdba4c96216a1f5c7", size = 84419, upload-time = "2026-07-28T06:05:39.131Z" }, - { url = "https://files.pythonhosted.org/packages/63/61/50d511c0dc5105563849e86daa3e16ac7feef699f79fb05af45ea70107d5/wrapt-2.3.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85de890ff968196e92dd1ae73a9fb8970495e7650a457b1c9ef0ac3dd550bce2", size = 207171, upload-time = "2026-07-28T06:05:40.69Z" }, - { url = "https://files.pythonhosted.org/packages/3f/59/9b538cf7795217e810699d16bc88b96a830d9b5c403eb2ec2db6b5f2ae81/wrapt-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50f416b74d092bb9f41b424e90dd457f365f7ba4b11de62a23679769a21bd85c", size = 214329, upload-time = "2026-07-28T06:05:42.287Z" }, - { url = "https://files.pythonhosted.org/packages/b3/28/9935d62b1499e5c8b3d191e99ba4eb31ca237a0b699142011a837e9dc7ea/wrapt-2.3.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39febbee6d77301d31da6996b152ce52452da7c7ef72aba10c2fa976dff9c295", size = 199079, upload-time = "2026-07-28T06:05:43.958Z" }, - { url = "https://files.pythonhosted.org/packages/2b/01/4446b80fa2ffa47a3449b250d004ba1c1937f07f64a179608fec735df866/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93513bec052c6cd987f9f580c3df068c8bc4ebae6543736be3ca7ec5959cafcd", size = 209992, upload-time = "2026-07-28T06:05:45.677Z" }, - { url = "https://files.pythonhosted.org/packages/d4/07/56f26c9f9979586a021e8148747004aba4498f49458c90b0502969b904e1/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:729126e667da34d251b8ebf8a45ef0c5ddadc21542b3d6e1abf4259ece6508df", size = 196334, upload-time = "2026-07-28T06:05:47.608Z" }, - { url = "https://files.pythonhosted.org/packages/8b/41/6d7bcc895b0f28b2250e10908f060687b9165429dcd7f22ddb3d4c031b74/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:626b69db2021aa01671ec7bbc9740e558522bd44c18cf2ce69bf3d666a014109", size = 202644, upload-time = "2026-07-28T06:05:49.183Z" }, - { url = "https://files.pythonhosted.org/packages/cd/25/7860927edba06b758b8852a6f02e832be715563c67a6795d94350bc81099/wrapt-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:629d73378082c00a8173031f9fb30a3ac6abbc894a5bfdfae71fabc60642d501", size = 79685, upload-time = "2026-07-28T06:05:50.976Z" }, - { url = "https://files.pythonhosted.org/packages/c4/0f/270bafe92fde3b069a39bc01e39ee79340895b335640df861d43d2a51885/wrapt-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:42869085687f0aefd57c0f636c3f9354f8ffb321a8ba9cb52d19beb796e561c5", size = 83104, upload-time = "2026-07-28T06:05:52.405Z" }, - { url = "https://files.pythonhosted.org/packages/55/b3/af176d79a8515a8a720eccdad9a96f6e31a30abf2865430c8c42adf2fd13/wrapt-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b1e5aa486e269b00ed35e64771c7d0ab8096cfd2643405ca8cd60ebedc099a51", size = 81774, upload-time = "2026-07-28T06:05:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/00/39/3daf9f47be208606586de4568ba6713db53ebc8fd7a575aea1fe57983b69/wrapt-2.3.0-py3-none-any.whl", hash = "sha256:d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2", size = 61866, upload-time = "2026-07-28T06:06:12.9Z" }, -] diff --git a/pyrightconfig.json b/pyrightconfig.json index 6d2aefd..be6e3a4 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,6 +1,6 @@ { "include": ["src", "tests", "migrations"], - "exclude": ["old_repo", "**/__pycache__"], + "exclude": ["**/__pycache__"], "pythonVersion": "3.13", "venvPath": ".", "venv": ".venv"

WsFs|jh%_BBNxbaEicPv{A|w| zV?bHmmeoowu4%%vjtF6WSV_o@64(~oDZ2c!W%#~>DGK-;GC{hlU9h_?#%Ixj3 zk@zsnrSys2C2ofj%krJfO6bPO$x7zgt41iSQ8RJ{Ztaon_Ngo{t?vS#PLXRfBN^+( z#c~7?L|}%ZdzzaJs#i>Bq$=&FL&go$VmLf%$!yQ3g)1;_t(cQlHPe1YbrM;uN!=CR zl(Ng-;1(nltuASz>nbMH&#xh<>r*{r65*{*sJk}nc-VfUl=pV+j;IVgJPKBpuE?%Q zRt2nU1xF%|szFz(!ziOyUX|&gD^ZmpC=2D@32d6n<1$Mu$D=DCDxEOKH4=dl1hTfT zNSRev8n0(a5$AH)ml6mp%vp&s(>{)>on{@C0Bm~LSZ!7%Sg?Ke zzSbn$kgsK87PiM_Dm5NsRa$AA*yUQ(3t76vJ1VPOu2?D;Ol3o=+Q3f8_LI7-siyYq z$AW5ZR47+s)Xa=(mT{As z_ch++zP}<1!^d=dd!w;10}L{@&Bp7{sYI=kC2UOX>|9$hO4Dj&t@a(e14|;G8koMa zpc@Od#q37M7`~wiGM4uH7`=h}izRoiUHLC#*q~5G`$NM)F)l8|!3;3N6y#%^cEz(9 z7g``ETx@H6dtvXfA$p2a8v5+W#lbY;w!(VFdwaY481JxVEW`i-VPeZG0H`|;nyEz; z*FdME(A7mYO-`$27jHrsd0|`S)>$lS`m-vC=N2?(9B;P6*OP62Hn3irtoqJj%xsd! zg1Rf!jiSZYmmkoy0>q8&tn6HcYOPxG5ksMXo&vKOi*D8)8-p4tWi+))w%Do^iqziL z12xXH!`ZOSZ+_wdH`v%O7yTahy2~>Uh08J7t`FEbav4bP+p?b>Zf(cNTVw!|13MU} z8qM%{B%2O1UrDz&$f5!taF~h>kIzlcy4O=piH@GF22xz37GA4ToScjr*6U$JvN{>D zXJ%F#3<<0cUktLt&OOM)X={4+(5joOzJXev+pH7FJSbSVIC&O6`vNE< zOf|jjT;ACWV%ynYODinO*0MXqTP_a4Si%FbGh%G82Qw@&5pbd>8RghyGi5 z=Eu+=sjDR~anq9}mOu z{687S`TZV`oYU&{^!4?Dg`*{Nhm~J8zI8m(`7wWALBeksd~Qk9VWYwP7Yz(Bh$9^a zZZsAXMY|HSu0ToVaY;ZNIGa}NElh)#R!`CNwnx~R{{V5%ZzMdE`CspUalF{`t>%ZF z9&*1k{bS_6q4x~4k=`pZkV7e6x?&eX7i85=5U%5P7gQ|j^v&i3hRDr+cf73q;tt5NEz1?i?j0{Xp7ngA(KAv@&$L||q; zfT5jw5CVjrZwCUE#(OYD$UKS98UFy$t0AxU_e(N|uhr}IJDjG@_bF+y7BK2oG+53h zOeaEyuz)hSAi+1-kPIB|9tR9*#rddKLpQYm0ZmygO)H2io7Y`#yMC{)>-!Ezzv%it zh7XVY{{Vntd|w#C_(-O4^Wt&pQ2TveJe zf#Y0b*EUVD3Kd!tU5QodR?Zqwq+_5%Sy96@)=E*2^Iwiqy3-Fk7hjM#ACe*DK@A;B zTSFtNEqs{axB!%_0>!&L0~R8ZPi0gQ+k+`wiH|$Al+L{@0PiMI#qyk6FzBl;rvvZ+ z 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 diff --git a/old_repo/apps/dev-console/public/images/lead-enterprise-training.jpg b/old_repo/apps/dev-console/public/images/lead-enterprise-training.jpg deleted file mode 100644 index 48c8e357e963ef05367a82af580af3df1b51a106..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 100011 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 Q*9I76_3=p6{kQP{08g;9?EnA( diff --git a/old_repo/apps/dev-console/public/images/lead-ielts-student.jpg b/old_repo/apps/dev-console/public/images/lead-ielts-student.jpg deleted file mode 100644 index af050ffccece3453692bd2750ffefda3b16d6999..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 158358 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%0AOzXY-dY}1J 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≫opF}TREg8q|MU1SXFmhVwBPtrE3zdyT!vnEl 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