apsimo 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apsimo/__init__.py +38 -0
- apsimo/__main__.py +6 -0
- apsimo/agent/__init__.py +6 -0
- apsimo/agent/client.py +276 -0
- apsimo/agent/models.py +46 -0
- apsimo/agents/__init__.py +20 -0
- apsimo/agents/models.py +264 -0
- apsimo/agents/store.py +861 -0
- apsimo/agents/websocket.py +522 -0
- apsimo/api/__init__.py +1 -0
- apsimo/api/auth_telemetry.py +287 -0
- apsimo/api/authority.py +1203 -0
- apsimo/api/contact_grants.py +347 -0
- apsimo/api/middleware.py +483 -0
- apsimo/api/routers/__init__.py +1 -0
- apsimo/api/routers/commitment_work.py +265 -0
- apsimo/api/routers/context_gate.py +123 -0
- apsimo/api/routers/executions.py +140 -0
- apsimo/api/routers/followup_plans.py +147 -0
- apsimo/api/routers/governed_actions.py +162 -0
- apsimo/api/routers/host.py +14473 -0
- apsimo/api/routers/initiative_work.py +115 -0
- apsimo/api/routers/mining.py +104 -0
- apsimo/api/routers/observations.py +110 -0
- apsimo/api/routers/social_state.py +225 -0
- apsimo/api/routers/task_queue.py +2715 -0
- apsimo/api/routers/temporal_followups.py +251 -0
- apsimo/api/routers/transport.py +110 -0
- apsimo/api/routers/transport_ingress_api.py +240 -0
- apsimo/api/schemas/__init__.py +1 -0
- apsimo/api/schemas/host.py +1949 -0
- apsimo/autonomy/cli.py +110 -0
- apsimo/autonomy/condition_worker.py +437 -0
- apsimo/autonomy/config.py +424 -0
- apsimo/autonomy/loop.py +4316 -0
- apsimo/autonomy/registry.py +339 -0
- apsimo/autonomy/scheduler.py +1822 -0
- apsimo/autonomy/synthesis.py +449 -0
- apsimo/backup.py +962 -0
- apsimo/beliefs/__init__.py +23 -0
- apsimo/beliefs/contradictions.py +109 -0
- apsimo/beliefs/decay.py +61 -0
- apsimo/beliefs/engine.py +479 -0
- apsimo/beliefs/models.py +67 -0
- apsimo/beliefs/promotion.py +41 -0
- apsimo/beliefs/resolve.py +58 -0
- apsimo/beliefs/source_claims.py +690 -0
- apsimo/beliefs/source_projection.py +883 -0
- apsimo/beliefs/source_time.py +208 -0
- apsimo/beliefs/store.py +133 -0
- apsimo/briefings/aggregators.py +824 -0
- apsimo/briefings/composer.py +420 -0
- apsimo/briefings/config.py +55 -0
- apsimo/briefings/delivery.py +439 -0
- apsimo/briefings/engagement.py +97 -0
- apsimo/briefings/engine.py +274 -0
- apsimo/briefings/enhancer.py +99 -0
- apsimo/briefings/models.py +183 -0
- apsimo/briefings/scheduler.py +382 -0
- apsimo/briefings/store.py +435 -0
- apsimo/chain/__init__.py +48 -0
- apsimo/chain/block.py +100 -0
- apsimo/chain/cli.py +704 -0
- apsimo/chain/genesis.py +443 -0
- apsimo/chain/identity.py +416 -0
- apsimo/chain/keys.py +1025 -0
- apsimo/chain/local_keys.py +187 -0
- apsimo/chain/manager.py +290 -0
- apsimo/chain/node.py +163 -0
- apsimo/chain/plugin_transactions.py +371 -0
- apsimo/chain/protocol.py +220 -0
- apsimo/chain/state_machine.py +676 -0
- apsimo/chain/storage.py +503 -0
- apsimo/chain/transactions.py +250 -0
- apsimo/chain/validation.py +397 -0
- apsimo/channels/__init__.py +1 -0
- apsimo/channels/manifest.py +31 -0
- apsimo/channels/migrations/001_channels_schema.sql +12 -0
- apsimo/channels/phone_gateways.py +42 -0
- apsimo/channels/presence.py +188 -0
- apsimo/channels/router.py +235 -0
- apsimo/channels/store.py +231 -0
- apsimo/cli.py +2688 -0
- apsimo/cognition/__init__.py +11 -0
- apsimo/cognition/charter.py +398 -0
- apsimo/cognition/drive_governance.py +3530 -0
- apsimo/cognition/evidence_pipeline.py +1627 -0
- apsimo/cognition/external_events.py +932 -0
- apsimo/cognition/goal_spine.py +3488 -0
- apsimo/cognition/introspection.py +214 -0
- apsimo/cognition/prompt.py +150 -0
- apsimo/cognition/runtime.py +108 -0
- apsimo/cognition/trigger.py +154 -0
- apsimo/commitments/__init__.py +18 -0
- apsimo/commitments/local_work.py +355 -0
- apsimo/commitments/store.py +1052 -0
- apsimo/commitments/work.py +91 -0
- apsimo/compat.py +53 -0
- apsimo/compression/__init__.py +467 -0
- apsimo/connectors/__init__.py +21 -0
- apsimo/connectors/base.py +152 -0
- apsimo/connectors/caldav_calendar.py +125 -0
- apsimo/connectors/fs_documents.py +85 -0
- apsimo/connectors/imap_email.py +138 -0
- apsimo/connectors/manager.py +218 -0
- apsimo/connectors/webhook_pull.py +88 -0
- apsimo/contacts/__init__.py +33 -0
- apsimo/contacts/comms.py +357 -0
- apsimo/contacts/config.py +79 -0
- apsimo/contacts/exporters/__init__.py +1 -0
- apsimo/contacts/exporters/vcard.py +71 -0
- apsimo/contacts/identity_links.py +251 -0
- apsimo/contacts/importer.py +280 -0
- apsimo/contacts/importers/__init__.py +1 -0
- apsimo/contacts/importers/batch.py +43 -0
- apsimo/contacts/importers/macos_contacts.py +101 -0
- apsimo/contacts/migrations/001_contacts_schema.sql +141 -0
- apsimo/contacts/migrations/002_trust_scopes.sql +36 -0
- apsimo/contacts/migrations/003_open_gateway_enum.sql +32 -0
- apsimo/contacts/migrations/004_contact_provision_operations.sql +18 -0
- apsimo/contacts/migrations/005_identity_links.sql +27 -0
- apsimo/contacts/models.py +308 -0
- apsimo/contacts/scoring.py +16 -0
- apsimo/contacts/store.py +1623 -0
- apsimo/contacts/transport_ingress.py +252 -0
- apsimo/contacts/world_bridge.py +314 -0
- apsimo/contextgate/__init__.py +69 -0
- apsimo/contextgate/chunker.py +169 -0
- apsimo/contextgate/estimate.py +54 -0
- apsimo/contextgate/gate.py +313 -0
- apsimo/contextgate/retrieve.py +115 -0
- apsimo/delivery/__init__.py +16 -0
- apsimo/delivery/bridge.py +1260 -0
- apsimo/delivery/channels.py +526 -0
- apsimo/delivery/classification.py +50 -0
- apsimo/delivery/rate_limiter.py +268 -0
- apsimo/delivery/reachout_policy.py +206 -0
- apsimo/directed/__init__.py +22 -0
- apsimo/directed/audit.py +167 -0
- apsimo/directed/intake.py +95 -0
- apsimo/directed/models.py +191 -0
- apsimo/directed/service.py +509 -0
- apsimo/directives/__init__.py +25 -0
- apsimo/directives/evidence.py +87 -0
- apsimo/directives/extractor.py +188 -0
- apsimo/directives/guard.py +364 -0
- apsimo/directives/models.py +206 -0
- apsimo/directives/service.py +372 -0
- apsimo/directives/store.py +167 -0
- apsimo/doctor.py +2173 -0
- apsimo/environment.py +43 -0
- apsimo/events/__init__.py +33 -0
- apsimo/events/broadcaster.py +98 -0
- apsimo/events/bus.py +217 -0
- apsimo/events/journal.py +863 -0
- apsimo/events/stream.py +131 -0
- apsimo/events/types.py +150 -0
- apsimo/execution_results.py +357 -0
- apsimo/feedback/__init__.py +5 -0
- apsimo/feedback/store.py +76 -0
- apsimo/feeds/__init__.py +19 -0
- apsimo/feeds/cli.py +84 -0
- apsimo/feeds/engine.py +437 -0
- apsimo/feeds/example-feed.yaml +77 -0
- apsimo/feeds/hermes_cron.py +126 -0
- apsimo/feeds/manager.py +235 -0
- apsimo/feeds/spec.py +250 -0
- apsimo/feeds/template.py +202 -0
- apsimo/gate/__init__.py +18 -0
- apsimo/gate/audit.py +61 -0
- apsimo/gate/communication_policy.py +166 -0
- apsimo/gate/config.py +72 -0
- apsimo/gate/context_provenance.py +170 -0
- apsimo/gate/env_risk.py +226 -0
- apsimo/gate/guard_audit.py +353 -0
- apsimo/gate/layers/__init__.py +1 -0
- apsimo/gate/layers/base.py +15 -0
- apsimo/gate/layers/l1_recipient.py +66 -0
- apsimo/gate/layers/l2_pii.py +134 -0
- apsimo/gate/layers/l3_cross_context.py +50 -0
- apsimo/gate/layers/l4_trust_tier.py +78 -0
- apsimo/gate/layers/l5_injection.py +199 -0
- apsimo/gate/layers/l6_review.py +86 -0
- apsimo/gate/layers/l7_delay.py +100 -0
- apsimo/gate/layers/tom2_epistemic.py +185 -0
- apsimo/gate/models.py +64 -0
- apsimo/gate/pending_dispatch.py +5 -0
- apsimo/gate/pipeline.py +206 -0
- apsimo/gate/rejection.py +259 -0
- apsimo/gate/response_guard.py +700 -0
- apsimo/gate/rulesets/injection_v1.yaml +51 -0
- apsimo/gate/surface_policy.py +189 -0
- apsimo/gate/taint.py +226 -0
- apsimo/genesis.json +9 -0
- apsimo/goals/__init__.py +100 -0
- apsimo/goals/config.py +38 -0
- apsimo/goals/decomposer.py +421 -0
- apsimo/goals/engine.py +617 -0
- apsimo/goals/inference.py +354 -0
- apsimo/goals/models.py +302 -0
- apsimo/goals/priority.py +270 -0
- apsimo/goals/queue_bridge.py +149 -0
- apsimo/goals/replan.py +450 -0
- apsimo/goals/schema.sql +89 -0
- apsimo/goals/store.py +692 -0
- apsimo/governed_actions.py +1708 -0
- apsimo/harness_integration/__init__.py +45 -0
- apsimo/harness_integration/context.py +41 -0
- apsimo/harness_integration/skills.py +231 -0
- apsimo/identity/__init__.py +26 -0
- apsimo/identity/participants.py +181 -0
- apsimo/identity/resolver.py +329 -0
- apsimo/identity_bootstrap/__init__.py +5 -0
- apsimo/identity_bootstrap/builder.py +208 -0
- apsimo/identity_bootstrap/corpus.py +443 -0
- apsimo/identity_bootstrap/models.py +54 -0
- apsimo/identity_bootstrap/runner.py +353 -0
- apsimo/identity_bootstrap/seeders/__init__.py +25 -0
- apsimo/identity_bootstrap/seeders/briefings.py +109 -0
- apsimo/identity_bootstrap/seeders/chain.py +57 -0
- apsimo/identity_bootstrap/seeders/goals.py +128 -0
- apsimo/identity_bootstrap/seeders/memory.py +191 -0
- apsimo/identity_bootstrap/seeders/neo4j_cognition.py +79 -0
- apsimo/identity_bootstrap/seeders/relationship.py +152 -0
- apsimo/identity_bootstrap/seeders/sessions.py +67 -0
- apsimo/identity_bootstrap/seeders/skills.py +92 -0
- apsimo/identity_bootstrap/seeders/task_queue.py +72 -0
- apsimo/identity_bootstrap/seeders/world_model.py +143 -0
- apsimo/identity_bootstrap/self_query.py +92 -0
- apsimo/identity_bootstrap/self_reflection.py +155 -0
- apsimo/identity_bootstrap/skill.py +37 -0
- apsimo/identity_bootstrap/verifier.py +436 -0
- apsimo/initiatives/__init__.py +20 -0
- apsimo/initiatives/action_registry.py +454 -0
- apsimo/initiatives/approval_authority.py +2105 -0
- apsimo/initiatives/approval_policy.py +123 -0
- apsimo/initiatives/assignment.py +263 -0
- apsimo/initiatives/backup_evidence.py +100 -0
- apsimo/initiatives/context_freshness.py +103 -0
- apsimo/initiatives/models.py +318 -0
- apsimo/initiatives/native_work.py +270 -0
- apsimo/initiatives/standing_approvals.py +232 -0
- apsimo/initiatives/store.py +1081 -0
- apsimo/initiatives/temporal_followup.py +410 -0
- apsimo/intelligence/__init__.py +1 -0
- apsimo/intelligence/cognition/__init__.py +24 -0
- apsimo/intelligence/cognition/gap_detector.py +148 -0
- apsimo/intelligence/cognition/metalearner.py +547 -0
- apsimo/intelligence/cognition/metrics_collector.py +217 -0
- apsimo/intelligence/cognition/performance_index.py +299 -0
- apsimo/intelligence/cognition/registry.py +192 -0
- apsimo/intelligence/cognition/strategy_adjuster.py +222 -0
- apsimo/intelligence/cognition/types.py +16 -0
- apsimo/intelligence/components/__init__.py +66 -0
- apsimo/intelligence/components/anomaly_detector.py +413 -0
- apsimo/intelligence/components/initiative_engine.py +2643 -0
- apsimo/intelligence/components/preference_learner.py +521 -0
- apsimo/intelligence/components/research_orchestrator.py +358 -0
- apsimo/intelligence/components/self_directed_thinker.py +221 -0
- apsimo/intelligence/components/self_reflector.py +252 -0
- apsimo/intelligence/components/session_continuity.py +154 -0
- apsimo/intelligence/components/task_planner.py +320 -0
- apsimo/intelligence/components/tool_learner.py +217 -0
- apsimo/intelligence/graph/__init__.py +79 -0
- apsimo/intelligence/graph/client.py +2483 -0
- apsimo/intelligence/graph/consolidator.py +405 -0
- apsimo/intelligence/graph/distiller.py +312 -0
- apsimo/intelligence/graph/migrations.py +129 -0
- apsimo/intelligence/graph/queries.py +248 -0
- apsimo/intelligence/graph/recall.py +281 -0
- apsimo/intelligence/graph/reconciler.py +144 -0
- apsimo/intelligence/graph/schema.py +337 -0
- apsimo/intelligence/graph/selection.py +252 -0
- apsimo/intelligence/learning/__init__.py +17 -0
- apsimo/intelligence/learning/continuous_learner.py +245 -0
- apsimo/intelligence/learning/feedback_store.py +321 -0
- apsimo/intelligence/mind_model/__init__.py +1 -0
- apsimo/intelligence/mind_model/graph_baseline.py +136 -0
- apsimo/intelligence/mind_model/signal_collector.py +361 -0
- apsimo/intelligence/relationships/__init__.py +11 -0
- apsimo/intelligence/relationships/profiler.py +389 -0
- apsimo/intelligence/relationships/scorer.py +560 -0
- apsimo/intelligence/relationships/signal_floor.py +66 -0
- apsimo/intelligence/relationships/trust_tiers.py +300 -0
- apsimo/intelligence/synthesis/__init__.py +40 -0
- apsimo/intelligence/synthesis/connection_discoverer.py +379 -0
- apsimo/intelligence/synthesis/cross_domain_analyzer.py +287 -0
- apsimo/intelligence/synthesis/insight_deliverer.py +171 -0
- apsimo/intelligence/synthesis/insight_store.py +79 -0
- apsimo/intelligence/synthesis/insight_validator.py +183 -0
- apsimo/intelligence/synthesis/novelty_scorer.py +267 -0
- apsimo/intelligence/turn_middleware/__init__.py +15 -0
- apsimo/intelligence/turn_middleware/memory_sync.py +119 -0
- apsimo/mcp/__init__.py +41 -0
- apsimo/mcp/__main__.py +6 -0
- apsimo/mcp/config.py +287 -0
- apsimo/mcp/server.py +501 -0
- apsimo/migrations.py +187 -0
- apsimo/mining/__init__.py +27 -0
- apsimo/mining/corpus.py +239 -0
- apsimo/mining/escalations.py +289 -0
- apsimo/mining/models.py +169 -0
- apsimo/mining/store.py +210 -0
- apsimo/models/__init__.py +30 -0
- apsimo/models/memory.py +80 -0
- apsimo/models/mesh.py +72 -0
- apsimo/models/person.py +104 -0
- apsimo/models/signal.py +108 -0
- apsimo/observations/__init__.py +15 -0
- apsimo/observations/store.py +277 -0
- apsimo/patterns/__init__.py +6 -0
- apsimo/patterns/extract.py +187 -0
- apsimo/patterns/store.py +227 -0
- apsimo/persona/__init__.py +1 -0
- apsimo/persona/engine.py +611 -0
- apsimo/persona/manifest.py +140 -0
- apsimo/projects/__init__.py +28 -0
- apsimo/projects/engine.py +1681 -0
- apsimo/projects/event_outbox.py +188 -0
- apsimo/projects/models.py +216 -0
- apsimo/projects/planner.py +181 -0
- apsimo/projects/store.py +1446 -0
- apsimo/proposals/__init__.py +12 -0
- apsimo/proposals/engine.py +114 -0
- apsimo/proposals/models.py +207 -0
- apsimo/qualification/__init__.py +1 -0
- apsimo/qualification/cases.py +75 -0
- apsimo/qualification/cli.py +51 -0
- apsimo/qualification/memory_cases.py +209 -0
- apsimo/qualification/records.py +92 -0
- apsimo/qualification/report.py +87 -0
- apsimo/qualification/runner.py +311 -0
- apsimo/qualification/structured_cases.py +131 -0
- apsimo/reasoning/__init__.py +13 -0
- apsimo/reasoning/executor.py +506 -0
- apsimo/reasoning/loop.py +373 -0
- apsimo/reasoning/native_tools/__init__.py +16 -0
- apsimo/reasoning/native_tools/calculate.py +141 -0
- apsimo/reasoning/native_tools/file_ops.py +150 -0
- apsimo/reasoning/native_tools/web_search.py +49 -0
- apsimo/reasoning/tool_policy.py +182 -0
- apsimo/redact/__init__.py +176 -0
- apsimo/repos/__init__.py +5 -0
- apsimo/repos/mirrors.py +204 -0
- apsimo/research/__init__.py +41 -0
- apsimo/research/artifact.py +482 -0
- apsimo/research/gatherer.py +387 -0
- apsimo/research/pipeline.py +513 -0
- apsimo/research/search/__init__.py +7 -0
- apsimo/research/search/base.py +41 -0
- apsimo/research/search/brave.py +59 -0
- apsimo/research/search/cache.py +51 -0
- apsimo/research/search/duckduckgo.py +103 -0
- apsimo/research/search/orchestrator.py +119 -0
- apsimo/research/search/serpapi.py +59 -0
- apsimo/research/search/tavily.py +59 -0
- apsimo/research/synthesizer.py +309 -0
- apsimo/router/__init__.py +30 -0
- apsimo/router/complexity_scorer.py +148 -0
- apsimo/router/endpoints.py +153 -0
- apsimo/router/fallback.py +58 -0
- apsimo/router/functions.py +243 -0
- apsimo/router/native_policy.py +52 -0
- apsimo/router/router.py +762 -0
- apsimo/router/self_learning.py +174 -0
- apsimo/router/tiers.py +677 -0
- apsimo/sandbox/__init__.py +21 -0
- apsimo/sandbox/backend.py +195 -0
- apsimo/sandbox/manager.py +173 -0
- apsimo/scope_bounds.py +7 -0
- apsimo/secrets/__init__.py +6 -0
- apsimo/secrets/backends/__init__.py +8 -0
- apsimo/secrets/backends/base.py +42 -0
- apsimo/secrets/backends/env.py +110 -0
- apsimo/secrets/backends/keyring.py +72 -0
- apsimo/secrets/backends/onepassword.py +232 -0
- apsimo/secrets/cli.py +191 -0
- apsimo/secrets/manager.py +160 -0
- apsimo/secrets/migration.py +101 -0
- apsimo/secrets/types.py +98 -0
- apsimo/seed.py +41 -0
- apsimo/self_model/__init__.py +37 -0
- apsimo/self_model/appraisals.py +673 -0
- apsimo/self_model/benchmark.py +1314 -0
- apsimo/self_model/brief.py +40 -0
- apsimo/self_model/event_concerns.py +1128 -0
- apsimo/self_model/execution_forecasts.py +353 -0
- apsimo/self_model/expectations.py +1595 -0
- apsimo/self_model/experiments.py +1150 -0
- apsimo/self_model/journal.py +148 -0
- apsimo/self_model/judgments.py +705 -0
- apsimo/self_model/native_outcomes.py +55 -0
- apsimo/self_model/params.py +220 -0
- apsimo/self_model/perspective.py +246 -0
- apsimo/self_model/reconcile.py +183 -0
- apsimo/self_model/reply_forecasts.py +381 -0
- apsimo/self_model/runtime_forecasts.py +296 -0
- apsimo/self_model/runtime_models.py +67 -0
- apsimo/self_model/settlement.py +207 -0
- apsimo/self_model/situation.py +1731 -0
- apsimo/self_model/store.py +883 -0
- apsimo/self_model/supervised.py +137 -0
- apsimo/self_model/thinker.py +99 -0
- apsimo/self_model/trust.py +388 -0
- apsimo/self_model/workspace.py +2388 -0
- apsimo/server.py +4197 -0
- apsimo/services/__init__.py +1 -0
- apsimo/services/agent_bridge.py +474 -0
- apsimo/services/initiative_executor.py +914 -0
- apsimo/services/instance.py +297 -0
- apsimo/sessions/__init__.py +22 -0
- apsimo/sessions/config.py +13 -0
- apsimo/sessions/context_loader.py +88 -0
- apsimo/sessions/federation_session.py +75 -0
- apsimo/sessions/isolated_session.py +98 -0
- apsimo/sessions/reports.py +84 -0
- apsimo/sessions/store.py +148 -0
- apsimo/setup.py +2818 -0
- apsimo/setup_hermes.py +879 -0
- apsimo/setup_local_work.py +218 -0
- apsimo/setup_native_goals.py +134 -0
- apsimo/setup_native_reviews.py +115 -0
- apsimo/skills/__init__.py +10 -0
- apsimo/skills/base.py +108 -0
- apsimo/skills/budget.py +28 -0
- apsimo/skills/executor.py +493 -0
- apsimo/skills/executors/__init__.py +1 -0
- apsimo/skills/executors/behavioral_correction.py +75 -0
- apsimo/skills/executors/capability_gap.py +38 -0
- apsimo/skills/executors/data_quality.py +163 -0
- apsimo/skills/executors/knowledge_acquisition.py +41 -0
- apsimo/skills/executors/operational_hygiene.py +185 -0
- apsimo/skills/executors/subsystem_health.py +169 -0
- apsimo/skills/hermes_export.py +431 -0
- apsimo/skills/index.py +123 -0
- apsimo/skills/learning/__init__.py +21 -0
- apsimo/skills/learning/novelty_detector.py +206 -0
- apsimo/skills/learning/pattern_extractor.py +199 -0
- apsimo/skills/learning/triggers.py +159 -0
- apsimo/skills/loader.py +246 -0
- apsimo/skills/migrations/002_progressive_loading.sql +6 -0
- apsimo/skills/migrations/backfill_triggers.py +20 -0
- apsimo/skills/models.py +202 -0
- apsimo/skills/packager.py +128 -0
- apsimo/skills/protocols.py +70 -0
- apsimo/skills/registry.py +191 -0
- apsimo/skills/runtime.py +58 -0
- apsimo/skills/sandbox_runner.py +229 -0
- apsimo/skills/scheduler.py +129 -0
- apsimo/skills/schema.py +79 -0
- apsimo/skills/security/__init__.py +12 -0
- apsimo/skills/security/guards.py +53 -0
- apsimo/skills/security/scanner.py +223 -0
- apsimo/skills_memory/__init__.py +26 -0
- apsimo/skills_memory/distill.py +159 -0
- apsimo/skills_memory/models.py +85 -0
- apsimo/skills_memory/retrieve.py +62 -0
- apsimo/skills_memory/store.py +172 -0
- apsimo/surprise/__init__.py +6 -0
- apsimo/surprise/accumulation.py +57 -0
- apsimo/surprise/scorer.py +102 -0
- apsimo/surprise/store.py +203 -0
- apsimo/task_queue/__init__.py +69 -0
- apsimo/task_queue/action_receipts.py +148 -0
- apsimo/task_queue/approval_relay_canary.py +108 -0
- apsimo/task_queue/config.py +85 -0
- apsimo/task_queue/contract.py +361 -0
- apsimo/task_queue/events.py +130 -0
- apsimo/task_queue/governor.py +1031 -0
- apsimo/task_queue/handlers/__init__.py +16 -0
- apsimo/task_queue/handlers/base.py +37 -0
- apsimo/task_queue/handlers/inference.py +640 -0
- apsimo/task_queue/handlers/monitoring.py +116 -0
- apsimo/task_queue/handlers/registry.py +75 -0
- apsimo/task_queue/handlers/subtask_handler.py +173 -0
- apsimo/task_queue/handlers/system_maintenance.py +147 -0
- apsimo/task_queue/mesh_integration.py +111 -0
- apsimo/task_queue/models.py +317 -0
- apsimo/task_queue/queue_manager.py +8286 -0
- apsimo/task_queue/routing.py +287 -0
- apsimo/task_queue/scheduler.py +252 -0
- apsimo/task_queue/schema.sql +197 -0
- apsimo/task_queue/work_control.py +342 -0
- apsimo/task_queue/worker.py +993 -0
- apsimo/telemetry.py +145 -0
- apsimo/tom/__init__.py +6 -0
- apsimo/tom/affect.py +387 -0
- apsimo/tom/approvals.py +171 -0
- apsimo/tom/arcs.py +896 -0
- apsimo/tom/asymmetry.py +131 -0
- apsimo/tom/eligibility.py +248 -0
- apsimo/tom/engagement.py +214 -0
- apsimo/tom/exposure.py +214 -0
- apsimo/tom/extractor.py +306 -0
- apsimo/tom/fact_adapters.py +144 -0
- apsimo/tom/facts.py +326 -0
- apsimo/tom/integration.py +592 -0
- apsimo/tom/leveled.py +118 -0
- apsimo/tom/levels.py +247 -0
- apsimo/tom/recipient_audit.py +995 -0
- apsimo/tom/recipient_simulator.py +593 -0
- apsimo/tom/source_lineage.py +93 -0
- apsimo/tom/tom2.py +277 -0
- apsimo/tom/visibility.py +559 -0
- apsimo/tom/visibility_store.py +414 -0
- apsimo/tools/__init__.py +0 -0
- apsimo/tools/definitions.py +740 -0
- apsimo/tools/handlers.py +943 -0
- apsimo/toolsmith/__init__.py +26 -0
- apsimo/toolsmith/authority.py +166 -0
- apsimo/toolsmith/engine.py +559 -0
- apsimo/toolsmith/integrity.py +100 -0
- apsimo/toolsmith/miner.py +145 -0
- apsimo/toolsmith/policy.py +110 -0
- apsimo/toolsmith/registry.py +635 -0
- apsimo/turns/__init__.py +17 -0
- apsimo/turns/audio.py +134 -0
- apsimo/turns/documents.py +235 -0
- apsimo/turns/executions.py +486 -0
- apsimo/turns/hermes_history.py +245 -0
- apsimo/turns/hermes_kanban.py +268 -0
- apsimo/turns/hermes_work.py +96 -0
- apsimo/turns/idempotency.py +752 -0
- apsimo/turns/local_work.py +115 -0
- apsimo/turns/media.py +581 -0
- apsimo/turns/reported_workers.py +196 -0
- apsimo/turns/source_annotations.py +283 -0
- apsimo/turns/source_attribution.py +154 -0
- apsimo/turns/source_read.py +351 -0
- apsimo/turns/source_vectors.py +263 -0
- apsimo/turns/video.py +210 -0
- apsimo/util/autonomy_preset.py +220 -0
- apsimo/util/instance.py +92 -0
- apsimo/util/model_output.py +25 -0
- apsimo/util/quiet_hours.py +27 -0
- apsimo/util/session_safety.py +37 -0
- apsimo/util/temporal.py +343 -0
- apsimo/vector/__init__.py +75 -0
- apsimo/vector/backfill.py +171 -0
- apsimo/vector/caption.py +114 -0
- apsimo/vector/collections.py +51 -0
- apsimo/vector/config.py +102 -0
- apsimo/vector/embedder.py +670 -0
- apsimo/vector/image_preprocess.py +406 -0
- apsimo/vector/image_store.py +296 -0
- apsimo/vector/indexes.py +162 -0
- apsimo/vector/migrate.py +334 -0
- apsimo/vector/multimodal_provider.py +417 -0
- apsimo/vector/multimodal_types.py +87 -0
- apsimo/vector/openai_provider.py +119 -0
- apsimo/vector/query.py +49 -0
- apsimo/vector/reranker.py +565 -0
- apsimo/vector/safety_image.py +159 -0
- apsimo/vector/scanner.py +197 -0
- apsimo/vector/setup.py +289 -0
- apsimo/vector/store.py +533 -0
- apsimo/vector/tiers.py +263 -0
- apsimo/work_orders.py +925 -0
- apsimo/workers/__init__.py +21 -0
- apsimo/workers/agent_bridge.py +640 -0
- apsimo/workers/colony_worker.py +382 -0
- apsimo/workers/queue_worker.py +441 -0
- apsimo/workers/skills_sync.py +152 -0
- apsimo/world_model/__init__.py +71 -0
- apsimo/world_model/causal_maintenance.py +131 -0
- apsimo/world_model/causal_policy.py +43 -0
- apsimo/world_model/causal_query.py +125 -0
- apsimo/world_model/confidence.py +54 -0
- apsimo/world_model/config.py +64 -0
- apsimo/world_model/constants.py +97 -0
- apsimo/world_model/entities.py +145 -0
- apsimo/world_model/expectation_resolvers.py +177 -0
- apsimo/world_model/extraction/__init__.py +7 -0
- apsimo/world_model/extraction/base.py +62 -0
- apsimo/world_model/extraction/conversation_extractor.py +262 -0
- apsimo/world_model/extraction/detector.py +74 -0
- apsimo/world_model/extraction/document_extractor.py +78 -0
- apsimo/world_model/extraction/formats/__init__.py +24 -0
- apsimo/world_model/extraction/formats/csv_fmt.py +68 -0
- apsimo/world_model/extraction/formats/html_fmt.py +72 -0
- apsimo/world_model/extraction/formats/json_fmt.py +68 -0
- apsimo/world_model/extraction/formats/pdf.py +43 -0
- apsimo/world_model/extraction/formats/text.py +27 -0
- apsimo/world_model/extraction/llm_extractor.py +164 -0
- apsimo/world_model/extraction/pipeline.py +73 -0
- apsimo/world_model/integrations/__init__.py +5 -0
- apsimo/world_model/integrations/mind_model_bridge.py +115 -0
- apsimo/world_model/integrations/social_intel_bridge.py +120 -0
- apsimo/world_model/jobs/__init__.py +4 -0
- apsimo/world_model/jobs/extraction_job.py +168 -0
- apsimo/world_model/llm_extract.py +572 -0
- apsimo/world_model/neo4j/__init__.py +5 -0
- apsimo/world_model/neo4j/backend.py +654 -0
- apsimo/world_model/observations.py +155 -0
- apsimo/world_model/populator.py +307 -0
- apsimo/world_model/postgres/__init__.py +1 -0
- apsimo/world_model/postgres/backend.py +683 -0
- apsimo/world_model/relationships.py +25 -0
- apsimo/world_model/resolution/__init__.py +13 -0
- apsimo/world_model/resolution/entity_resolver.py +232 -0
- apsimo/world_model/resolution/merge_audit.py +16 -0
- apsimo/world_model/resolution/merge_workflow.py +117 -0
- apsimo/world_model/source_reports.py +121 -0
- apsimo/world_model/sqlite/__init__.py +4 -0
- apsimo/world_model/sqlite/backend.py +855 -0
- apsimo/world_model/sqlite/schema.sql +132 -0
- apsimo/world_model/store.py +545 -0
- apsimo-1.3.0.dist-info/METADATA +78 -0
- apsimo-1.3.0.dist-info/RECORD +614 -0
- apsimo-1.3.0.dist-info/WHEEL +5 -0
- apsimo-1.3.0.dist-info/entry_points.txt +11 -0
- apsimo-1.3.0.dist-info/licenses/LICENSE +21 -0
- apsimo-1.3.0.dist-info/top_level.txt +2 -0
- colony_sidecar/__init__.py +4 -0
apsimo/contacts/store.py
ADDED
|
@@ -0,0 +1,1623 @@
|
|
|
1
|
+
"""Colony Contacts — SQLite-backed ContactStore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import hashlib
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
import aiosqlite
|
|
15
|
+
|
|
16
|
+
from .config import ContactsConfig
|
|
17
|
+
from .models import (
|
|
18
|
+
Contact,
|
|
19
|
+
ContactHandle,
|
|
20
|
+
ScopeMember,
|
|
21
|
+
TrustScope,
|
|
22
|
+
TRUST_TIERS,
|
|
23
|
+
TIER_DEFAULT_INTERACTION,
|
|
24
|
+
more_permissive_tier,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("colony.contacts.store")
|
|
28
|
+
|
|
29
|
+
_SCHEMA_FILE = Path(__file__).parent / "migrations" / "001_contacts_schema.sql"
|
|
30
|
+
_SCHEMA_FILE_002 = Path(__file__).parent / "migrations" / "002_trust_scopes.sql"
|
|
31
|
+
_MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
|
32
|
+
|
|
33
|
+
_PHONE_DIGITS = re.compile(r'\D')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _gen_id(prefix: str) -> str:
|
|
37
|
+
import secrets
|
|
38
|
+
ts = int(time.time() * 1000)
|
|
39
|
+
rand = secrets.token_hex(6) # 12 hex chars, 48 bits of CSPRNG entropy
|
|
40
|
+
return f"{prefix}-{ts}-{rand}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _now_iso() -> str:
|
|
44
|
+
from datetime import datetime, timezone
|
|
45
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _normalize_phone(phone: str) -> str:
|
|
49
|
+
"""Strip non-digit characters; preserve leading +."""
|
|
50
|
+
phone = phone.strip()
|
|
51
|
+
if phone.startswith("+"):
|
|
52
|
+
return "+" + _PHONE_DIGITS.sub("", phone[1:])
|
|
53
|
+
return _PHONE_DIGITS.sub("", phone)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _normalize_email(email: str) -> str:
|
|
57
|
+
return email.strip().lower()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# A phone number is ONE identity across channels. Phone-bearing gateways resolve a number to the
|
|
61
|
+
# same contact regardless of which transport it arrived on (sms/rcs/imessage/signal/whatsapp).
|
|
62
|
+
from apsimo.channels.phone_gateways import get_phone_gateways as _get_phone_gateways
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _looks_like_phone(address: str) -> bool:
|
|
66
|
+
"""True if `address` is a phone number (digits/+, no letters) — used to route an unknown-gateway
|
|
67
|
+
sender (e.g. a 'custom' platform) into the phone-identity resolution path."""
|
|
68
|
+
s = (address or "").strip()
|
|
69
|
+
if not s or any(c.isalpha() for c in s):
|
|
70
|
+
return False
|
|
71
|
+
digits = _PHONE_DIGITS.sub("", s.lstrip("+"))
|
|
72
|
+
return len(digits) >= 7
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _phone_key(address: str) -> str:
|
|
76
|
+
"""Canonical phone digits, retaining international country codes."""
|
|
77
|
+
digits = _PHONE_DIGITS.sub("", (address or "").split('@', 1)[0])
|
|
78
|
+
# The supported bare NANP form may omit +1; other country codes stay intact.
|
|
79
|
+
return '1' + digits if len(digits) == 10 and not (address or '').strip().startswith('+') else digits
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _name_similarity(a: Optional[str], b: Optional[str]) -> float:
|
|
83
|
+
"""Simple character bigram similarity in [0, 1]."""
|
|
84
|
+
if not a or not b:
|
|
85
|
+
return 0.0
|
|
86
|
+
a, b = a.lower(), b.lower()
|
|
87
|
+
if a == b:
|
|
88
|
+
return 1.0
|
|
89
|
+
|
|
90
|
+
def bigrams(s):
|
|
91
|
+
return {s[i:i+2] for i in range(len(s) - 1)}
|
|
92
|
+
|
|
93
|
+
bg_a, bg_b = bigrams(a), bigrams(b)
|
|
94
|
+
if not bg_a or not bg_b:
|
|
95
|
+
return 0.0
|
|
96
|
+
return 2.0 * len(bg_a & bg_b) / (len(bg_a) + len(bg_b))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── Abstract interface ────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
class ContactStore(ABC):
|
|
102
|
+
"""Primary read/write interface for the contact store."""
|
|
103
|
+
|
|
104
|
+
@abstractmethod
|
|
105
|
+
async def get(self, contact_id: str) -> Optional[Contact]:
|
|
106
|
+
"""Fetch a contact by canonical ID. Returns None if not found or deleted."""
|
|
107
|
+
|
|
108
|
+
@abstractmethod
|
|
109
|
+
async def resolve_handle(self, gateway: str, address: str) -> Optional[Contact]:
|
|
110
|
+
"""Resolve a gateway handle to a Contact."""
|
|
111
|
+
|
|
112
|
+
async def resolve_verified_handles(self, gateway: str, addresses: List[str]) -> Optional[Contact]:
|
|
113
|
+
"""Resolve exact transport aliases only when they identify one verified contact."""
|
|
114
|
+
raise NotImplementedError
|
|
115
|
+
|
|
116
|
+
@abstractmethod
|
|
117
|
+
async def create(
|
|
118
|
+
self,
|
|
119
|
+
display_name: Optional[str] = None,
|
|
120
|
+
given_name: Optional[str] = None,
|
|
121
|
+
family_name: Optional[str] = None,
|
|
122
|
+
organization: Optional[str] = None,
|
|
123
|
+
trust_tier: str = "unknown",
|
|
124
|
+
interaction_allowed: Optional[bool] = None,
|
|
125
|
+
tags: Optional[List[str]] = None,
|
|
126
|
+
privacy_level: str = "private",
|
|
127
|
+
import_source: str = "manual",
|
|
128
|
+
notes: Optional[str] = None,
|
|
129
|
+
) -> Contact:
|
|
130
|
+
"""Create a new contact record."""
|
|
131
|
+
|
|
132
|
+
@abstractmethod
|
|
133
|
+
async def add_handle(
|
|
134
|
+
self,
|
|
135
|
+
contact_id: str,
|
|
136
|
+
gateway: str,
|
|
137
|
+
address: str,
|
|
138
|
+
is_primary: bool = False,
|
|
139
|
+
confidence: float = 1.0,
|
|
140
|
+
source: str = "manual",
|
|
141
|
+
verified: bool = False,
|
|
142
|
+
) -> ContactHandle:
|
|
143
|
+
"""Add a gateway handle to an existing contact."""
|
|
144
|
+
|
|
145
|
+
@abstractmethod
|
|
146
|
+
async def provision_verified_handle(
|
|
147
|
+
self,
|
|
148
|
+
*,
|
|
149
|
+
operation_id: str,
|
|
150
|
+
performed_by: str,
|
|
151
|
+
gateway: str,
|
|
152
|
+
address: str,
|
|
153
|
+
display_name: Optional[str] = None,
|
|
154
|
+
contact_id: Optional[str] = None,
|
|
155
|
+
) -> Dict[str, Any]:
|
|
156
|
+
"""Atomically create/map one exact owner-verified contact handle.
|
|
157
|
+
|
|
158
|
+
Exactly one of ``display_name`` (create) and ``contact_id`` (map) is
|
|
159
|
+
required. Creation is always inert (``interaction_allowed=false``).
|
|
160
|
+
``operation_id`` is a durable idempotency key bound to the exact input
|
|
161
|
+
and authenticated principal.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
@abstractmethod
|
|
165
|
+
async def get_handles(self, contact_id: str) -> List[ContactHandle]:
|
|
166
|
+
"""Return all handles for a contact."""
|
|
167
|
+
|
|
168
|
+
@abstractmethod
|
|
169
|
+
async def update_tier(
|
|
170
|
+
self,
|
|
171
|
+
contact_id: str,
|
|
172
|
+
new_tier: str,
|
|
173
|
+
reason: Optional[str] = None,
|
|
174
|
+
performed_by: str = "operator",
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Update a contact's trust tier and record the change in audit."""
|
|
177
|
+
|
|
178
|
+
@abstractmethod
|
|
179
|
+
async def update_relationship_score(self, contact_id: str, score: float) -> None:
|
|
180
|
+
"""Update the relationship_score for a contact (0.0–1.0)."""
|
|
181
|
+
|
|
182
|
+
@abstractmethod
|
|
183
|
+
async def update_interaction_allowed(
|
|
184
|
+
self, contact_id: str, allowed: bool, performed_by: str = "operator"
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Toggle the interaction_allowed flag."""
|
|
187
|
+
|
|
188
|
+
@abstractmethod
|
|
189
|
+
async def soft_delete(
|
|
190
|
+
self, contact_id: str, reason: Optional[str] = None, performed_by: str = "operator"
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Soft-delete a contact."""
|
|
193
|
+
|
|
194
|
+
@abstractmethod
|
|
195
|
+
async def hard_delete(self, contact_id: str, performed_by: str = "system") -> None:
|
|
196
|
+
"""Permanently delete a contact record."""
|
|
197
|
+
|
|
198
|
+
@abstractmethod
|
|
199
|
+
async def list(
|
|
200
|
+
self,
|
|
201
|
+
trust_tier: Optional[str] = None,
|
|
202
|
+
interaction_allowed: Optional[bool] = None,
|
|
203
|
+
tag: Optional[str] = None,
|
|
204
|
+
include_deleted: bool = False,
|
|
205
|
+
limit: int = 100,
|
|
206
|
+
offset: int = 0,
|
|
207
|
+
) -> List[Contact]:
|
|
208
|
+
"""List contacts with optional filtering."""
|
|
209
|
+
|
|
210
|
+
@abstractmethod
|
|
211
|
+
async def find_by_name(self, name: str, threshold: float = 0.5) -> List[Contact]:
|
|
212
|
+
"""Find contacts whose display_name is similar to name."""
|
|
213
|
+
|
|
214
|
+
@abstractmethod
|
|
215
|
+
async def merge_contacts(
|
|
216
|
+
self, keep_id: str, merge_id: str, performed_by: str = "owner",
|
|
217
|
+
) -> Optional[Contact]:
|
|
218
|
+
"""Merge one contact into another: reassign handles, fold interaction
|
|
219
|
+
history, soft-delete the merged record. Audited and reversible."""
|
|
220
|
+
|
|
221
|
+
@abstractmethod
|
|
222
|
+
async def list_handle_proposals(self, limit: int = 50) -> List[Dict[str, Any]]:
|
|
223
|
+
"""Pending handle-link proposals (from scoped-name attribution) for
|
|
224
|
+
owner review: {contact_id, display_name, gateway, address, at}."""
|
|
225
|
+
|
|
226
|
+
@abstractmethod
|
|
227
|
+
async def update(self, contact_id: str, **fields) -> Optional[Contact]:
|
|
228
|
+
"""Update arbitrary fields on a contact."""
|
|
229
|
+
|
|
230
|
+
@abstractmethod
|
|
231
|
+
async def record_audit(
|
|
232
|
+
self,
|
|
233
|
+
contact_id: str,
|
|
234
|
+
action: str,
|
|
235
|
+
detail: Optional[Dict[str, Any]] = None,
|
|
236
|
+
performed_by: str = "system",
|
|
237
|
+
) -> None:
|
|
238
|
+
"""Write an audit record."""
|
|
239
|
+
|
|
240
|
+
@abstractmethod
|
|
241
|
+
async def connect(self) -> None:
|
|
242
|
+
"""Open the database connection."""
|
|
243
|
+
|
|
244
|
+
@abstractmethod
|
|
245
|
+
async def close(self) -> None:
|
|
246
|
+
"""Close the database connection."""
|
|
247
|
+
|
|
248
|
+
async def __aenter__(self) -> "ContactStore":
|
|
249
|
+
await self.connect()
|
|
250
|
+
return self
|
|
251
|
+
|
|
252
|
+
async def __aexit__(self, *_) -> None:
|
|
253
|
+
await self.close()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ── SQLite implementation ─────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
class SQLiteContactStore(ContactStore):
|
|
259
|
+
"""SQLite-backed implementation of ContactStore."""
|
|
260
|
+
|
|
261
|
+
def __init__(self, config: Optional[ContactsConfig] = None, graph=None) -> None:
|
|
262
|
+
self._config = config or ContactsConfig()
|
|
263
|
+
self._db: Optional[aiosqlite.Connection] = None
|
|
264
|
+
self._graph = graph # Optional ColonyGraph for score sync
|
|
265
|
+
|
|
266
|
+
async def connect(self) -> None:
|
|
267
|
+
path = self._config.sqlite_path
|
|
268
|
+
if path != ":memory:":
|
|
269
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
self._db = await aiosqlite.connect(path)
|
|
271
|
+
self._db.row_factory = aiosqlite.Row
|
|
272
|
+
# In-query phone identity key so messaging resolution matches a number to its contact even
|
|
273
|
+
# when stored handles are formatted differently / under a different gateway -- works without
|
|
274
|
+
# a data migration (the stored side is keyed at query time).
|
|
275
|
+
await self._db.create_function(
|
|
276
|
+
"phone_key", 1, lambda v: _phone_key(v) if v else "")
|
|
277
|
+
await self._db.execute("PRAGMA journal_mode=WAL")
|
|
278
|
+
await self._db.execute("PRAGMA foreign_keys=ON")
|
|
279
|
+
from apsimo.migrations import run_migrations
|
|
280
|
+
await run_migrations(self._db, _MIGRATIONS_DIR)
|
|
281
|
+
await self._apply_migrations()
|
|
282
|
+
|
|
283
|
+
async def _apply_migrations(self) -> None:
|
|
284
|
+
"""Idempotent additive migrations for DBs created before a column existed.
|
|
285
|
+
|
|
286
|
+
SQLite has no ADD COLUMN IF NOT EXISTS, so we introspect first.
|
|
287
|
+
"""
|
|
288
|
+
db = self._db
|
|
289
|
+
assert db is not None
|
|
290
|
+
async with db.execute("PRAGMA table_info(contacts)") as cur:
|
|
291
|
+
cols = {row[1] for row in await cur.fetchall()}
|
|
292
|
+
if "timezone" not in cols: # v0.21.0 — per-contact timezone
|
|
293
|
+
await db.execute("ALTER TABLE contacts ADD COLUMN timezone TEXT")
|
|
294
|
+
await db.commit()
|
|
295
|
+
# Introduction provenance (social-graph autonomy): who introduced this
|
|
296
|
+
# contact + how/where the agent met them. First-class on the contact so
|
|
297
|
+
# it is always available even when no world-model Person node exists yet.
|
|
298
|
+
if "introduced_by" not in cols:
|
|
299
|
+
await db.execute("ALTER TABLE contacts ADD COLUMN introduced_by TEXT")
|
|
300
|
+
await db.commit()
|
|
301
|
+
if "met_via_json" not in cols:
|
|
302
|
+
await db.execute("ALTER TABLE contacts ADD COLUMN met_via_json TEXT")
|
|
303
|
+
await db.commit()
|
|
304
|
+
|
|
305
|
+
async def close(self) -> None:
|
|
306
|
+
if self._db:
|
|
307
|
+
await self._db.close()
|
|
308
|
+
self._db = None
|
|
309
|
+
|
|
310
|
+
def _require_db(self) -> aiosqlite.Connection:
|
|
311
|
+
if not self._db:
|
|
312
|
+
raise RuntimeError("ContactStore not connected. Use async with or call connect().")
|
|
313
|
+
return self._db
|
|
314
|
+
|
|
315
|
+
async def _open_provision_connection(self) -> aiosqlite.Connection:
|
|
316
|
+
"""Open one dedicated durable connection for a provisioning command.
|
|
317
|
+
|
|
318
|
+
Provisioning promises crash-safe idempotency. A process-local memory
|
|
319
|
+
database cannot provide that promise, and sharing ``self._db`` would
|
|
320
|
+
allow an unrelated coroutine's commit to persist a partial operation.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
raw_path = str(self._config.sqlite_path or "").strip()
|
|
324
|
+
if (
|
|
325
|
+
not raw_path
|
|
326
|
+
or raw_path == ":memory:"
|
|
327
|
+
or raw_path.startswith("file:")
|
|
328
|
+
):
|
|
329
|
+
raise RuntimeError(
|
|
330
|
+
"contact provisioning requires a durable file-backed store"
|
|
331
|
+
)
|
|
332
|
+
path = Path(raw_path).expanduser().resolve()
|
|
333
|
+
if not path.is_file():
|
|
334
|
+
raise RuntimeError(
|
|
335
|
+
"contact provisioning durable store is unavailable"
|
|
336
|
+
)
|
|
337
|
+
db = await aiosqlite.connect(str(path), timeout=5.0)
|
|
338
|
+
db.row_factory = aiosqlite.Row
|
|
339
|
+
await db.create_function(
|
|
340
|
+
"phone_key", 1, lambda value: _phone_key(value) if value else ""
|
|
341
|
+
)
|
|
342
|
+
await db.execute("PRAGMA foreign_keys=ON")
|
|
343
|
+
await db.execute("PRAGMA busy_timeout=5000")
|
|
344
|
+
return db
|
|
345
|
+
|
|
346
|
+
async def _after_provision_contact_insert(self) -> None:
|
|
347
|
+
"""Internal fault-injection seam; production has no side effect."""
|
|
348
|
+
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
# ── Read ops ──────────────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
async def propose_handle_link(self, contact_id, gateway, address, *, evidence_refs=(), source='auto:scoped-name'):
|
|
354
|
+
from .identity_links import propose
|
|
355
|
+
return await propose(self, contact_id=contact_id, gateway=gateway, address=address,
|
|
356
|
+
evidence_refs=evidence_refs, source=source)
|
|
357
|
+
|
|
358
|
+
async def correct_handle_identity(self, **kwargs):
|
|
359
|
+
from .identity_links import correct
|
|
360
|
+
return await correct(self, **kwargs)
|
|
361
|
+
|
|
362
|
+
async def pending_identity_reconciliations(self, *, limit=100):
|
|
363
|
+
from .identity_links import pending_reconciliations
|
|
364
|
+
return await pending_reconciliations(self, limit=limit)
|
|
365
|
+
|
|
366
|
+
async def mark_sources_reconciled(self, operation_id, source_result):
|
|
367
|
+
from .identity_links import mark_sources_reconciled
|
|
368
|
+
return await mark_sources_reconciled(self, operation_id=operation_id, source_result=source_result)
|
|
369
|
+
|
|
370
|
+
async def mark_sources_conflicted(self, operation_id, code):
|
|
371
|
+
from .identity_links import mark_sources_conflicted
|
|
372
|
+
return await mark_sources_conflicted(self, operation_id=operation_id, code=code)
|
|
373
|
+
|
|
374
|
+
async def identity_evidence(self, contact_id):
|
|
375
|
+
from .identity_links import evidence
|
|
376
|
+
return await evidence(self, contact_id)
|
|
377
|
+
|
|
378
|
+
async def identity_revision(self):
|
|
379
|
+
async with self._require_db().execute('SELECT coalesce(max(rowid),0) FROM contact_identity_operations') as cur:
|
|
380
|
+
return int((await cur.fetchone())[0])
|
|
381
|
+
|
|
382
|
+
async def get(self, contact_id: str) -> Optional[Contact]:
|
|
383
|
+
db = self._require_db()
|
|
384
|
+
async with db.execute(
|
|
385
|
+
"SELECT * FROM contacts WHERE contact_id = ? AND deleted_at IS NULL",
|
|
386
|
+
(contact_id,),
|
|
387
|
+
) as cur:
|
|
388
|
+
row = await cur.fetchone()
|
|
389
|
+
if row is None:
|
|
390
|
+
return None
|
|
391
|
+
return Contact.from_row(dict(row))
|
|
392
|
+
|
|
393
|
+
async def resolve_handle(self, gateway: str, address: str) -> Optional[Contact]:
|
|
394
|
+
db = self._require_db()
|
|
395
|
+
norm = _normalize_email(address) if gateway == "email" else _normalize_phone(address) if gateway in ("imessage", "sms", "signal") else address
|
|
396
|
+
async with db.execute(
|
|
397
|
+
"""
|
|
398
|
+
SELECT c.* FROM contacts c
|
|
399
|
+
JOIN contact_handles h ON h.contact_id = c.contact_id
|
|
400
|
+
WHERE h.gateway = ? AND h.address = ? AND c.deleted_at IS NULL
|
|
401
|
+
AND (h.verified=1 OR h.source!='auto:scoped-name')
|
|
402
|
+
""",
|
|
403
|
+
(gateway, norm),
|
|
404
|
+
) as cur:
|
|
405
|
+
row = await cur.fetchone()
|
|
406
|
+
if row is None:
|
|
407
|
+
return None
|
|
408
|
+
return Contact.from_row(dict(row))
|
|
409
|
+
|
|
410
|
+
async def resolve_messaging_handle(self, gateway: str, address: str) -> Optional[Contact]:
|
|
411
|
+
"""Resolve a live sender, preferring a verified exact transport handle.
|
|
412
|
+
|
|
413
|
+
An explicit channel correction can split previously shared phone
|
|
414
|
+
attribution. Cross-gateway phone inference must not undo that decision.
|
|
415
|
+
Without an exact verified handle, retain the unambiguous normalized
|
|
416
|
+
phone/email fallback. This does not grant a contact any authority.
|
|
417
|
+
"""
|
|
418
|
+
db = self._require_db()
|
|
419
|
+
g = (gateway or "").strip().lower()
|
|
420
|
+
if g == "rcs": # D1: RCS canonicalizes to the shared phone identity (no separate gateway)
|
|
421
|
+
g = "sms"
|
|
422
|
+
normalized = (_normalize_email(address) if g == 'email' else
|
|
423
|
+
_normalize_phone(address) if g in ('sms', 'imessage', 'signal') else address)
|
|
424
|
+
exact = await self.resolve_verified_handles(g, [normalized])
|
|
425
|
+
if exact is not None:
|
|
426
|
+
return exact
|
|
427
|
+
if g == "email":
|
|
428
|
+
sql = ("SELECT c.* FROM contacts c JOIN contact_handles h ON h.contact_id = c.contact_id "
|
|
429
|
+
"WHERE h.gateway = 'email' AND lower(h.address) = ? AND c.deleted_at IS NULL LIMIT 1")
|
|
430
|
+
params: tuple = (_normalize_email(address),)
|
|
431
|
+
elif g in _get_phone_gateways() or _looks_like_phone(address):
|
|
432
|
+
_pgw = tuple(_get_phone_gateways())
|
|
433
|
+
placeholders = ",".join("?" for _ in _pgw)
|
|
434
|
+
sql = ("SELECT c.* FROM contacts c JOIN contact_handles h ON h.contact_id = c.contact_id "
|
|
435
|
+
f"WHERE h.gateway IN ({placeholders}) AND phone_key(h.address) = ? "
|
|
436
|
+
"AND c.deleted_at IS NULL LIMIT 1")
|
|
437
|
+
params = (*_pgw, _phone_key(address))
|
|
438
|
+
else:
|
|
439
|
+
sql = ("SELECT c.* FROM contacts c JOIN contact_handles h ON h.contact_id = c.contact_id "
|
|
440
|
+
"WHERE h.gateway = ? AND h.address = ? AND c.deleted_at IS NULL LIMIT 1")
|
|
441
|
+
params = (g, address)
|
|
442
|
+
# Name-based legacy proposals must never become confirmed attribution.
|
|
443
|
+
# Multiple matching contacts are ambiguous, even on a normalized phone.
|
|
444
|
+
sql = sql.replace(' LIMIT 1', '') + " AND (h.verified=1 OR h.source!='auto:scoped-name')"
|
|
445
|
+
async with db.execute(sql, params) as cur:
|
|
446
|
+
rows = await cur.fetchall()
|
|
447
|
+
row = rows[0] if len({r['contact_id'] for r in rows}) == 1 else None
|
|
448
|
+
return Contact.from_row(dict(row)) if row else None
|
|
449
|
+
|
|
450
|
+
async def resolve_verified_handles(self, gateway: str, addresses: List[str]) -> Optional[Contact]:
|
|
451
|
+
values = list(dict.fromkeys(addresses))
|
|
452
|
+
if not 1 <= len(values) <= 5 or any(not isinstance(v, str) or not 1 <= len(v) <= 512 for v in values):
|
|
453
|
+
raise ValueError('bounded_exact_transport_aliases_required')
|
|
454
|
+
db = self._require_db()
|
|
455
|
+
async with db.execute('SELECT DISTINCT c.* FROM contacts c JOIN contact_handles h '
|
|
456
|
+
'ON h.contact_id=c.contact_id WHERE c.deleted_at IS NULL AND h.verified=1 '
|
|
457
|
+
'AND h.gateway=? AND h.address IN (' + ','.join('?' for _ in values) + ')',
|
|
458
|
+
[gateway, *values]) as cursor:
|
|
459
|
+
rows = await cursor.fetchall()
|
|
460
|
+
return Contact.from_row(dict(rows[0])) if len(rows) == 1 else None
|
|
461
|
+
|
|
462
|
+
async def get_handles(self, contact_id: str) -> List[ContactHandle]:
|
|
463
|
+
db = self._require_db()
|
|
464
|
+
async with db.execute(
|
|
465
|
+
"SELECT * FROM contact_handles WHERE contact_id = ? ORDER BY is_primary DESC, created_at",
|
|
466
|
+
(contact_id,),
|
|
467
|
+
) as cur:
|
|
468
|
+
rows = await cur.fetchall()
|
|
469
|
+
return [ContactHandle.from_row(dict(r)) for r in rows]
|
|
470
|
+
|
|
471
|
+
async def list(
|
|
472
|
+
self,
|
|
473
|
+
trust_tier: Optional[str] = None,
|
|
474
|
+
interaction_allowed: Optional[bool] = None,
|
|
475
|
+
tag: Optional[str] = None,
|
|
476
|
+
include_deleted: bool = False,
|
|
477
|
+
limit: int = 100,
|
|
478
|
+
offset: int = 0,
|
|
479
|
+
) -> List[Contact]:
|
|
480
|
+
db = self._require_db()
|
|
481
|
+
clauses = []
|
|
482
|
+
params: List[Any] = []
|
|
483
|
+
if not include_deleted:
|
|
484
|
+
clauses.append("deleted_at IS NULL")
|
|
485
|
+
if trust_tier:
|
|
486
|
+
clauses.append("trust_tier = ?")
|
|
487
|
+
params.append(trust_tier)
|
|
488
|
+
if interaction_allowed is not None:
|
|
489
|
+
clauses.append("interaction_allowed = ?")
|
|
490
|
+
params.append(1 if interaction_allowed else 0)
|
|
491
|
+
if tag:
|
|
492
|
+
# SQL-02: escape LIKE wildcards to prevent contact enumeration
|
|
493
|
+
safe_tag = tag.replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_")
|
|
494
|
+
clauses.append("tags_json LIKE ? ESCAPE '\\'")
|
|
495
|
+
params.append(f'%"{safe_tag}"%')
|
|
496
|
+
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
497
|
+
params += [limit, offset]
|
|
498
|
+
async with db.execute(
|
|
499
|
+
f"SELECT * FROM contacts {where} ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
|
500
|
+
params,
|
|
501
|
+
) as cur:
|
|
502
|
+
rows = await cur.fetchall()
|
|
503
|
+
return [Contact.from_row(dict(r)) for r in rows]
|
|
504
|
+
|
|
505
|
+
async def find_by_name(self, name: str, threshold: float = 0.5) -> List[Contact]:
|
|
506
|
+
db = self._require_db()
|
|
507
|
+
async with db.execute(
|
|
508
|
+
"SELECT * FROM contacts WHERE deleted_at IS NULL",
|
|
509
|
+
) as cur:
|
|
510
|
+
rows = await cur.fetchall()
|
|
511
|
+
results = []
|
|
512
|
+
for row in rows:
|
|
513
|
+
c = Contact.from_row(dict(row))
|
|
514
|
+
sim = _name_similarity(name, c.display_name)
|
|
515
|
+
if sim >= threshold:
|
|
516
|
+
results.append((sim, c))
|
|
517
|
+
results.sort(key=lambda x: x[0], reverse=True)
|
|
518
|
+
return [c for _, c in results]
|
|
519
|
+
|
|
520
|
+
async def find_by_person_node_id(self, person_node_id: str) -> Optional[Contact]:
|
|
521
|
+
"""Fetch a contact by its linked Neo4j Person node ID."""
|
|
522
|
+
db = self._require_db()
|
|
523
|
+
async with db.execute(
|
|
524
|
+
"SELECT * FROM contacts WHERE person_node_id = ? AND deleted_at IS NULL",
|
|
525
|
+
(person_node_id,),
|
|
526
|
+
) as cur:
|
|
527
|
+
row = await cur.fetchone()
|
|
528
|
+
if row is None:
|
|
529
|
+
return None
|
|
530
|
+
return Contact.from_row(dict(row))
|
|
531
|
+
|
|
532
|
+
async def find_discovered_by_handle(
|
|
533
|
+
self, gateway: str, address: str
|
|
534
|
+
) -> Optional[Contact]:
|
|
535
|
+
"""Find a discovered (world_model) contact that owns a given handle."""
|
|
536
|
+
db = self._require_db()
|
|
537
|
+
norm = _normalize_email(address) if gateway == "email" else _normalize_phone(address) if gateway in ("imessage", "sms", "signal") else address
|
|
538
|
+
async with db.execute(
|
|
539
|
+
"""
|
|
540
|
+
SELECT c.* FROM contacts c
|
|
541
|
+
JOIN contact_handles h ON h.contact_id = c.contact_id
|
|
542
|
+
WHERE c.import_source = 'world_model'
|
|
543
|
+
AND c.deleted_at IS NULL
|
|
544
|
+
AND h.gateway = ? AND h.address = ?
|
|
545
|
+
LIMIT 1
|
|
546
|
+
""",
|
|
547
|
+
(gateway, norm),
|
|
548
|
+
) as cur:
|
|
549
|
+
row = await cur.fetchone()
|
|
550
|
+
if row is None:
|
|
551
|
+
return None
|
|
552
|
+
return Contact.from_row(dict(row))
|
|
553
|
+
|
|
554
|
+
# ── Write ops ─────────────────────────────────────────────────────────────
|
|
555
|
+
|
|
556
|
+
async def create(
|
|
557
|
+
self,
|
|
558
|
+
display_name: Optional[str] = None,
|
|
559
|
+
given_name: Optional[str] = None,
|
|
560
|
+
family_name: Optional[str] = None,
|
|
561
|
+
organization: Optional[str] = None,
|
|
562
|
+
trust_tier: str = "unknown",
|
|
563
|
+
interaction_allowed: Optional[bool] = None,
|
|
564
|
+
tags: Optional[List[str]] = None,
|
|
565
|
+
privacy_level: str = "private",
|
|
566
|
+
import_source: str = "manual",
|
|
567
|
+
notes: Optional[str] = None,
|
|
568
|
+
introduced_by: Optional[str] = None,
|
|
569
|
+
met_via: Optional[Dict[str, Any]] = None,
|
|
570
|
+
) -> Contact:
|
|
571
|
+
db = self._require_db()
|
|
572
|
+
if trust_tier not in TRUST_TIERS:
|
|
573
|
+
raise ValueError(f"Invalid trust_tier: {trust_tier}")
|
|
574
|
+
if interaction_allowed is None:
|
|
575
|
+
interaction_allowed = TIER_DEFAULT_INTERACTION.get(trust_tier, True)
|
|
576
|
+
contact_id = _gen_id("cid")
|
|
577
|
+
now = _now_iso()
|
|
578
|
+
dn = display_name
|
|
579
|
+
if not dn and (given_name or family_name):
|
|
580
|
+
dn = " ".join(p for p in [given_name, family_name] if p)
|
|
581
|
+
await db.execute(
|
|
582
|
+
"""
|
|
583
|
+
INSERT INTO contacts
|
|
584
|
+
(contact_id, display_name, given_name, family_name, organization,
|
|
585
|
+
trust_tier, interaction_allowed, tags_json, privacy_level,
|
|
586
|
+
import_source, notes, introduced_by, met_via_json,
|
|
587
|
+
first_seen_at, created_at, updated_at)
|
|
588
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
589
|
+
""",
|
|
590
|
+
(
|
|
591
|
+
contact_id, dn, given_name, family_name, organization,
|
|
592
|
+
trust_tier, 1 if interaction_allowed else 0,
|
|
593
|
+
json.dumps(tags or []), privacy_level,
|
|
594
|
+
import_source, notes, introduced_by,
|
|
595
|
+
json.dumps(met_via) if met_via else None,
|
|
596
|
+
now, now, now,
|
|
597
|
+
),
|
|
598
|
+
)
|
|
599
|
+
await db.commit()
|
|
600
|
+
audit = {"import_source": import_source}
|
|
601
|
+
if introduced_by:
|
|
602
|
+
audit["introduced_by"] = introduced_by
|
|
603
|
+
await self.record_audit(contact_id, "created", audit)
|
|
604
|
+
contact = await self.get(contact_id)
|
|
605
|
+
assert contact is not None
|
|
606
|
+
return contact
|
|
607
|
+
|
|
608
|
+
async def record_introduction(
|
|
609
|
+
self,
|
|
610
|
+
contact_id: str,
|
|
611
|
+
introduced_by: Optional[str] = None,
|
|
612
|
+
met_via: Optional[Dict[str, Any]] = None,
|
|
613
|
+
) -> Optional[Contact]:
|
|
614
|
+
"""Annotate an EXISTING contact with introduction provenance.
|
|
615
|
+
|
|
616
|
+
Used when an intro names someone Colony already knows: we record who
|
|
617
|
+
introduced them / how they were met without duplicating the contact, and
|
|
618
|
+
do NOT touch their trust_tier or interaction_allowed (an intro never
|
|
619
|
+
grants standing). Only fills blanks — an existing introduced_by/met_via
|
|
620
|
+
is preserved (first introduction wins).
|
|
621
|
+
"""
|
|
622
|
+
db = self._require_db()
|
|
623
|
+
existing = await self.get(contact_id)
|
|
624
|
+
if existing is None:
|
|
625
|
+
return None
|
|
626
|
+
set_parts, params, details = [], [], {}
|
|
627
|
+
if introduced_by and not existing.introduced_by:
|
|
628
|
+
set_parts.append("introduced_by = ?")
|
|
629
|
+
params.append(introduced_by)
|
|
630
|
+
details["introduced_by"] = introduced_by
|
|
631
|
+
if met_via and not existing.met_via:
|
|
632
|
+
set_parts.append("met_via_json = ?")
|
|
633
|
+
params.append(json.dumps(met_via))
|
|
634
|
+
details["met_via"] = met_via
|
|
635
|
+
if not set_parts:
|
|
636
|
+
return existing
|
|
637
|
+
set_parts.append("updated_at = ?")
|
|
638
|
+
params.append(_now_iso())
|
|
639
|
+
params.append(contact_id)
|
|
640
|
+
await db.execute(
|
|
641
|
+
f"UPDATE contacts SET {', '.join(set_parts)} WHERE contact_id = ?",
|
|
642
|
+
params,
|
|
643
|
+
)
|
|
644
|
+
await db.commit()
|
|
645
|
+
await self.record_audit(contact_id, "introduction_recorded", details)
|
|
646
|
+
return await self.get(contact_id)
|
|
647
|
+
|
|
648
|
+
async def introduction_candidates(
|
|
649
|
+
self,
|
|
650
|
+
trust_floor: str = "regular",
|
|
651
|
+
owner_contact_id: Optional[str] = None,
|
|
652
|
+
limit: int = 10,
|
|
653
|
+
) -> List[Dict[str, Any]]:
|
|
654
|
+
"""Find pairs of contacts who plausibly should meet (social-graph autonomy).
|
|
655
|
+
|
|
656
|
+
A candidate pair shares an organization (a "related work" signal) and BOTH
|
|
657
|
+
sides sit at or above ``trust_floor`` — the agent only proposes connecting
|
|
658
|
+
people it has standing with. The owner is never a candidate (the owner is
|
|
659
|
+
served, not introduced). Soft-deleted contacts are excluded. Returns at most
|
|
660
|
+
``limit`` ordered pairs; the autonomy loop turns each into an owner-approved
|
|
661
|
+
INTRODUCTION proposal (never an auto-executed action).
|
|
662
|
+
"""
|
|
663
|
+
from .models import _TIER_RANK
|
|
664
|
+
|
|
665
|
+
db = self._require_db()
|
|
666
|
+
floor_rank = _TIER_RANK.get(trust_floor, _TIER_RANK["regular"])
|
|
667
|
+
allowed = [t for t, r in _TIER_RANK.items() if r >= floor_rank]
|
|
668
|
+
if not allowed:
|
|
669
|
+
return []
|
|
670
|
+
ph = ",".join("?" for _ in allowed)
|
|
671
|
+
owner = owner_contact_id or ""
|
|
672
|
+
sql = f"""
|
|
673
|
+
SELECT a.contact_id AS a_id, a.display_name AS a_name,
|
|
674
|
+
b.contact_id AS b_id, b.display_name AS b_name,
|
|
675
|
+
a.organization AS org
|
|
676
|
+
FROM contacts a
|
|
677
|
+
JOIN contacts b
|
|
678
|
+
ON lower(a.organization) = lower(b.organization)
|
|
679
|
+
AND a.contact_id < b.contact_id
|
|
680
|
+
WHERE a.deleted_at IS NULL AND b.deleted_at IS NULL
|
|
681
|
+
AND a.organization IS NOT NULL AND trim(a.organization) != ''
|
|
682
|
+
AND a.trust_tier IN ({ph}) AND b.trust_tier IN ({ph})
|
|
683
|
+
AND a.contact_id != ? AND b.contact_id != ?
|
|
684
|
+
ORDER BY lower(a.organization), a.contact_id, b.contact_id
|
|
685
|
+
LIMIT ?
|
|
686
|
+
"""
|
|
687
|
+
params = [*allowed, *allowed, owner, owner, limit]
|
|
688
|
+
async with db.execute(sql, params) as cur:
|
|
689
|
+
rows = await cur.fetchall()
|
|
690
|
+
return [
|
|
691
|
+
{
|
|
692
|
+
"a_id": r["a_id"], "a_name": r["a_name"],
|
|
693
|
+
"b_id": r["b_id"], "b_name": r["b_name"],
|
|
694
|
+
"organization": r["org"],
|
|
695
|
+
}
|
|
696
|
+
for r in rows
|
|
697
|
+
]
|
|
698
|
+
|
|
699
|
+
async def add_handle(
|
|
700
|
+
self,
|
|
701
|
+
contact_id: str,
|
|
702
|
+
gateway: str,
|
|
703
|
+
address: str,
|
|
704
|
+
is_primary: bool = False,
|
|
705
|
+
confidence: float = 1.0,
|
|
706
|
+
source: str = "manual",
|
|
707
|
+
verified: bool = False,
|
|
708
|
+
) -> ContactHandle:
|
|
709
|
+
db = self._require_db()
|
|
710
|
+
# Normalize address
|
|
711
|
+
if gateway == "email":
|
|
712
|
+
address = _normalize_email(address)
|
|
713
|
+
elif gateway in ("imessage", "sms", "signal"):
|
|
714
|
+
address = _normalize_phone(address)
|
|
715
|
+
|
|
716
|
+
# Check if address already belongs to another contact
|
|
717
|
+
async with db.execute(
|
|
718
|
+
"SELECT contact_id FROM contact_handles WHERE gateway = ? AND address = ?",
|
|
719
|
+
(gateway, address),
|
|
720
|
+
) as cur:
|
|
721
|
+
existing = await cur.fetchone()
|
|
722
|
+
if existing and existing["contact_id"] != contact_id:
|
|
723
|
+
raise ValueError(
|
|
724
|
+
f"Handle ({gateway}, {address}) is already assigned to contact {existing['contact_id']}"
|
|
725
|
+
)
|
|
726
|
+
if existing and existing["contact_id"] == contact_id:
|
|
727
|
+
# Already exists for this contact — return it
|
|
728
|
+
async with db.execute(
|
|
729
|
+
"SELECT * FROM contact_handles WHERE gateway = ? AND address = ?",
|
|
730
|
+
(gateway, address),
|
|
731
|
+
) as cur:
|
|
732
|
+
row = await cur.fetchone()
|
|
733
|
+
return ContactHandle.from_row(dict(row))
|
|
734
|
+
|
|
735
|
+
handle_id = _gen_id("hdl")
|
|
736
|
+
now = _now_iso()
|
|
737
|
+
await db.execute(
|
|
738
|
+
"""
|
|
739
|
+
INSERT INTO contact_handles
|
|
740
|
+
(handle_id, contact_id, gateway, address, is_primary, verified, confidence, source, created_at)
|
|
741
|
+
VALUES (?,?,?,?,?,?,?,?,?)
|
|
742
|
+
""",
|
|
743
|
+
(handle_id, contact_id, gateway, address, 1 if is_primary else 0,
|
|
744
|
+
1 if verified else 0, confidence, source, now),
|
|
745
|
+
)
|
|
746
|
+
await db.commit()
|
|
747
|
+
await self.record_audit(
|
|
748
|
+
contact_id, "handle_added",
|
|
749
|
+
{"gateway": gateway, "address": address, "source": source},
|
|
750
|
+
)
|
|
751
|
+
async with db.execute(
|
|
752
|
+
"SELECT * FROM contact_handles WHERE handle_id = ?", (handle_id,)
|
|
753
|
+
) as cur:
|
|
754
|
+
row = await cur.fetchone()
|
|
755
|
+
return ContactHandle.from_row(dict(row))
|
|
756
|
+
|
|
757
|
+
async def provision_verified_handle(
|
|
758
|
+
self,
|
|
759
|
+
*,
|
|
760
|
+
operation_id: str,
|
|
761
|
+
performed_by: str,
|
|
762
|
+
gateway: str,
|
|
763
|
+
address: str,
|
|
764
|
+
display_name: Optional[str] = None,
|
|
765
|
+
contact_id: Optional[str] = None,
|
|
766
|
+
) -> Dict[str, Any]:
|
|
767
|
+
"""Atomically create/map an exact verified handle with an audit receipt.
|
|
768
|
+
|
|
769
|
+
This is intentionally generic at the store boundary. The API layer
|
|
770
|
+
decides which gateway/address grammar and which authenticated principal
|
|
771
|
+
are authorized. The store guarantees that create+handle cannot leave
|
|
772
|
+
an orphan contact, that an exact handle cannot cross contacts, and that
|
|
773
|
+
an operation retry cannot repeat or change the original mutation.
|
|
774
|
+
"""
|
|
775
|
+
|
|
776
|
+
shared_db = self._require_db()
|
|
777
|
+
operation = str(operation_id or "")
|
|
778
|
+
principal = str(performed_by or "")
|
|
779
|
+
exact_gateway = str(gateway or "")
|
|
780
|
+
exact_address = str(address or "")
|
|
781
|
+
create_name = display_name if isinstance(display_name, str) else None
|
|
782
|
+
selected_id = contact_id if isinstance(contact_id, str) else None
|
|
783
|
+
creating = create_name is not None and selected_id is None
|
|
784
|
+
mapping = selected_id is not None and create_name is None
|
|
785
|
+
if not (creating or mapping):
|
|
786
|
+
raise ValueError("exactly one of display_name and contact_id is required")
|
|
787
|
+
if (
|
|
788
|
+
not operation
|
|
789
|
+
or operation != operation.strip()
|
|
790
|
+
or not principal
|
|
791
|
+
or principal != principal.strip()
|
|
792
|
+
or not exact_gateway
|
|
793
|
+
or exact_gateway != exact_gateway.strip()
|
|
794
|
+
or not exact_address
|
|
795
|
+
or exact_address != exact_address.strip()
|
|
796
|
+
):
|
|
797
|
+
raise ValueError("provisioning identifiers must be exact non-empty text")
|
|
798
|
+
if creating and (
|
|
799
|
+
not create_name
|
|
800
|
+
or create_name != create_name.strip()
|
|
801
|
+
or any(
|
|
802
|
+
ord(character) < 0x20 or ord(character) == 0x7F
|
|
803
|
+
for character in create_name
|
|
804
|
+
)
|
|
805
|
+
):
|
|
806
|
+
raise ValueError("display_name must be canonical text")
|
|
807
|
+
if mapping and (
|
|
808
|
+
not selected_id
|
|
809
|
+
or selected_id != selected_id.strip()
|
|
810
|
+
):
|
|
811
|
+
raise ValueError("contact_id must be canonical text")
|
|
812
|
+
|
|
813
|
+
request_value = {
|
|
814
|
+
"contact_id": selected_id,
|
|
815
|
+
"display_name": create_name,
|
|
816
|
+
"gateway": exact_gateway,
|
|
817
|
+
"address": exact_address,
|
|
818
|
+
}
|
|
819
|
+
request_sha256 = hashlib.sha256(json.dumps(
|
|
820
|
+
request_value,
|
|
821
|
+
sort_keys=True,
|
|
822
|
+
separators=(",", ":"),
|
|
823
|
+
ensure_ascii=False,
|
|
824
|
+
).encode("utf-8")).hexdigest()
|
|
825
|
+
now = _now_iso()
|
|
826
|
+
|
|
827
|
+
# connect() applies migrations on the shared connection. Prove the
|
|
828
|
+
# idempotency ledger exists before opening the dedicated operation
|
|
829
|
+
# connection; never create or migrate schema in the mutation window.
|
|
830
|
+
async with shared_db.execute(
|
|
831
|
+
"SELECT 1 FROM sqlite_master "
|
|
832
|
+
"WHERE type = 'table' AND name = 'contact_provision_operations'"
|
|
833
|
+
) as cur:
|
|
834
|
+
migration_ready = await cur.fetchone()
|
|
835
|
+
if migration_ready is None:
|
|
836
|
+
raise RuntimeError("contact provisioning migration is unavailable")
|
|
837
|
+
|
|
838
|
+
db = await self._open_provision_connection()
|
|
839
|
+
try:
|
|
840
|
+
await db.execute("BEGIN IMMEDIATE")
|
|
841
|
+
try:
|
|
842
|
+
async with db.execute(
|
|
843
|
+
"SELECT request_sha256, performed_by, result_json "
|
|
844
|
+
"FROM contact_provision_operations WHERE operation_id = ?",
|
|
845
|
+
(operation,),
|
|
846
|
+
) as cur:
|
|
847
|
+
prior = await cur.fetchone()
|
|
848
|
+
if prior is not None:
|
|
849
|
+
if (
|
|
850
|
+
prior["request_sha256"] != request_sha256
|
|
851
|
+
or prior["performed_by"] != principal
|
|
852
|
+
):
|
|
853
|
+
raise ValueError(
|
|
854
|
+
"operation_id is already bound to another "
|
|
855
|
+
"provisioning request"
|
|
856
|
+
)
|
|
857
|
+
try:
|
|
858
|
+
result = json.loads(prior["result_json"])
|
|
859
|
+
except (TypeError, ValueError) as exc:
|
|
860
|
+
raise RuntimeError(
|
|
861
|
+
"stored provisioning receipt is invalid"
|
|
862
|
+
) from exc
|
|
863
|
+
if not isinstance(result, dict):
|
|
864
|
+
raise RuntimeError(
|
|
865
|
+
"stored provisioning receipt is invalid"
|
|
866
|
+
)
|
|
867
|
+
await db.commit()
|
|
868
|
+
return result
|
|
869
|
+
|
|
870
|
+
# Exact uniqueness remains authoritative for every gateway.
|
|
871
|
+
async with db.execute(
|
|
872
|
+
"SELECT contact_id, handle_id, verified "
|
|
873
|
+
"FROM contact_handles WHERE gateway = ? AND address = ?",
|
|
874
|
+
(exact_gateway, exact_address),
|
|
875
|
+
) as cur:
|
|
876
|
+
existing_handle = await cur.fetchone()
|
|
877
|
+
|
|
878
|
+
# Phone identity is canonical across every configured
|
|
879
|
+
# phone-bearing gateway. RCS is explicit because inbound RCS
|
|
880
|
+
# canonicalizes to SMS while legacy databases can retain rcs.
|
|
881
|
+
phone_gateways = tuple(sorted(
|
|
882
|
+
set(_get_phone_gateways()) | {"rcs"}
|
|
883
|
+
))
|
|
884
|
+
phone_equivalents = []
|
|
885
|
+
if exact_gateway in phone_gateways:
|
|
886
|
+
identity_key = _phone_key(exact_address)
|
|
887
|
+
if len(identity_key) < 7:
|
|
888
|
+
raise ValueError(
|
|
889
|
+
"phone-bearing handle has no canonical phone identity"
|
|
890
|
+
)
|
|
891
|
+
placeholders = ",".join("?" for _ in phone_gateways)
|
|
892
|
+
async with db.execute(
|
|
893
|
+
"SELECT h.contact_id, h.handle_id, h.gateway, "
|
|
894
|
+
"h.address, h.verified FROM contact_handles h "
|
|
895
|
+
"JOIN contacts c ON c.contact_id = h.contact_id "
|
|
896
|
+
f"WHERE h.gateway IN ({placeholders}) "
|
|
897
|
+
"AND phone_key(h.address) = ? "
|
|
898
|
+
"AND c.deleted_at IS NULL "
|
|
899
|
+
"ORDER BY h.contact_id, h.handle_id",
|
|
900
|
+
(*phone_gateways, identity_key),
|
|
901
|
+
) as cur:
|
|
902
|
+
phone_equivalents = await cur.fetchall()
|
|
903
|
+
|
|
904
|
+
created_contact = False
|
|
905
|
+
if creating:
|
|
906
|
+
assert create_name is not None
|
|
907
|
+
if existing_handle is not None:
|
|
908
|
+
raise ValueError("exact handle is already assigned")
|
|
909
|
+
if phone_equivalents:
|
|
910
|
+
raise ValueError(
|
|
911
|
+
"phone-equivalent handle is already assigned"
|
|
912
|
+
)
|
|
913
|
+
async with db.execute(
|
|
914
|
+
"SELECT contact_id, display_name FROM contacts "
|
|
915
|
+
"WHERE deleted_at IS NULL "
|
|
916
|
+
"AND display_name IS NOT NULL"
|
|
917
|
+
) as cur:
|
|
918
|
+
named_contacts = await cur.fetchall()
|
|
919
|
+
if any(
|
|
920
|
+
str(row["display_name"] or "").casefold()
|
|
921
|
+
== create_name.casefold()
|
|
922
|
+
for row in named_contacts
|
|
923
|
+
):
|
|
924
|
+
raise ValueError("display_name is not unique")
|
|
925
|
+
selected_id = _gen_id("cid")
|
|
926
|
+
await db.execute(
|
|
927
|
+
"""
|
|
928
|
+
INSERT INTO contacts
|
|
929
|
+
(contact_id, display_name, trust_tier,
|
|
930
|
+
interaction_allowed, tags_json, privacy_level,
|
|
931
|
+
import_source, first_seen_at, created_at, updated_at)
|
|
932
|
+
VALUES (?, ?, 'unknown', 0, '[]', 'private',
|
|
933
|
+
'owner_operator', ?, ?, ?)
|
|
934
|
+
""",
|
|
935
|
+
(selected_id, create_name, now, now, now),
|
|
936
|
+
)
|
|
937
|
+
created_contact = True
|
|
938
|
+
await self._after_provision_contact_insert()
|
|
939
|
+
await db.execute(
|
|
940
|
+
"INSERT INTO contact_audit "
|
|
941
|
+
"(id, contact_id, action, detail, performed_by, "
|
|
942
|
+
"created_at) VALUES (?, ?, 'created', ?, ?, ?)",
|
|
943
|
+
(
|
|
944
|
+
_gen_id("cau"),
|
|
945
|
+
selected_id,
|
|
946
|
+
json.dumps({"import_source": "owner_operator"}),
|
|
947
|
+
principal,
|
|
948
|
+
now,
|
|
949
|
+
),
|
|
950
|
+
)
|
|
951
|
+
else:
|
|
952
|
+
assert selected_id is not None
|
|
953
|
+
async with db.execute(
|
|
954
|
+
"SELECT display_name, interaction_allowed "
|
|
955
|
+
"FROM contacts WHERE contact_id = ? "
|
|
956
|
+
"AND deleted_at IS NULL",
|
|
957
|
+
(selected_id,),
|
|
958
|
+
) as cur:
|
|
959
|
+
selected = await cur.fetchone()
|
|
960
|
+
if selected is None:
|
|
961
|
+
raise ValueError("selected contact does not exist")
|
|
962
|
+
create_name = selected["display_name"]
|
|
963
|
+
|
|
964
|
+
assert selected_id is not None
|
|
965
|
+
if (
|
|
966
|
+
existing_handle is not None
|
|
967
|
+
and existing_handle["contact_id"] != selected_id
|
|
968
|
+
):
|
|
969
|
+
raise ValueError(
|
|
970
|
+
"exact handle is already assigned to another contact"
|
|
971
|
+
)
|
|
972
|
+
if any(
|
|
973
|
+
row["contact_id"] != selected_id
|
|
974
|
+
for row in phone_equivalents
|
|
975
|
+
):
|
|
976
|
+
raise ValueError(
|
|
977
|
+
"phone-equivalent handle is already assigned to "
|
|
978
|
+
"another contact"
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
handle_created = existing_handle is None
|
|
982
|
+
handle_changed = (
|
|
983
|
+
handle_created or not bool(existing_handle["verified"])
|
|
984
|
+
)
|
|
985
|
+
if handle_created:
|
|
986
|
+
handle_id = _gen_id("hdl")
|
|
987
|
+
await db.execute(
|
|
988
|
+
"""
|
|
989
|
+
INSERT INTO contact_handles
|
|
990
|
+
(handle_id, contact_id, gateway, address, is_primary,
|
|
991
|
+
verified, confidence, source, created_at)
|
|
992
|
+
VALUES (?, ?, ?, ?, 1, 1, 1.0,
|
|
993
|
+
'owner_operator', ?)
|
|
994
|
+
""",
|
|
995
|
+
(
|
|
996
|
+
handle_id,
|
|
997
|
+
selected_id,
|
|
998
|
+
exact_gateway,
|
|
999
|
+
exact_address,
|
|
1000
|
+
now,
|
|
1001
|
+
),
|
|
1002
|
+
)
|
|
1003
|
+
else:
|
|
1004
|
+
handle_id = str(existing_handle["handle_id"])
|
|
1005
|
+
if handle_changed:
|
|
1006
|
+
await db.execute(
|
|
1007
|
+
"UPDATE contact_handles SET verified = 1, "
|
|
1008
|
+
"confidence = 1.0, source = 'owner_operator' "
|
|
1009
|
+
"WHERE handle_id = ?",
|
|
1010
|
+
(handle_id,),
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
address_sha256 = hashlib.sha256(
|
|
1014
|
+
exact_address.encode("utf-8")
|
|
1015
|
+
).hexdigest()
|
|
1016
|
+
await db.execute(
|
|
1017
|
+
"INSERT INTO contact_audit "
|
|
1018
|
+
"(id, contact_id, action, detail, performed_by, created_at) "
|
|
1019
|
+
"VALUES (?, ?, 'contact_handle_owner_verified', ?, ?, ?)",
|
|
1020
|
+
(
|
|
1021
|
+
_gen_id("cau"),
|
|
1022
|
+
selected_id,
|
|
1023
|
+
json.dumps({
|
|
1024
|
+
"operation_id": operation,
|
|
1025
|
+
"gateway": exact_gateway,
|
|
1026
|
+
"address_sha256": address_sha256,
|
|
1027
|
+
"handle_created": handle_created,
|
|
1028
|
+
"verification_changed": handle_changed,
|
|
1029
|
+
"contact_created": created_contact,
|
|
1030
|
+
}, sort_keys=True),
|
|
1031
|
+
principal,
|
|
1032
|
+
now,
|
|
1033
|
+
),
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
# Read standing in the same transaction. This method never
|
|
1037
|
+
# enables it; creation is always held.
|
|
1038
|
+
async with db.execute(
|
|
1039
|
+
"SELECT interaction_allowed FROM contacts "
|
|
1040
|
+
"WHERE contact_id = ?",
|
|
1041
|
+
(selected_id,),
|
|
1042
|
+
) as cur:
|
|
1043
|
+
standing_row = await cur.fetchone()
|
|
1044
|
+
if standing_row is None:
|
|
1045
|
+
raise RuntimeError("provisioned contact disappeared")
|
|
1046
|
+
result = {
|
|
1047
|
+
"contact_id": selected_id,
|
|
1048
|
+
"display_name": create_name,
|
|
1049
|
+
"gateway": exact_gateway,
|
|
1050
|
+
"address": exact_address,
|
|
1051
|
+
"handle_id": handle_id,
|
|
1052
|
+
"created": created_contact,
|
|
1053
|
+
"handle_created": handle_created,
|
|
1054
|
+
"changed": created_contact or handle_changed,
|
|
1055
|
+
"verified": True,
|
|
1056
|
+
"interaction_allowed": bool(
|
|
1057
|
+
standing_row["interaction_allowed"]
|
|
1058
|
+
),
|
|
1059
|
+
"operation_id": operation,
|
|
1060
|
+
}
|
|
1061
|
+
result_json = json.dumps(
|
|
1062
|
+
result,
|
|
1063
|
+
sort_keys=True,
|
|
1064
|
+
separators=(",", ":"),
|
|
1065
|
+
ensure_ascii=False,
|
|
1066
|
+
)
|
|
1067
|
+
await db.execute(
|
|
1068
|
+
"INSERT INTO contact_provision_operations "
|
|
1069
|
+
"(operation_id, request_sha256, performed_by, contact_id, "
|
|
1070
|
+
"result_json, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
1071
|
+
(
|
|
1072
|
+
operation,
|
|
1073
|
+
request_sha256,
|
|
1074
|
+
principal,
|
|
1075
|
+
selected_id,
|
|
1076
|
+
result_json,
|
|
1077
|
+
now,
|
|
1078
|
+
),
|
|
1079
|
+
)
|
|
1080
|
+
await db.commit()
|
|
1081
|
+
return result
|
|
1082
|
+
except Exception:
|
|
1083
|
+
await db.rollback()
|
|
1084
|
+
raise
|
|
1085
|
+
finally:
|
|
1086
|
+
await db.close()
|
|
1087
|
+
|
|
1088
|
+
async def update_tier(
|
|
1089
|
+
self,
|
|
1090
|
+
contact_id: str,
|
|
1091
|
+
new_tier: str,
|
|
1092
|
+
reason: Optional[str] = None,
|
|
1093
|
+
performed_by: str = "operator",
|
|
1094
|
+
) -> None:
|
|
1095
|
+
db = self._require_db()
|
|
1096
|
+
if new_tier not in TRUST_TIERS:
|
|
1097
|
+
raise ValueError(f"Invalid trust_tier: {new_tier}")
|
|
1098
|
+
async with db.execute(
|
|
1099
|
+
"SELECT trust_tier FROM contacts WHERE contact_id = ?", (contact_id,)
|
|
1100
|
+
) as cur:
|
|
1101
|
+
row = await cur.fetchone()
|
|
1102
|
+
if not row:
|
|
1103
|
+
raise ValueError(f"Contact not found: {contact_id}")
|
|
1104
|
+
old_tier = row["trust_tier"]
|
|
1105
|
+
await db.execute(
|
|
1106
|
+
"UPDATE contacts SET trust_tier = ?, updated_at = ? WHERE contact_id = ?",
|
|
1107
|
+
(new_tier, _now_iso(), contact_id),
|
|
1108
|
+
)
|
|
1109
|
+
await db.commit()
|
|
1110
|
+
await self.record_audit(
|
|
1111
|
+
contact_id, "tier_changed",
|
|
1112
|
+
{"old_tier": old_tier, "new_tier": new_tier, "reason": reason},
|
|
1113
|
+
performed_by=performed_by,
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
async def update_relationship_score(self, contact_id: str, score: float) -> None:
|
|
1117
|
+
db = self._require_db()
|
|
1118
|
+
score = max(0.0, min(1.0, score))
|
|
1119
|
+
await db.execute(
|
|
1120
|
+
"UPDATE contacts SET relationship_score = ?, updated_at = ? WHERE contact_id = ?",
|
|
1121
|
+
(score, _now_iso(), contact_id),
|
|
1122
|
+
)
|
|
1123
|
+
await db.commit()
|
|
1124
|
+
# Sync to graph if linked
|
|
1125
|
+
if self._graph is not None:
|
|
1126
|
+
try:
|
|
1127
|
+
contact = await self.get(contact_id)
|
|
1128
|
+
if contact and contact.person_node_id:
|
|
1129
|
+
await self._graph.update_person(
|
|
1130
|
+
contact.person_node_id, score=score,
|
|
1131
|
+
)
|
|
1132
|
+
except Exception as exc:
|
|
1133
|
+
logger.debug("Score sync to graph failed for %s: %s", contact_id, exc)
|
|
1134
|
+
|
|
1135
|
+
async def update_interaction_allowed(
|
|
1136
|
+
self, contact_id: str, allowed: bool, performed_by: str = "operator"
|
|
1137
|
+
) -> None:
|
|
1138
|
+
db = self._require_db()
|
|
1139
|
+
await db.execute(
|
|
1140
|
+
"UPDATE contacts SET interaction_allowed = ?, updated_at = ? WHERE contact_id = ?",
|
|
1141
|
+
(1 if allowed else 0, _now_iso(), contact_id),
|
|
1142
|
+
)
|
|
1143
|
+
await db.commit()
|
|
1144
|
+
await self.record_audit(
|
|
1145
|
+
contact_id, "interaction_toggled",
|
|
1146
|
+
{"interaction_allowed": allowed},
|
|
1147
|
+
performed_by=performed_by,
|
|
1148
|
+
)
|
|
1149
|
+
|
|
1150
|
+
async def compute_cadence_overdue(
|
|
1151
|
+
self,
|
|
1152
|
+
*,
|
|
1153
|
+
now_iso: Optional[str] = None,
|
|
1154
|
+
default_cadence_days: float = 7.0,
|
|
1155
|
+
factor: float = 1.5,
|
|
1156
|
+
min_silence_days: float = 2.0,
|
|
1157
|
+
overdue_only: bool = True,
|
|
1158
|
+
limit: int = 20,
|
|
1159
|
+
exclude_ids: Optional[set] = None,
|
|
1160
|
+
) -> List[Dict[str, Any]]:
|
|
1161
|
+
"""Per-contact rhythm + silence (v0.21.0), SQLite-based.
|
|
1162
|
+
|
|
1163
|
+
Estimates each contact's typical cadence from their own interaction
|
|
1164
|
+
history (active span / interactions) and flags those overdue relative
|
|
1165
|
+
to *their* rhythm — so a daily contact is overdue after a few days while
|
|
1166
|
+
a monthly one isn't for weeks. Independent of the Neo4j graph.
|
|
1167
|
+
"""
|
|
1168
|
+
from apsimo.util import temporal as _t
|
|
1169
|
+
db = self._require_db()
|
|
1170
|
+
now = _t.parse_iso(now_iso) or _t.now_utc()
|
|
1171
|
+
exclude = set(exclude_ids or [])
|
|
1172
|
+
async with db.execute(
|
|
1173
|
+
"SELECT contact_id, display_name, given_name, first_seen_at, "
|
|
1174
|
+
"last_interaction_at, interaction_count, timezone "
|
|
1175
|
+
"FROM contacts WHERE deleted_at IS NULL AND interaction_allowed = 1 "
|
|
1176
|
+
"AND last_interaction_at IS NOT NULL"
|
|
1177
|
+
) as cur:
|
|
1178
|
+
rows = await cur.fetchall()
|
|
1179
|
+
|
|
1180
|
+
out: List[Dict[str, Any]] = []
|
|
1181
|
+
for r in rows:
|
|
1182
|
+
cid = r["contact_id"]
|
|
1183
|
+
if cid in exclude:
|
|
1184
|
+
continue
|
|
1185
|
+
last = _t.parse_iso(r["last_interaction_at"])
|
|
1186
|
+
if last is None:
|
|
1187
|
+
continue
|
|
1188
|
+
first = _t.parse_iso(r["first_seen_at"])
|
|
1189
|
+
count = int(r["interaction_count"] or 0)
|
|
1190
|
+
days_since = (now - last).total_seconds() / 86400.0
|
|
1191
|
+
# cadence estimate
|
|
1192
|
+
if count >= 2 and first is not None and last > first:
|
|
1193
|
+
span = (last - first).total_seconds() / 86400.0
|
|
1194
|
+
cadence = span / max(count - 1, 1)
|
|
1195
|
+
else:
|
|
1196
|
+
cadence = default_cadence_days
|
|
1197
|
+
cadence = max(0.5, min(cadence, 90.0))
|
|
1198
|
+
threshold = max(min_silence_days, cadence * factor)
|
|
1199
|
+
is_overdue = days_since > threshold
|
|
1200
|
+
if overdue_only and not is_overdue:
|
|
1201
|
+
continue
|
|
1202
|
+
out.append({
|
|
1203
|
+
"contact_id": cid,
|
|
1204
|
+
"name": r["display_name"] or r["given_name"] or cid,
|
|
1205
|
+
"timezone": r["timezone"],
|
|
1206
|
+
"last_interaction_at": r["last_interaction_at"],
|
|
1207
|
+
"days_since": round(days_since, 1),
|
|
1208
|
+
"cadence_days": round(cadence, 1),
|
|
1209
|
+
"overdue": is_overdue,
|
|
1210
|
+
"overdue_ratio": round(days_since / max(cadence, 0.5), 2),
|
|
1211
|
+
})
|
|
1212
|
+
|
|
1213
|
+
out.sort(key=lambda x: x["overdue_ratio"], reverse=True)
|
|
1214
|
+
return out[:limit]
|
|
1215
|
+
|
|
1216
|
+
async def record_interaction(self, contact_id: str, at_iso: Optional[str] = None) -> bool:
|
|
1217
|
+
"""Bump last_interaction_at (+count) for a contact. v0.21.0.
|
|
1218
|
+
|
|
1219
|
+
Returns True if a row was updated (i.e. the contact exists).
|
|
1220
|
+
"""
|
|
1221
|
+
db = self._require_db()
|
|
1222
|
+
ts = at_iso or _now_iso()
|
|
1223
|
+
cur = await db.execute(
|
|
1224
|
+
"UPDATE contacts SET last_interaction_at = ?, "
|
|
1225
|
+
"interaction_count = interaction_count + 1, updated_at = ? "
|
|
1226
|
+
"WHERE contact_id = ? AND deleted_at IS NULL",
|
|
1227
|
+
(ts, ts, contact_id),
|
|
1228
|
+
)
|
|
1229
|
+
await db.commit()
|
|
1230
|
+
return cur.rowcount > 0
|
|
1231
|
+
|
|
1232
|
+
async def set_timezone(
|
|
1233
|
+
self, contact_id: str, timezone: Optional[str], performed_by: str = "operator"
|
|
1234
|
+
) -> None:
|
|
1235
|
+
"""Set (or clear, with None) a contact's IANA timezone. v0.21.0."""
|
|
1236
|
+
from apsimo.util.temporal import is_valid_timezone
|
|
1237
|
+
if timezone is not None and not is_valid_timezone(timezone):
|
|
1238
|
+
raise ValueError(f"Invalid IANA timezone: {timezone!r}")
|
|
1239
|
+
db = self._require_db()
|
|
1240
|
+
async with db.execute(
|
|
1241
|
+
"SELECT timezone FROM contacts WHERE contact_id = ?", (contact_id,)
|
|
1242
|
+
) as cur:
|
|
1243
|
+
row = await cur.fetchone()
|
|
1244
|
+
if not row:
|
|
1245
|
+
raise ValueError(f"Contact not found: {contact_id}")
|
|
1246
|
+
old_tz = row["timezone"]
|
|
1247
|
+
await db.execute(
|
|
1248
|
+
"UPDATE contacts SET timezone = ?, updated_at = ? WHERE contact_id = ?",
|
|
1249
|
+
(timezone, _now_iso(), contact_id),
|
|
1250
|
+
)
|
|
1251
|
+
await db.commit()
|
|
1252
|
+
await self.record_audit(
|
|
1253
|
+
contact_id, "timezone_changed",
|
|
1254
|
+
{"old_timezone": old_tz, "new_timezone": timezone},
|
|
1255
|
+
performed_by=performed_by,
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
async def soft_delete(
|
|
1259
|
+
self, contact_id: str, reason: Optional[str] = None, performed_by: str = "operator"
|
|
1260
|
+
) -> None:
|
|
1261
|
+
db = self._require_db()
|
|
1262
|
+
now = _now_iso()
|
|
1263
|
+
await db.execute(
|
|
1264
|
+
"UPDATE contacts SET deleted_at = ?, updated_at = ? WHERE contact_id = ?",
|
|
1265
|
+
(now, now, contact_id),
|
|
1266
|
+
)
|
|
1267
|
+
await db.commit()
|
|
1268
|
+
await self.record_audit(
|
|
1269
|
+
contact_id, "soft_deleted", {"reason": reason}, performed_by=performed_by
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1272
|
+
async def hard_delete(self, contact_id: str, performed_by: str = "system") -> None:
|
|
1273
|
+
db = self._require_db()
|
|
1274
|
+
await self.record_audit(
|
|
1275
|
+
contact_id, "hard_deleted", {}, performed_by=performed_by
|
|
1276
|
+
)
|
|
1277
|
+
await db.execute("DELETE FROM contacts WHERE contact_id = ?", (contact_id,))
|
|
1278
|
+
await db.commit()
|
|
1279
|
+
|
|
1280
|
+
async def merge_contacts(
|
|
1281
|
+
self, keep_id: str, merge_id: str, performed_by: str = "owner",
|
|
1282
|
+
) -> Optional[Contact]:
|
|
1283
|
+
"""Fold ``merge_id`` into ``keep_id``: move its handles, add its
|
|
1284
|
+
interaction count, then soft-delete it. Audited on both records so
|
|
1285
|
+
the merge is traceable and the loser is recoverable."""
|
|
1286
|
+
db = self._require_db()
|
|
1287
|
+
if keep_id == merge_id:
|
|
1288
|
+
return await self.get(keep_id)
|
|
1289
|
+
keep = await self.get(keep_id)
|
|
1290
|
+
loser = await self.get(merge_id)
|
|
1291
|
+
if keep is None or loser is None:
|
|
1292
|
+
raise ValueError("both contacts must exist to merge")
|
|
1293
|
+
now = _now_iso()
|
|
1294
|
+
moved = 0
|
|
1295
|
+
for h in await self.get_handles(merge_id):
|
|
1296
|
+
# Skip a handle the keeper already has (avoid a unique clash);
|
|
1297
|
+
# otherwise reassign it to the keeper.
|
|
1298
|
+
try:
|
|
1299
|
+
await db.execute(
|
|
1300
|
+
"UPDATE contact_handles SET contact_id = ? "
|
|
1301
|
+
"WHERE handle_id = ?", (keep_id, h.handle_id))
|
|
1302
|
+
moved += 1
|
|
1303
|
+
except Exception:
|
|
1304
|
+
# duplicate (gateway,address) already on keep -> drop the dup
|
|
1305
|
+
await db.execute(
|
|
1306
|
+
"DELETE FROM contact_handles WHERE handle_id = ?",
|
|
1307
|
+
(h.handle_id,))
|
|
1308
|
+
# Fold interaction history + keep the earlier last-seen.
|
|
1309
|
+
new_count = int(getattr(keep, "interaction_count", 0) or 0) +\
|
|
1310
|
+
int(getattr(loser, "interaction_count", 0) or 0)
|
|
1311
|
+
await db.execute(
|
|
1312
|
+
"UPDATE contacts SET interaction_count = ?, updated_at = ? "
|
|
1313
|
+
"WHERE contact_id = ?", (new_count, now, keep_id))
|
|
1314
|
+
await db.commit()
|
|
1315
|
+
await self.record_audit(
|
|
1316
|
+
keep_id, "merged_in",
|
|
1317
|
+
{"merged_contact_id": merge_id,
|
|
1318
|
+
"merged_display_name": loser.display_name,
|
|
1319
|
+
"handles_moved": moved},
|
|
1320
|
+
performed_by=performed_by)
|
|
1321
|
+
await self.soft_delete(
|
|
1322
|
+
merge_id, reason=f"merged into {keep_id}", performed_by=performed_by)
|
|
1323
|
+
await self.record_audit(
|
|
1324
|
+
merge_id, "merged_into", {"kept_contact_id": keep_id},
|
|
1325
|
+
performed_by=performed_by)
|
|
1326
|
+
return await self.get(keep_id)
|
|
1327
|
+
|
|
1328
|
+
async def list_handle_proposals(self, limit: int = 50) -> List[Dict[str, Any]]:
|
|
1329
|
+
"""Pending candidate associations, never installed identity links."""
|
|
1330
|
+
db = self._require_db()
|
|
1331
|
+
async with db.execute(
|
|
1332
|
+
"SELECT p.*, c.display_name FROM contact_identity_candidates p "
|
|
1333
|
+
"JOIN contacts c ON c.contact_id=p.contact_id WHERE p.status='pending' "
|
|
1334
|
+
"AND c.deleted_at IS NULL ORDER BY p.created_at DESC LIMIT ?", (max(1, min(100, limit)),)) as cur:
|
|
1335
|
+
rows = [dict(row) for row in await cur.fetchall()]
|
|
1336
|
+
for row in rows:
|
|
1337
|
+
row['at'] = row['created_at']
|
|
1338
|
+
row['evidence_refs'] = json.loads(row.pop('evidence_refs_json'))
|
|
1339
|
+
return rows
|
|
1340
|
+
|
|
1341
|
+
async def update(self, contact_id: str, **fields) -> Optional[Contact]:
|
|
1342
|
+
db = self._require_db()
|
|
1343
|
+
allowed_fields = {
|
|
1344
|
+
"display_name", "given_name", "family_name", "organization",
|
|
1345
|
+
"notes", "person_node_id", "privacy_level",
|
|
1346
|
+
"last_interaction_at", "interaction_count",
|
|
1347
|
+
"enrichment_source", "enrichment_last_at",
|
|
1348
|
+
}
|
|
1349
|
+
set_parts = []
|
|
1350
|
+
params = []
|
|
1351
|
+
for k, v in fields.items():
|
|
1352
|
+
if k not in allowed_fields:
|
|
1353
|
+
continue
|
|
1354
|
+
if k == "enrichment_source" and isinstance(v, list):
|
|
1355
|
+
v = json.dumps(v)
|
|
1356
|
+
# SQL-01: column name is validated against allowed_fields; double-quote the
|
|
1357
|
+
# identifier so SQLite treats it safely even if allowed_fields is later extended.
|
|
1358
|
+
set_parts.append(f'"{k}" = ?')
|
|
1359
|
+
params.append(v)
|
|
1360
|
+
if not set_parts:
|
|
1361
|
+
return await self.get(contact_id)
|
|
1362
|
+
set_parts.append("updated_at = ?")
|
|
1363
|
+
params.append(_now_iso())
|
|
1364
|
+
params.append(contact_id)
|
|
1365
|
+
await db.execute(
|
|
1366
|
+
f"UPDATE contacts SET {', '.join(set_parts)} WHERE contact_id = ?",
|
|
1367
|
+
params,
|
|
1368
|
+
)
|
|
1369
|
+
await db.commit()
|
|
1370
|
+
return await self.get(contact_id)
|
|
1371
|
+
|
|
1372
|
+
async def record_audit(
|
|
1373
|
+
self,
|
|
1374
|
+
contact_id: str,
|
|
1375
|
+
action: str,
|
|
1376
|
+
detail: Optional[Dict[str, Any]] = None,
|
|
1377
|
+
performed_by: str = "system",
|
|
1378
|
+
) -> None:
|
|
1379
|
+
db = self._require_db()
|
|
1380
|
+
audit_id = _gen_id("cau")
|
|
1381
|
+
now = _now_iso()
|
|
1382
|
+
await db.execute(
|
|
1383
|
+
"INSERT INTO contact_audit (id, contact_id, action, detail, performed_by, created_at) VALUES (?,?,?,?,?,?)",
|
|
1384
|
+
(audit_id, contact_id, action, json.dumps(detail or {}), performed_by, now),
|
|
1385
|
+
)
|
|
1386
|
+
await db.commit()
|
|
1387
|
+
|
|
1388
|
+
async def get_audit_log(self, contact_id: str, limit: int = 50) -> List[Dict[str, Any]]:
|
|
1389
|
+
db = self._require_db()
|
|
1390
|
+
async with db.execute(
|
|
1391
|
+
"SELECT * FROM contact_audit WHERE contact_id = ? ORDER BY created_at DESC LIMIT ?",
|
|
1392
|
+
(contact_id, limit),
|
|
1393
|
+
) as cur:
|
|
1394
|
+
rows = await cur.fetchall()
|
|
1395
|
+
return [dict(r) for r in rows]
|
|
1396
|
+
|
|
1397
|
+
# ── Deduplication helpers ─────────────────────────────────────────────────
|
|
1398
|
+
|
|
1399
|
+
async def find_by_handle(self, gateway: str, address: str) -> Optional[Contact]:
|
|
1400
|
+
"""Find contact by exact handle (including soft-deleted contacts)."""
|
|
1401
|
+
db = self._require_db()
|
|
1402
|
+
async with db.execute(
|
|
1403
|
+
"""
|
|
1404
|
+
SELECT c.* FROM contacts c
|
|
1405
|
+
JOIN contact_handles h ON h.contact_id = c.contact_id
|
|
1406
|
+
WHERE h.gateway = ? AND h.address = ?
|
|
1407
|
+
""",
|
|
1408
|
+
(gateway, address),
|
|
1409
|
+
) as cur:
|
|
1410
|
+
row = await cur.fetchone()
|
|
1411
|
+
if row is None:
|
|
1412
|
+
return None
|
|
1413
|
+
return Contact.from_row(dict(row))
|
|
1414
|
+
|
|
1415
|
+
async def find_dedup_candidates(
|
|
1416
|
+
self, given_name: Optional[str], family_name: Optional[str],
|
|
1417
|
+
phones: List[str], emails: List[str],
|
|
1418
|
+
) -> List[tuple]:
|
|
1419
|
+
"""Return list of (confidence, contact_id, reason) tuples."""
|
|
1420
|
+
candidates = []
|
|
1421
|
+
seen_ids: set = set()
|
|
1422
|
+
|
|
1423
|
+
# Normalize
|
|
1424
|
+
norm_phones = [_normalize_phone(p) for p in phones if p]
|
|
1425
|
+
norm_emails = [_normalize_email(e) for e in emails if e]
|
|
1426
|
+
|
|
1427
|
+
# 1. Exact phone match
|
|
1428
|
+
for phone in norm_phones:
|
|
1429
|
+
contact = await self.resolve_handle("imessage", phone)
|
|
1430
|
+
if contact is None:
|
|
1431
|
+
contact = await self.resolve_handle("sms", phone)
|
|
1432
|
+
if contact and contact.contact_id not in seen_ids:
|
|
1433
|
+
seen_ids.add(contact.contact_id)
|
|
1434
|
+
candidates.append((0.99, contact.contact_id, f"exact_phone:{phone}"))
|
|
1435
|
+
|
|
1436
|
+
# 2. Exact email match
|
|
1437
|
+
for email in norm_emails:
|
|
1438
|
+
contact = await self.resolve_handle("email", email)
|
|
1439
|
+
if contact and contact.contact_id not in seen_ids:
|
|
1440
|
+
seen_ids.add(contact.contact_id)
|
|
1441
|
+
candidates.append((0.99, contact.contact_id, f"exact_email:{email}"))
|
|
1442
|
+
|
|
1443
|
+
# 3. Fuzzy name similarity
|
|
1444
|
+
display = " ".join(p for p in [given_name, family_name] if p)
|
|
1445
|
+
if display:
|
|
1446
|
+
name_matches = await self.find_by_name(display, threshold=0.4)
|
|
1447
|
+
for c in name_matches:
|
|
1448
|
+
if c.contact_id not in seen_ids:
|
|
1449
|
+
sim = _name_similarity(display, c.display_name)
|
|
1450
|
+
if sim >= 0.4:
|
|
1451
|
+
seen_ids.add(c.contact_id)
|
|
1452
|
+
candidates.append((sim * 0.7, c.contact_id, f"name_similarity:{sim:.2f}"))
|
|
1453
|
+
|
|
1454
|
+
return sorted(candidates, key=lambda x: x[0], reverse=True)
|
|
1455
|
+
|
|
1456
|
+
# ── Trust scopes (context-scoped trust) ────────────────────────────────────
|
|
1457
|
+
|
|
1458
|
+
async def create_scope(
|
|
1459
|
+
self,
|
|
1460
|
+
*,
|
|
1461
|
+
scope_type: str = "group",
|
|
1462
|
+
platform: Optional[str] = None,
|
|
1463
|
+
external_id: Optional[str] = None,
|
|
1464
|
+
label: Optional[str] = None,
|
|
1465
|
+
granted_tier: str = "group_guest",
|
|
1466
|
+
created_by: str = "agent",
|
|
1467
|
+
) -> TrustScope:
|
|
1468
|
+
"""Create a trust scope, or return the existing active one for
|
|
1469
|
+
(platform, external_id) if it already exists (idempotent upsert)."""
|
|
1470
|
+
if granted_tier not in TRUST_TIERS:
|
|
1471
|
+
raise ValueError(f"invalid granted_tier: {granted_tier}")
|
|
1472
|
+
db = self._require_db()
|
|
1473
|
+
if platform is not None and external_id is not None:
|
|
1474
|
+
existing = await self.get_scope(platform=platform, external_id=external_id)
|
|
1475
|
+
if existing is not None:
|
|
1476
|
+
return existing
|
|
1477
|
+
scope_id = _gen_id("ts")
|
|
1478
|
+
now = _now_iso()
|
|
1479
|
+
await db.execute(
|
|
1480
|
+
"INSERT INTO trust_scopes (scope_id, scope_type, platform, external_id, label, "
|
|
1481
|
+
"granted_tier, created_by, active, created_at, updated_at) "
|
|
1482
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)",
|
|
1483
|
+
(scope_id, scope_type, platform, external_id, label, granted_tier, created_by, now, now),
|
|
1484
|
+
)
|
|
1485
|
+
await db.commit()
|
|
1486
|
+
return TrustScope(
|
|
1487
|
+
scope_id=scope_id, scope_type=scope_type, platform=platform, external_id=external_id,
|
|
1488
|
+
label=label, granted_tier=granted_tier, created_by=created_by, active=True,
|
|
1489
|
+
created_at=now, updated_at=now,
|
|
1490
|
+
)
|
|
1491
|
+
|
|
1492
|
+
async def get_scope(
|
|
1493
|
+
self,
|
|
1494
|
+
*,
|
|
1495
|
+
scope_id: Optional[str] = None,
|
|
1496
|
+
platform: Optional[str] = None,
|
|
1497
|
+
external_id: Optional[str] = None,
|
|
1498
|
+
) -> Optional[TrustScope]:
|
|
1499
|
+
"""Fetch a scope by scope_id, or by (platform, external_id)."""
|
|
1500
|
+
db = self._require_db()
|
|
1501
|
+
if scope_id is not None:
|
|
1502
|
+
sql, params = "SELECT * FROM trust_scopes WHERE scope_id = ?", (scope_id,)
|
|
1503
|
+
elif platform is not None and external_id is not None:
|
|
1504
|
+
sql = "SELECT * FROM trust_scopes WHERE platform = ? AND external_id = ?"
|
|
1505
|
+
params = (platform, external_id)
|
|
1506
|
+
else:
|
|
1507
|
+
raise ValueError("get_scope needs scope_id or (platform, external_id)")
|
|
1508
|
+
async with db.execute(sql, params) as cur:
|
|
1509
|
+
row = await cur.fetchone()
|
|
1510
|
+
return TrustScope.from_row(dict(row)) if row else None
|
|
1511
|
+
|
|
1512
|
+
async def add_scope_member(
|
|
1513
|
+
self, scope_id: str, contact_id: str, role: str = "member"
|
|
1514
|
+
) -> None:
|
|
1515
|
+
"""Add (or re-activate) a contact's membership in a scope."""
|
|
1516
|
+
db = self._require_db()
|
|
1517
|
+
await db.execute(
|
|
1518
|
+
"INSERT INTO scope_members (scope_id, contact_id, role, joined_at, left_at) "
|
|
1519
|
+
"VALUES (?, ?, ?, ?, NULL) "
|
|
1520
|
+
"ON CONFLICT(scope_id, contact_id) DO UPDATE SET role = excluded.role, left_at = NULL",
|
|
1521
|
+
(scope_id, contact_id, role, _now_iso()),
|
|
1522
|
+
)
|
|
1523
|
+
await db.commit()
|
|
1524
|
+
await self.record_audit(contact_id, "scope_member_added",
|
|
1525
|
+
{"scope_id": scope_id, "role": role}, performed_by="agent")
|
|
1526
|
+
|
|
1527
|
+
async def remove_scope_member(self, scope_id: str, contact_id: str) -> None:
|
|
1528
|
+
"""Mark a member as having left the scope (soft; preserves history)."""
|
|
1529
|
+
db = self._require_db()
|
|
1530
|
+
await db.execute(
|
|
1531
|
+
"UPDATE scope_members SET left_at = ? WHERE scope_id = ? AND contact_id = ? AND left_at IS NULL",
|
|
1532
|
+
(_now_iso(), scope_id, contact_id),
|
|
1533
|
+
)
|
|
1534
|
+
await db.commit()
|
|
1535
|
+
await self.record_audit(contact_id, "scope_member_removed",
|
|
1536
|
+
{"scope_id": scope_id}, performed_by="agent")
|
|
1537
|
+
|
|
1538
|
+
async def scope_members(self, scope_id: str, *, current_only: bool = True) -> List[ScopeMember]:
|
|
1539
|
+
db = self._require_db()
|
|
1540
|
+
sql = "SELECT * FROM scope_members WHERE scope_id = ?"
|
|
1541
|
+
if current_only:
|
|
1542
|
+
sql += " AND left_at IS NULL"
|
|
1543
|
+
async with db.execute(sql, (scope_id,)) as cur:
|
|
1544
|
+
rows = await cur.fetchall()
|
|
1545
|
+
return [ScopeMember.from_row(dict(r)) for r in rows]
|
|
1546
|
+
|
|
1547
|
+
async def scopes_for_contact(self, contact_id: str, *, active_only: bool = True) -> List[TrustScope]:
|
|
1548
|
+
"""All scopes a contact is a current member of (optionally active scopes only)."""
|
|
1549
|
+
db = self._require_db()
|
|
1550
|
+
sql = (
|
|
1551
|
+
"SELECT s.* FROM trust_scopes s "
|
|
1552
|
+
"JOIN scope_members m ON m.scope_id = s.scope_id "
|
|
1553
|
+
"WHERE m.contact_id = ? AND m.left_at IS NULL"
|
|
1554
|
+
)
|
|
1555
|
+
if active_only:
|
|
1556
|
+
sql += " AND s.active = 1"
|
|
1557
|
+
async with db.execute(sql, (contact_id,)) as cur:
|
|
1558
|
+
rows = await cur.fetchall()
|
|
1559
|
+
return [TrustScope.from_row(dict(r)) for r in rows]
|
|
1560
|
+
|
|
1561
|
+
async def is_authorized_in_scope(self, contact_id: str, scope_id: str) -> bool:
|
|
1562
|
+
"""True iff the contact is a current member of the (active) scope.
|
|
1563
|
+
This is group-scoped authorization — it says nothing about 1:1 rights."""
|
|
1564
|
+
db = self._require_db()
|
|
1565
|
+
async with db.execute(
|
|
1566
|
+
"SELECT 1 FROM scope_members m JOIN trust_scopes s ON s.scope_id = m.scope_id "
|
|
1567
|
+
"WHERE m.scope_id = ? AND m.contact_id = ? AND m.left_at IS NULL AND s.active = 1",
|
|
1568
|
+
(scope_id, contact_id),
|
|
1569
|
+
) as cur:
|
|
1570
|
+
return await cur.fetchone() is not None
|
|
1571
|
+
|
|
1572
|
+
async def deactivate_scope(self, scope_id: str) -> None:
|
|
1573
|
+
"""Deactivate a scope (revokes group-trust for all members at once)."""
|
|
1574
|
+
db = self._require_db()
|
|
1575
|
+
members = await self.scope_members(scope_id, current_only=True)
|
|
1576
|
+
await db.execute(
|
|
1577
|
+
"UPDATE trust_scopes SET active = 0, updated_at = ? WHERE scope_id = ?",
|
|
1578
|
+
(_now_iso(), scope_id),
|
|
1579
|
+
)
|
|
1580
|
+
await db.commit()
|
|
1581
|
+
for m in members:
|
|
1582
|
+
await self.record_audit(m.contact_id, "scope_deactivated",
|
|
1583
|
+
{"scope_id": scope_id}, performed_by="agent")
|
|
1584
|
+
|
|
1585
|
+
async def group_promotion_candidates(
|
|
1586
|
+
self, *, min_interactions: int = 5, limit: int = 50
|
|
1587
|
+
) -> List["Contact"]:
|
|
1588
|
+
"""Current members of an ACTIVE scope with sustained contact but no global 1:1 rights yet.
|
|
1589
|
+
These are who the owner could promote (group_guest -> regular), or who get auto-promoted
|
|
1590
|
+
when ``auto_promote_group_to_1on1`` is on. Group membership alone never promotes."""
|
|
1591
|
+
db = self._require_db()
|
|
1592
|
+
async with db.execute(
|
|
1593
|
+
"SELECT DISTINCT c.* FROM contacts c "
|
|
1594
|
+
"JOIN scope_members m ON m.contact_id = c.contact_id AND m.left_at IS NULL "
|
|
1595
|
+
"JOIN trust_scopes s ON s.scope_id = m.scope_id AND s.active = 1 "
|
|
1596
|
+
"WHERE c.deleted_at IS NULL AND c.interaction_allowed = 0 "
|
|
1597
|
+
"AND c.interaction_count >= ? ORDER BY c.interaction_count DESC LIMIT ?",
|
|
1598
|
+
(int(min_interactions), int(limit)),
|
|
1599
|
+
) as cur:
|
|
1600
|
+
rows = await cur.fetchall()
|
|
1601
|
+
return [Contact.from_row(dict(r)) for r in rows]
|
|
1602
|
+
|
|
1603
|
+
async def promote_scope_member(
|
|
1604
|
+
self, contact_id: str, *, to_tier: str = "regular", performed_by: str = "agent"
|
|
1605
|
+
) -> bool:
|
|
1606
|
+
"""Grant a group-scope member global 1:1 rights (tier >= ``to_tier`` + interaction
|
|
1607
|
+
allowed). Only ever RAISES standing; returns True iff something changed."""
|
|
1608
|
+
from apsimo.contacts.models import _TIER_RANK
|
|
1609
|
+
c = await self.get(contact_id)
|
|
1610
|
+
if c is None:
|
|
1611
|
+
return False
|
|
1612
|
+
changed = False
|
|
1613
|
+
if _TIER_RANK.get(c.trust_tier, 0) < _TIER_RANK.get(to_tier, 0):
|
|
1614
|
+
await self.update_tier(contact_id, to_tier, reason="promoted from group scope",
|
|
1615
|
+
performed_by=performed_by)
|
|
1616
|
+
changed = True
|
|
1617
|
+
if not c.interaction_allowed:
|
|
1618
|
+
await self.update_interaction_allowed(contact_id, True, performed_by=performed_by)
|
|
1619
|
+
changed = True
|
|
1620
|
+
if changed:
|
|
1621
|
+
await self.record_audit(contact_id, "scope_promoted_to_1on1",
|
|
1622
|
+
{"to_tier": to_tier}, performed_by=performed_by)
|
|
1623
|
+
return changed
|