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
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
"""Scoped assertion projections owned by the canonical source transaction."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from contextlib import closing
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
from .source_claims import (EXTRACTION_VERSION, admission_metadata, extract_claims,
|
|
13
|
+
extraction_diagnostics, projection_timeout_seconds, norm_value)
|
|
14
|
+
from .source_time import MemoryTimeQuery, filter_unstructured
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def initialize(conn):
|
|
20
|
+
conn.execute('''CREATE TABLE IF NOT EXISTS source_claim_jobs (
|
|
21
|
+
turn_id TEXT PRIMARY KEY, status TEXT NOT NULL DEFAULT 'pending',
|
|
22
|
+
timezone TEXT NOT NULL DEFAULT 'UTC', attempts INTEGER NOT NULL DEFAULT 0,
|
|
23
|
+
next_attempt REAL NOT NULL DEFAULT 0, lease_until REAL NOT NULL DEFAULT 0,
|
|
24
|
+
error TEXT, model TEXT, extraction_version TEXT, lease_token TEXT NOT NULL DEFAULT '')''')
|
|
25
|
+
if 'diagnostics_json' not in {row[1] for row in conn.execute('PRAGMA table_info(source_claim_jobs)')}:
|
|
26
|
+
conn.execute('ALTER TABLE source_claim_jobs ADD COLUMN diagnostics_json TEXT')
|
|
27
|
+
conn.execute('''CREATE TABLE IF NOT EXISTS source_claims (
|
|
28
|
+
id TEXT PRIMARY KEY, turn_id TEXT NOT NULL, message_hash TEXT NOT NULL,
|
|
29
|
+
subject_key TEXT NOT NULL, predicate TEXT NOT NULL, value_key TEXT NOT NULL, data_json TEXT NOT NULL,
|
|
30
|
+
valid_from TEXT, valid_to TEXT, superseded_by TEXT, retracted_by TEXT)''')
|
|
31
|
+
conn.execute('CREATE INDEX IF NOT EXISTS source_claim_turn ON source_claims(turn_id)')
|
|
32
|
+
conn.execute('CREATE INDEX IF NOT EXISTS source_claim_key ON source_claims(subject_key,predicate)')
|
|
33
|
+
conn.execute('CREATE INDEX IF NOT EXISTS source_claim_message ON source_claims(message_hash)')
|
|
34
|
+
conn.execute('CREATE INDEX IF NOT EXISTS source_contact_scope ON turn_sources(contact_id,scope,session_id)')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def enqueue(conn, turn_id, messages, *, scope, timezone_name=None):
|
|
38
|
+
# Checkpoint history has no per-message verified speaker attribution/time.
|
|
39
|
+
if scope == "person" and any(m.get("role") == "user" for m in messages):
|
|
40
|
+
conn.execute('INSERT OR IGNORE INTO source_claim_jobs(turn_id,timezone) VALUES (?,?)',
|
|
41
|
+
(turn_id, timezone_name or "UTC"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def erase_removed(conn, turn_id, session_id, retained):
|
|
45
|
+
from apsimo.turns.idempotency import source_message_hash
|
|
46
|
+
hashes = {source_message_hash(session_id, message) for message in retained}
|
|
47
|
+
rows = conn.execute('SELECT id,message_hash FROM source_claims WHERE turn_id=?', (turn_id,)).fetchall()
|
|
48
|
+
for row in rows:
|
|
49
|
+
if row["message_hash"] not in hashes:
|
|
50
|
+
# Inherited identities point directly to their original grounded
|
|
51
|
+
# claim, so erasure needs one hop and never removes raw corrections.
|
|
52
|
+
conn.execute("DELETE FROM source_claims WHERE json_extract(data_json,'$.subject_basis_claim_id')=?", (row['id'],))
|
|
53
|
+
conn.execute('DELETE FROM source_claims WHERE id=?', (row["id"],))
|
|
54
|
+
if not retained:
|
|
55
|
+
conn.execute('DELETE FROM source_claim_jobs WHERE turn_id=?', (turn_id,))
|
|
56
|
+
# Supersession/retraction links on surviving claims retain IDs, not erased
|
|
57
|
+
# values. Deleting a correction must never silently revive its old value.
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _subject_basis_source_sql(identifier_sql, contact_sql):
|
|
61
|
+
"""Shared root lifecycle eligibility; full grounding stays in subject_basis.
|
|
62
|
+
|
|
63
|
+
Both arguments are internal SQL fragments, never user-supplied values.
|
|
64
|
+
"""
|
|
65
|
+
return f'''FROM source_claims b
|
|
66
|
+
JOIN turn_sources bs ON bs.turn_id=b.turn_id JOIN source_claim_jobs bj ON bj.turn_id=b.turn_id
|
|
67
|
+
WHERE b.id={identifier_sql} AND bs.contact_id={contact_sql} AND bs.scope='person' AND bj.status='complete'
|
|
68
|
+
AND NOT EXISTS (SELECT 1 FROM source_attribution_invalidations i WHERE i.source_id=bs.turn_id)
|
|
69
|
+
AND NOT EXISTS (SELECT 1 FROM source_projection_erasures e WHERE e.turn_id=bs.turn_id)
|
|
70
|
+
AND NOT EXISTS (SELECT 1 FROM source_annotations a,json_each(a.target_message_hashes_json) h
|
|
71
|
+
WHERE a.target_source_id=bs.turn_id AND h.value=b.message_hash)'''
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def subject_basis(conn, claim, *, contact_id):
|
|
75
|
+
"""Return grounded identity and explicitly qualified earlier episode context.
|
|
76
|
+
|
|
77
|
+
A root's value may be retracted or superseded while its literal subject
|
|
78
|
+
remains grounded. Annotation, erasure or changed attribution revokes it.
|
|
79
|
+
Only one fully grounded ancestor is allowed, not a recursive claim chain.
|
|
80
|
+
An immediate episode correction can qualify the earlier report without
|
|
81
|
+
withdrawing every unchanged detail. Further corrections require the existing
|
|
82
|
+
history reader: the root alone does not represent intervening revisions.
|
|
83
|
+
"""
|
|
84
|
+
identifier = claim.get('subject_basis_claim_id')
|
|
85
|
+
if not identifier:
|
|
86
|
+
return None
|
|
87
|
+
from apsimo.turns.idempotency import source_message_hash, canonical_turn_digest
|
|
88
|
+
from apsimo.turns.audio import claim_message
|
|
89
|
+
row = conn.execute('SELECT b.*,bs.session_id,bs.messages_json,bs.occurred_at,bs.ingested_at '
|
|
90
|
+
+ _subject_basis_source_sql('?', '?'),
|
|
91
|
+
(identifier, contact_id)).fetchone()
|
|
92
|
+
if row is None:
|
|
93
|
+
return None
|
|
94
|
+
data = json.loads(row['data_json'])
|
|
95
|
+
messages = json.loads(row['messages_json'])
|
|
96
|
+
message = next((claim_message(m) for m in messages
|
|
97
|
+
if source_message_hash(row['session_id'], m) == row['message_hash']), None)
|
|
98
|
+
if (data.get('subject_basis_claim_id') or message is None or message.get('role') != 'user'
|
|
99
|
+
or data.get('subject') != claim.get('subject') or row['subject_key'] != claim['subject_key']
|
|
100
|
+
or row['predicate'] != claim['predicate'] or data['evidence'] not in message.get('content', '')
|
|
101
|
+
or admission_metadata(data) is None
|
|
102
|
+
or ('source_admission' in data and ('_audio_segments' in message
|
|
103
|
+
or data['evidence'] != message.get('content')))):
|
|
104
|
+
return None
|
|
105
|
+
# Reuse the original literal-grounding rule, without inferring an alias.
|
|
106
|
+
from .source_claims import literal_subject
|
|
107
|
+
episode = data.get('representation') == claim.get('representation') == 'episode'
|
|
108
|
+
if episode:
|
|
109
|
+
if row['subject_key'] != 'episode:' + hashlib.sha256(data['evidence'].encode()).hexdigest():
|
|
110
|
+
return None
|
|
111
|
+
elif not literal_subject(data['subject'], data['evidence']):
|
|
112
|
+
return None
|
|
113
|
+
result = {'claim_id': row['id'], 'turn_id': row['turn_id'], 'message_hash': row['message_hash'],
|
|
114
|
+
'source_version': canonical_turn_digest(messages),
|
|
115
|
+
'disposition': 'subject_identity_only',
|
|
116
|
+
'value_use': 'not_evidence_for_current_value',
|
|
117
|
+
**{k: data[k] for k in ('evidence_basis', 'epistemic_state', 'source_modality') if k in data},
|
|
118
|
+
'subject': data['subject'], 'predicate': row['predicate'], 'evidence': data['evidence']}
|
|
119
|
+
if episode:
|
|
120
|
+
immediate = claim.get('prior_claim_id') == identifier
|
|
121
|
+
result.update(
|
|
122
|
+
disposition='prior_episode_report' if immediate else 'episode_history_incomplete',
|
|
123
|
+
value_use=(
|
|
124
|
+
'Read with the current correction. Corrected or withdrawn details are not current. '
|
|
125
|
+
'Unchanged details remain attributed earlier context, not newly verified facts. '
|
|
126
|
+
'A withdrawal of the whole report withdraws all its details.' if immediate else
|
|
127
|
+
'Intermediate corrections are absent here. Do not infer current details from this '
|
|
128
|
+
'original report. Open assertion history; missing or withdrawn revisions cannot '
|
|
129
|
+
'be reconstructed from the original.'),
|
|
130
|
+
role='user', reported_at=row['occurred_at'], recorded_at=row['ingested_at'],
|
|
131
|
+
event_at=data.get('event_at'), event_time=data.get('event_time', {
|
|
132
|
+
'status': 'legacy_precision_unknown' if data.get('event_at') else 'unknown'}))
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class SourceClaimProjection:
|
|
137
|
+
def __init__(self, ledger):
|
|
138
|
+
self.ledger = ledger
|
|
139
|
+
|
|
140
|
+
def _rows(self, conn, contact_id, session_id, *, turn_ids=None, message_hashes=None, ids=None,
|
|
141
|
+
key=None, time_query=None, distinct_values=False, limit=256):
|
|
142
|
+
from apsimo.turns.idempotency import source_message_hash
|
|
143
|
+
where = ["s.contact_id=?", "(s.scope='person' OR s.session_id=?)",
|
|
144
|
+
"NOT EXISTS (SELECT 1 FROM source_attribution_invalidations i WHERE i.source_id=s.turn_id)"]
|
|
145
|
+
args = [contact_id, session_id]
|
|
146
|
+
if ids is not None:
|
|
147
|
+
if not ids:
|
|
148
|
+
return []
|
|
149
|
+
where.append("c.id IN (" + ",".join("?" for _ in ids) + ")")
|
|
150
|
+
args.extend(ids)
|
|
151
|
+
if turn_ids is not None or message_hashes is not None:
|
|
152
|
+
alternatives = []
|
|
153
|
+
for column, values in (("c.turn_id", turn_ids), ("c.message_hash", message_hashes)):
|
|
154
|
+
if values:
|
|
155
|
+
alternatives.append(column + " IN (" + ",".join("?" for _ in values) + ")")
|
|
156
|
+
args.extend(values)
|
|
157
|
+
if not alternatives:
|
|
158
|
+
return []
|
|
159
|
+
where.append("(" + " OR ".join(alternatives) + ")")
|
|
160
|
+
if key:
|
|
161
|
+
where += ["c.subject_key=?", "c.predicate=?"]
|
|
162
|
+
args.extend(key)
|
|
163
|
+
if time_query:
|
|
164
|
+
where.append("c.retracted_by IS NULL")
|
|
165
|
+
if time_query.mode == "unresolved_time":
|
|
166
|
+
pass # Return labelled evidence, never certify a requested time.
|
|
167
|
+
elif time_query.mode == "observed_range":
|
|
168
|
+
# A source calendar day is an interval, not a midnight event.
|
|
169
|
+
# Source and query timezones can give that day different UTC
|
|
170
|
+
# boundaries. Retain overlap without claiming an exact instant.
|
|
171
|
+
where.append("""((json_extract(c.data_json,'$.event_time.status')='resolved'
|
|
172
|
+
AND json_extract(c.data_json,'$.event_time.precision')='calendar_day'
|
|
173
|
+
AND json_extract(c.data_json,'$.event_time.start')<?
|
|
174
|
+
AND json_extract(c.data_json,'$.event_time.end_exclusive')>?)
|
|
175
|
+
OR (coalesce(json_extract(c.data_json,'$.event_time.precision'),'')!='calendar_day'
|
|
176
|
+
AND json_extract(c.data_json,'$.event_at')>=?
|
|
177
|
+
AND json_extract(c.data_json,'$.event_at')<?))""")
|
|
178
|
+
args.extend((time_query.end, time_query.start, time_query.start, time_query.end))
|
|
179
|
+
elif time_query.mode == "valid_range":
|
|
180
|
+
where += ["c.valid_from IS NOT NULL", "c.valid_from<?", "(c.valid_to IS NULL OR c.valid_to>?)"]
|
|
181
|
+
args.extend((time_query.end, time_query.start))
|
|
182
|
+
else:
|
|
183
|
+
where += ["(c.valid_from IS NULL OR c.valid_from<=?)", "(c.valid_to IS NULL OR c.valid_to>?)"]
|
|
184
|
+
args.extend((time_query.start, time_query.start))
|
|
185
|
+
columns = "c.*,s.contact_id,s.session_id,s.scope,s.messages_json,s.occurred_at,s.ingested_at"
|
|
186
|
+
if distinct_values:
|
|
187
|
+
# A revoked inherited claim cannot win value deduplication over
|
|
188
|
+
# an independent witness. Filter lifecycle eligibility inside the
|
|
189
|
+
# scoped query, preserving its value limit and the grounding
|
|
190
|
+
# checks on returned rows below.
|
|
191
|
+
where.append("(json_extract(c.data_json,'$.subject_basis_claim_id') IS NULL OR EXISTS (SELECT 1 "
|
|
192
|
+
+ _subject_basis_source_sql("json_extract(c.data_json,'$.subject_basis_claim_id')", 's.contact_id') + '))')
|
|
193
|
+
columns += ",row_number() OVER (PARTITION BY c.value_key ORDER BY s.ingested_at DESC,c.id) AS value_rank"
|
|
194
|
+
query = ("SELECT " + columns + " FROM source_claims c JOIN turn_sources s ON s.turn_id=c.turn_id WHERE "
|
|
195
|
+
+ " AND ".join(where))
|
|
196
|
+
if distinct_values:
|
|
197
|
+
query = "SELECT * FROM (" + query + ") WHERE value_rank=1"
|
|
198
|
+
query += " ORDER BY ingested_at DESC LIMIT ?"
|
|
199
|
+
rows = conn.execute(query, [*args, limit]).fetchall()
|
|
200
|
+
result, membership = [], {}
|
|
201
|
+
for row in rows:
|
|
202
|
+
turn = row["turn_id"]
|
|
203
|
+
if turn not in membership:
|
|
204
|
+
membership[turn] = {source_message_hash(row["session_id"], message)
|
|
205
|
+
for message in json.loads(row["messages_json"])}
|
|
206
|
+
if row["message_hash"] not in membership[turn]:
|
|
207
|
+
continue
|
|
208
|
+
data = json.loads(row["data_json"])
|
|
209
|
+
if data.get('subject_basis_claim_id'):
|
|
210
|
+
basis = subject_basis(conn, data, contact_id=contact_id)
|
|
211
|
+
if basis is None:
|
|
212
|
+
continue
|
|
213
|
+
data['subject_basis'] = basis
|
|
214
|
+
data.update({key: row[key] for key in ("id", "turn_id", "message_hash", "subject_key", "predicate",
|
|
215
|
+
"valid_from", "valid_to", "superseded_by", "retracted_by")})
|
|
216
|
+
data.update(observed_at=row["occurred_at"], recorded_at=row["ingested_at"])
|
|
217
|
+
result.append(data)
|
|
218
|
+
return result
|
|
219
|
+
|
|
220
|
+
def prior(self, source, message, limit=16):
|
|
221
|
+
words = set(norm_value(message.get("content", "")).split())
|
|
222
|
+
|
|
223
|
+
def relevance(row):
|
|
224
|
+
# A partial correction can omit every topic word in the original
|
|
225
|
+
# report. Its retained, scoped basis still identifies that episode.
|
|
226
|
+
basis = row.get('subject_basis', {}) if row.get('representation') == 'episode' else {}
|
|
227
|
+
evidence = row['evidence'] + ' ' + basis.get('evidence', '')
|
|
228
|
+
return len(words & set(norm_value(evidence).split())), row['recorded_at']
|
|
229
|
+
|
|
230
|
+
hits = self.ledger.search_sources(message.get("content", ""), contact_id=source["contact_id"],
|
|
231
|
+
session_id=source["session_id"], limit=10)
|
|
232
|
+
turn_ids = [hit["turn_id"] for hit in hits if hit["turn_id"] != source["turn_id"]]
|
|
233
|
+
with closing(self.ledger._connect()) as conn:
|
|
234
|
+
rows = self._rows(conn, source["contact_id"], source["session_id"], turn_ids=turn_ids or None)
|
|
235
|
+
# Relevance can find a retracted report without finding its latest
|
|
236
|
+
# count-only correction. Offer the existing current episode, never
|
|
237
|
+
# the stale handle. The normal row reader rechecks source scope,
|
|
238
|
+
# attribution and retained basis; no erased history is reconstructed.
|
|
239
|
+
keys = list(dict.fromkeys((row['subject_key'], row['predicate'])
|
|
240
|
+
for row in sorted(rows, key=relevance, reverse=True)
|
|
241
|
+
if row.get('representation') == 'episode'))[:limit]
|
|
242
|
+
for key in keys:
|
|
243
|
+
rows.extend(self._rows(conn, source['contact_id'], source['session_id'],
|
|
244
|
+
key=key, limit=limit))
|
|
245
|
+
rows = list({row['id']: row for row in rows
|
|
246
|
+
if not row['superseded_by'] and not row['retracted_by']}.values())
|
|
247
|
+
rows.sort(key=relevance, reverse=True)
|
|
248
|
+
return rows[:limit]
|
|
249
|
+
|
|
250
|
+
def commit(self, source, message, claims, *, model, lease_token=None):
|
|
251
|
+
from apsimo.turns.idempotency import source_message_hash, canonical_turn_digest
|
|
252
|
+
from apsimo.turns.audio import claim_message, claim_basis
|
|
253
|
+
message_hash = source_message_hash(source["session_id"], message)
|
|
254
|
+
with closing(self.ledger._connect()) as conn, conn:
|
|
255
|
+
conn.execute('BEGIN IMMEDIATE')
|
|
256
|
+
if lease_token is not None and not conn.execute(
|
|
257
|
+
"SELECT 1 FROM source_claim_jobs WHERE turn_id=? AND status='running' AND lease_token=?",
|
|
258
|
+
(source["turn_id"], lease_token)).fetchone():
|
|
259
|
+
return 0
|
|
260
|
+
current = conn.execute('''SELECT * FROM turn_sources s WHERE turn_id=? AND NOT EXISTS
|
|
261
|
+
(SELECT 1 FROM source_attribution_invalidations i WHERE i.source_id=s.turn_id)''',
|
|
262
|
+
(source["turn_id"],)).fetchone()
|
|
263
|
+
if current is None:
|
|
264
|
+
return 0
|
|
265
|
+
current_messages = json.loads(current['messages_json'])
|
|
266
|
+
original = next((m for m in current_messages
|
|
267
|
+
if source_message_hash(current['session_id'], m) == message_hash), None)
|
|
268
|
+
current_view = claim_message(original) if original is not None else None
|
|
269
|
+
if (current_view is None or current_view.get('content') != message.get('content')
|
|
270
|
+
or current_view.get('_audio_segments') != message.get('_audio_segments')):
|
|
271
|
+
return 0
|
|
272
|
+
ids = list({claim["prior_claim_id"] for claim in claims if claim.get("prior_claim_id")})
|
|
273
|
+
prior = {row["id"]: row for row in self._rows(conn, current["contact_id"], current["session_id"], ids=ids)}
|
|
274
|
+
written = 0
|
|
275
|
+
for raw in claims:
|
|
276
|
+
claim = dict(raw)
|
|
277
|
+
# Validation occurs against exact current message bytes again,
|
|
278
|
+
# after model execution and after any concurrent source erase.
|
|
279
|
+
if current_view["content"][claim["span_start"]:claim["span_end"]] != claim["evidence"]:
|
|
280
|
+
continue
|
|
281
|
+
if 'source_admission' in claim and (admission_metadata(claim) is None
|
|
282
|
+
or '_audio_segments' in current_view or claim['evidence'] != current_view['content']):
|
|
283
|
+
continue
|
|
284
|
+
if '_audio_segments' in current_view:
|
|
285
|
+
basis = claim_basis(current_view, claim['span_start'], claim['span_end'])
|
|
286
|
+
review = claim.get('admission_review', {})
|
|
287
|
+
if (basis is None or basis != claim.get('evidence_basis')
|
|
288
|
+
or review.get('version') != 'source-claim-review-v1'
|
|
289
|
+
or review.get('basis') != 'model_judgment_unverified'):
|
|
290
|
+
continue
|
|
291
|
+
if not conn.execute('''SELECT 1 FROM source_media_links l JOIN source_media m USING(asset_hash)
|
|
292
|
+
WHERE l.turn_id=? AND l.message_hash=? AND l.asset_hash=?
|
|
293
|
+
AND m.mime_type='audio/wav' AND m.status='complete' ''',
|
|
294
|
+
(source['turn_id'], message_hash, basis['segment']['asset_id'][7:])).fetchone():
|
|
295
|
+
continue
|
|
296
|
+
claim.update(epistemic_state='derived_unverified', source_modality='audio_transcript',
|
|
297
|
+
evidence_basis={**basis, 'source_message_hash': message_hash,
|
|
298
|
+
'source_version_at_formation': canonical_turn_digest(current_messages)})
|
|
299
|
+
basis = [source["turn_id"], message_hash, claim["subject_key"], claim["predicate"], claim["value"], claim["evidence"]]
|
|
300
|
+
if claim.get('subject_basis_claim_id'):
|
|
301
|
+
basis.append(claim['subject_basis_claim_id'])
|
|
302
|
+
cid = "claim:" + hashlib.sha256(json.dumps(basis, ensure_ascii=False).encode()).hexdigest()
|
|
303
|
+
if conn.execute('SELECT 1 FROM source_claims WHERE id=?', (cid,)).fetchone():
|
|
304
|
+
continue
|
|
305
|
+
old = prior.get(claim.get("prior_claim_id"))
|
|
306
|
+
if claim.get('subject_basis_claim_id'):
|
|
307
|
+
# Re-read current status even when another candidate in
|
|
308
|
+
# this same batch already corrected the supplied prior.
|
|
309
|
+
live = self._rows(conn, current['contact_id'], current['session_id'],
|
|
310
|
+
ids=[claim.get('prior_claim_id')])
|
|
311
|
+
old = live[0] if live else None
|
|
312
|
+
if (old is None or old['subject'] != claim['subject']
|
|
313
|
+
or old['subject_key'] != claim['subject_key'] or old['predicate'] != claim['predicate']
|
|
314
|
+
or old['superseded_by'] or old['retracted_by']
|
|
315
|
+
or claim['operation'] not in {'correct', 'change'}
|
|
316
|
+
or admission_metadata(claim) is None
|
|
317
|
+
or claim['subject_basis_claim_id'] != (old.get('subject_basis_claim_id') or old['id'])
|
|
318
|
+
or subject_basis(conn, claim, contact_id=current['contact_id']) is None):
|
|
319
|
+
continue # A missing dependency cannot become assert.
|
|
320
|
+
if old and (old["subject_key"] != claim["subject_key"] or old["predicate"] != claim["predicate"]
|
|
321
|
+
or old["superseded_by"] or old["retracted_by"]):
|
|
322
|
+
old = None
|
|
323
|
+
if not old:
|
|
324
|
+
claim["operation"] = "assert"
|
|
325
|
+
claim.update(model=model, extraction_version=EXTRACTION_VERSION, role="user")
|
|
326
|
+
conn.execute('''INSERT INTO source_claims
|
|
327
|
+
(id,turn_id,message_hash,subject_key,predicate,value_key,data_json,valid_from,valid_to)
|
|
328
|
+
VALUES (?,?,?,?,?,?,?,?,?)''', (cid, source["turn_id"], message_hash,
|
|
329
|
+
claim["subject_key"], claim["predicate"], norm_value(claim["value"]), json.dumps(claim, ensure_ascii=False),
|
|
330
|
+
claim["valid_from"], claim["valid_to"]))
|
|
331
|
+
if old and norm_value(old["value"]) != norm_value(claim["value"]):
|
|
332
|
+
if claim["operation"] == "correct":
|
|
333
|
+
conn.execute('UPDATE source_claims SET retracted_by=? WHERE id=?', (cid, old["id"]))
|
|
334
|
+
elif claim["operation"] == "change" and claim["valid_from"]:
|
|
335
|
+
if not old["valid_from"] or old["valid_from"] < claim["valid_from"]:
|
|
336
|
+
conn.execute('UPDATE source_claims SET valid_to=?,superseded_by=? WHERE id=?',
|
|
337
|
+
(claim["valid_from"], cid, old["id"]))
|
|
338
|
+
written += 1
|
|
339
|
+
return written
|
|
340
|
+
|
|
341
|
+
def claim_job(self):
|
|
342
|
+
now = time.time()
|
|
343
|
+
with closing(self.ledger._connect()) as conn, conn:
|
|
344
|
+
conn.execute('BEGIN IMMEDIATE')
|
|
345
|
+
job = conn.execute('''SELECT j.*,s.contact_id,s.session_id,s.scope,s.messages_json,s.occurred_at,s.ingested_at
|
|
346
|
+
FROM source_claim_jobs j JOIN turn_sources s ON s.turn_id=j.turn_id
|
|
347
|
+
WHERE (j.status='pending' AND j.next_attempt<=?) OR (j.status='running' AND j.lease_until<=?)
|
|
348
|
+
ORDER BY s.ingested_at LIMIT 1''', (now, now)).fetchone()
|
|
349
|
+
if job is None:
|
|
350
|
+
return None
|
|
351
|
+
token = uuid.uuid4().hex
|
|
352
|
+
conn.execute("UPDATE source_claim_jobs SET status='running',attempts=attempts+1,lease_until=?,lease_token=? WHERE turn_id=?",
|
|
353
|
+
(now + 60, token, job["turn_id"]))
|
|
354
|
+
return dict(job, lease_token=token)
|
|
355
|
+
|
|
356
|
+
def finish_job(self, job, *, model=None, error=None, diagnostics=None):
|
|
357
|
+
# claim_job returns the pre-increment count. Older workers can leave
|
|
358
|
+
# this additive column untouched; never attribute their later attempt
|
|
359
|
+
# to measurements produced by this worker.
|
|
360
|
+
encoded = json.dumps(dict(diagnostics, attempt=job['attempts'] + 1), sort_keys=True) if diagnostics is not None else None
|
|
361
|
+
with closing(self.ledger._connect()) as conn, conn:
|
|
362
|
+
if error:
|
|
363
|
+
delay = min(900, 15 * 2 ** min(job["attempts"], 6))
|
|
364
|
+
conn.execute("UPDATE source_claim_jobs SET status='pending',error=?,next_attempt=?,lease_until=0,diagnostics_json=? WHERE turn_id=? AND lease_token=?",
|
|
365
|
+
(error, time.time() + delay, encoded, job["turn_id"], job["lease_token"]))
|
|
366
|
+
else:
|
|
367
|
+
conn.execute("UPDATE source_claim_jobs SET status='complete',error=NULL,model=?,extraction_version=?,lease_until=0,diagnostics_json=? WHERE turn_id=? AND lease_token=?",
|
|
368
|
+
(model, EXTRACTION_VERSION, encoded, job["turn_id"], job["lease_token"]))
|
|
369
|
+
|
|
370
|
+
def renew_job(self, job, request_timeout):
|
|
371
|
+
"""Extend this lease for bounded extraction, review and their commit."""
|
|
372
|
+
with closing(self.ledger._connect()) as conn, conn:
|
|
373
|
+
updated = conn.execute('''UPDATE source_claim_jobs SET lease_until=?
|
|
374
|
+
WHERE turn_id=? AND status='running' AND lease_token=?''',
|
|
375
|
+
(time.time() + request_timeout + 30, job['turn_id'], job['lease_token']))
|
|
376
|
+
return updated.rowcount == 1
|
|
377
|
+
|
|
378
|
+
async def process_one(self, router):
|
|
379
|
+
job = self.claim_job()
|
|
380
|
+
if job is None:
|
|
381
|
+
return False
|
|
382
|
+
model = None
|
|
383
|
+
diagnostics = extraction_diagnostics()
|
|
384
|
+
try:
|
|
385
|
+
for message in json.loads(job["messages_json"]):
|
|
386
|
+
# Retained ASR has a deterministic derived view. Other media
|
|
387
|
+
# remains in its existing caption/source recall path.
|
|
388
|
+
from apsimo.turns.audio import claim_message
|
|
389
|
+
message = claim_message(message)
|
|
390
|
+
if message is None:
|
|
391
|
+
continue
|
|
392
|
+
content = message.get('content')
|
|
393
|
+
if message.get("role") != "user" or not isinstance(content, str) or not content.strip():
|
|
394
|
+
continue
|
|
395
|
+
request_timeout = projection_timeout_seconds(router)
|
|
396
|
+
if not self.renew_job(job, request_timeout):
|
|
397
|
+
return True # Forgotten or reclaimed; do not start a stale model call.
|
|
398
|
+
claims, model = await extract_claims(router, job, message, self.prior(job, message),
|
|
399
|
+
timezone_name=job["timezone"], request_timeout=request_timeout,
|
|
400
|
+
diagnostics=diagnostics)
|
|
401
|
+
if model == "local_extraction_role_unavailable":
|
|
402
|
+
self.finish_job(job, error=model, diagnostics=diagnostics)
|
|
403
|
+
return True
|
|
404
|
+
self.commit(job, message, claims, model=model, lease_token=job["lease_token"])
|
|
405
|
+
self.finish_job(job, model=model, diagnostics=diagnostics)
|
|
406
|
+
except asyncio.CancelledError:
|
|
407
|
+
raise
|
|
408
|
+
except Exception as exc:
|
|
409
|
+
self.finish_job(job, error=type(exc).__name__, diagnostics=diagnostics)
|
|
410
|
+
logger.warning("source claim projection deferred (%s)", type(exc).__name__)
|
|
411
|
+
return True
|
|
412
|
+
|
|
413
|
+
def status(self, contact_id):
|
|
414
|
+
with closing(self.ledger._connect()) as conn:
|
|
415
|
+
rows = [dict(row) for row in conn.execute('''SELECT j.turn_id,j.status,j.attempts,j.error,j.model,j.extraction_version,j.diagnostics_json,
|
|
416
|
+
(SELECT count(*) FROM source_claims c WHERE c.turn_id=j.turn_id) AS claim_count
|
|
417
|
+
FROM source_claim_jobs j JOIN turn_sources s ON s.turn_id=j.turn_id WHERE s.contact_id=?
|
|
418
|
+
ORDER BY s.ingested_at DESC LIMIT 20''', (contact_id,))]
|
|
419
|
+
for row in rows:
|
|
420
|
+
encoded = row.pop('diagnostics_json')
|
|
421
|
+
data = json.loads(encoded) if encoded is not None else None
|
|
422
|
+
row['diagnostics'] = data if data is not None and data['attempt'] == row['attempts'] else None
|
|
423
|
+
return rows
|
|
424
|
+
|
|
425
|
+
def preferences(self, contact_id, session_id='', *, now=None, limit=20):
|
|
426
|
+
"""Read admitted speaker preferences without authoring another profile.
|
|
427
|
+
|
|
428
|
+
Keep the full quotation, including a recurring activity or condition.
|
|
429
|
+
A correction makes an interpretation unsuitable as automatic guidance;
|
|
430
|
+
ordinary source recall still presents the original and its annotation.
|
|
431
|
+
"""
|
|
432
|
+
from datetime import datetime, timezone
|
|
433
|
+
from apsimo.turns.idempotency import canonical_turn_digest
|
|
434
|
+
stamp = datetime.fromtimestamp(time.time() if now is None else now, timezone.utc).isoformat()
|
|
435
|
+
with closing(self.ledger._connect()) as conn:
|
|
436
|
+
ids = [r[0] for r in conn.execute('''SELECT c.id FROM source_claims c
|
|
437
|
+
JOIN turn_sources s ON s.turn_id=c.turn_id
|
|
438
|
+
WHERE s.contact_id=? AND (s.scope='person' OR s.session_id=?)
|
|
439
|
+
AND c.subject_key='speaker' AND c.superseded_by IS NULL AND c.retracted_by IS NULL
|
|
440
|
+
AND json_extract(c.data_json,'$.memory_quality.memory_kind')='preference'
|
|
441
|
+
AND json_extract(c.data_json,'$.admission_review.version')='source-claim-review-v1'
|
|
442
|
+
AND json_extract(c.data_json,'$.admission_review.basis')='model_judgment_unverified'
|
|
443
|
+
AND (c.valid_from IS NULL OR c.valid_from<=?) AND (c.valid_to IS NULL OR c.valid_to>?)
|
|
444
|
+
AND NOT EXISTS (SELECT 1 FROM source_projection_erasures e WHERE e.turn_id=s.turn_id)
|
|
445
|
+
AND NOT EXISTS (SELECT 1 FROM source_attribution_invalidations i WHERE i.source_id=s.turn_id)
|
|
446
|
+
ORDER BY s.ingested_at DESC,c.id LIMIT ?''',
|
|
447
|
+
(contact_id, session_id, stamp, stamp, max(1, min(limit, 100))))]
|
|
448
|
+
result = []
|
|
449
|
+
for claim in self._rows(conn, contact_id, session_id, ids=ids, limit=len(ids) or 1):
|
|
450
|
+
annotations = conn.execute('SELECT target_message_hashes_json FROM source_annotations WHERE target_source_id=?',
|
|
451
|
+
(claim['turn_id'],)).fetchall()
|
|
452
|
+
if any(claim['message_hash'] in json.loads(r[0]) for r in annotations):
|
|
453
|
+
continue
|
|
454
|
+
source = conn.execute('SELECT session_id,messages_json FROM turn_sources WHERE turn_id=?', (claim['turn_id'],)).fetchone()
|
|
455
|
+
refs = [{
|
|
456
|
+
'source_id': claim['turn_id'], 'source_contact_id': contact_id,
|
|
457
|
+
'source_version': canonical_turn_digest(json.loads(source['messages_json'])),
|
|
458
|
+
'message_hash': claim['message_hash']}]
|
|
459
|
+
if claim.get('subject_basis'):
|
|
460
|
+
basis = claim['subject_basis']
|
|
461
|
+
refs.append({'source_id': basis['turn_id'], 'source_contact_id': contact_id,
|
|
462
|
+
'source_version': basis['source_version'], 'message_hash': basis['message_hash']})
|
|
463
|
+
result.append({**claim, 'session_id': source['session_id'], 'sources': refs})
|
|
464
|
+
return result
|
|
465
|
+
|
|
466
|
+
def _current_work_reply(self, conn, source, message, *, contact_id, session_id):
|
|
467
|
+
"""Resolve one exact status request through existing canonical lineage.
|
|
468
|
+
|
|
469
|
+
A native answer is often stored separately from its supplied user input.
|
|
470
|
+
Unknown, corrected, changed, multiple-input and cross-scope provenance
|
|
471
|
+
remains recallable. This transient hint never changes stored history.
|
|
472
|
+
"""
|
|
473
|
+
from apsimo.intelligence.graph.selection import current_work_query
|
|
474
|
+
from apsimo.turns.idempotency import canonical_turn_digest, source_message_hash
|
|
475
|
+
if message.get('role') != 'assistant' or source['scope'] != 'person':
|
|
476
|
+
return False
|
|
477
|
+
refs = message.get('_supplied_inputs')
|
|
478
|
+
if refs is not None:
|
|
479
|
+
if not isinstance(refs, list) or len(refs) != 1 or not isinstance(refs[0], dict):
|
|
480
|
+
return False
|
|
481
|
+
ref = refs[0]
|
|
482
|
+
if set(ref) != {'source_id', 'input_message_hash'}:
|
|
483
|
+
return False
|
|
484
|
+
parent = conn.execute('''SELECT * FROM turn_sources WHERE turn_id=? AND contact_id=?
|
|
485
|
+
AND (scope='person' OR session_id=?) AND NOT EXISTS (
|
|
486
|
+
SELECT 1 FROM source_attribution_invalidations i WHERE i.source_id=turn_sources.turn_id)
|
|
487
|
+
''', (ref['source_id'], contact_id, session_id)).fetchone()
|
|
488
|
+
if parent is None or parent['scope'] != 'person':
|
|
489
|
+
return False
|
|
490
|
+
messages = json.loads(parent['messages_json'])
|
|
491
|
+
if {'source_id': parent['turn_id'], 'source_version': canonical_turn_digest(messages)}\
|
|
492
|
+
not in message.get('_supplied_sources', []):
|
|
493
|
+
return False
|
|
494
|
+
matches = [m for m in messages if m.get('role') == 'user'
|
|
495
|
+
and source_message_hash(parent['session_id'], m) == ref['input_message_hash']]
|
|
496
|
+
if len(matches) != 1:
|
|
497
|
+
return False
|
|
498
|
+
request = matches[0]
|
|
499
|
+
else:
|
|
500
|
+
# Ordinary paired turns can carry the request in the same envelope.
|
|
501
|
+
messages = json.loads(source['messages_json'])
|
|
502
|
+
indices = [i for i, m in enumerate(messages) if m == message]
|
|
503
|
+
if len(indices) != 1 or indices[0] == 0:
|
|
504
|
+
return False
|
|
505
|
+
parent, request = source, messages[indices[0] - 1]
|
|
506
|
+
if request.get('role') != 'user':
|
|
507
|
+
return False
|
|
508
|
+
request_hash = source_message_hash(parent['session_id'], request)
|
|
509
|
+
annotations = conn.execute('SELECT target_message_hashes_json FROM source_annotations WHERE target_source_id=?',
|
|
510
|
+
(parent['turn_id'],)).fetchall()
|
|
511
|
+
if any(request_hash in json.loads(row[0]) for row in annotations):
|
|
512
|
+
return False
|
|
513
|
+
return current_work_query(request.get('content'))
|
|
514
|
+
|
|
515
|
+
def prepare_context(self, beliefs, source_hits, *, contact_id, session_id, time_query: MemoryTimeQuery,
|
|
516
|
+
classify_work_replies=False):
|
|
517
|
+
"""Expand retrieved keys into complete scoped assertion bundles.
|
|
518
|
+
|
|
519
|
+
Ranking chooses relevant keys. It cannot choose a winner within an
|
|
520
|
+
unresolved conflict or reintroduce text from a corrected assertion.
|
|
521
|
+
"""
|
|
522
|
+
from apsimo.turns.idempotency import source_message_hash
|
|
523
|
+
from apsimo.turns.audio import source_text
|
|
524
|
+
from apsimo.intelligence.graph.recall import source_candidates
|
|
525
|
+
turn_ids = list(dict.fromkeys(
|
|
526
|
+
[row["turn_id"] for row in source_hits] + [str(row["source_uri"])[5:] for row in beliefs
|
|
527
|
+
if str(row.get("source_uri") or "").startswith("turn:")]))
|
|
528
|
+
if not turn_ids:
|
|
529
|
+
return filter_unstructured(beliefs, time_query), []
|
|
530
|
+
with closing(self.ledger._connect()) as conn:
|
|
531
|
+
sources = {row["turn_id"]: dict(row) for row in conn.execute(
|
|
532
|
+
"SELECT * FROM turn_sources WHERE contact_id=? AND (scope='person' OR session_id=?) AND turn_id IN ("
|
|
533
|
+
+ ",".join("?" for _ in turn_ids) + ")", (contact_id, session_id, *turn_ids))}
|
|
534
|
+
hashes = {source_message_hash(source["session_id"], message)
|
|
535
|
+
for source in sources.values() for message in json.loads(source["messages_json"])
|
|
536
|
+
if any(
|
|
537
|
+
hit["turn_id"] == source["turn_id"] and hit["role"] == message.get("role")
|
|
538
|
+
and hit["content"] in source_text(message.get('content')) for hit in source_hits)}
|
|
539
|
+
claims = self._rows(conn, contact_id, session_id, turn_ids=turn_ids, message_hashes=hashes, limit=512)
|
|
540
|
+
status_replies = set()
|
|
541
|
+
if classify_work_replies:
|
|
542
|
+
# Resolve only exact retrieved messages, never every assistant
|
|
543
|
+
# message in an otherwise long canonical checkpoint.
|
|
544
|
+
pending = {(hit['turn_id'], hit.get('source_message_hash')) for hit in source_hits}
|
|
545
|
+
for source in sources.values():
|
|
546
|
+
for message in json.loads(source['messages_json']):
|
|
547
|
+
identity = (source['turn_id'], source_message_hash(source['session_id'], message))
|
|
548
|
+
if identity not in pending:
|
|
549
|
+
continue
|
|
550
|
+
pending.discard(identity)
|
|
551
|
+
if self._current_work_reply(conn, source, message,
|
|
552
|
+
contact_id=contact_id, session_id=session_id):
|
|
553
|
+
status_replies.add(identity)
|
|
554
|
+
by_turn, by_hash = {}, {}
|
|
555
|
+
for claim in claims:
|
|
556
|
+
by_turn.setdefault(claim["turn_id"], []).append(claim)
|
|
557
|
+
by_hash.setdefault(claim["message_hash"], []).append(claim)
|
|
558
|
+
keys, retained_beliefs = [], []
|
|
559
|
+
for original in beliefs:
|
|
560
|
+
row = dict(original)
|
|
561
|
+
uri = str(row.get("source_uri") or "")
|
|
562
|
+
turn = uri[5:] if uri.startswith("turn:") else None
|
|
563
|
+
known = by_turn.get(turn, [])
|
|
564
|
+
if known:
|
|
565
|
+
keys.extend((c["subject_key"], c["predicate"]) for c in known)
|
|
566
|
+
continue
|
|
567
|
+
if turn in sources:
|
|
568
|
+
row["occurred_at"] = sources[turn]["occurred_at"]
|
|
569
|
+
retained_beliefs.append(row)
|
|
570
|
+
retained_hits = []
|
|
571
|
+
for original in source_hits:
|
|
572
|
+
hit = dict(original)
|
|
573
|
+
hit.pop('_current_work_status_reply', None)
|
|
574
|
+
source = sources.get(hit["turn_id"])
|
|
575
|
+
removed = []
|
|
576
|
+
if source:
|
|
577
|
+
# Carry canonical attribution through selection. Checkpoint
|
|
578
|
+
# scope cannot attest the speaker of every historical message.
|
|
579
|
+
hit.update({name: source[name] for name in ("contact_id", "session_id", "scope")})
|
|
580
|
+
for message in json.loads(source["messages_json"]):
|
|
581
|
+
if hit.get('source_message_hash') and hit['source_message_hash'] != source_message_hash(source['session_id'], message):
|
|
582
|
+
continue
|
|
583
|
+
text = message.get("content")
|
|
584
|
+
if isinstance(text, list):
|
|
585
|
+
text = source_text(text)
|
|
586
|
+
if message.get("role") != hit["role"] or not isinstance(text, str):
|
|
587
|
+
continue
|
|
588
|
+
if ((source['turn_id'], source_message_hash(source['session_id'], message)) in status_replies
|
|
589
|
+
and hit.get('source_message_hash') == source_message_hash(source['session_id'], message)):
|
|
590
|
+
hit['_current_work_status_reply'] = True
|
|
591
|
+
message_claims = by_hash.get(source_message_hash(source["session_id"], message), [])
|
|
592
|
+
# Source FTS chunks overlap. Clip exact message spans into
|
|
593
|
+
# every matching chunk occurrence; never depend on an entire
|
|
594
|
+
# corrected quotation fitting inside one retrieved chunk.
|
|
595
|
+
offset = text.find(hit["content"])
|
|
596
|
+
if offset >= 0 and hit["content"] != text:
|
|
597
|
+
hit["excerpt_truncated"] = True
|
|
598
|
+
while offset >= 0:
|
|
599
|
+
procedures = [c for c in message_claims
|
|
600
|
+
if c.get('memory_quality', {}).get('memory_kind') == 'procedure']
|
|
601
|
+
if procedures:
|
|
602
|
+
# Its unclaimed remainder may also contain a
|
|
603
|
+
# required condition. Select the owning message,
|
|
604
|
+
# never an independently ranked leftover fragment.
|
|
605
|
+
keys.extend((c['subject_key'], c['predicate']) for c in procedures)
|
|
606
|
+
removed.append((0, len(hit['content'])))
|
|
607
|
+
for claim in message_claims:
|
|
608
|
+
start = max(0, claim["span_start"] - offset)
|
|
609
|
+
end = min(len(hit["content"]), claim["span_end"] - offset)
|
|
610
|
+
if start < end:
|
|
611
|
+
keys.append((claim["subject_key"], claim["predicate"]))
|
|
612
|
+
removed.append((start, end))
|
|
613
|
+
offset = text.find(hit["content"], offset + 1)
|
|
614
|
+
cursor = 0
|
|
615
|
+
# Each remainder is still one exact contiguous source quotation.
|
|
616
|
+
# Concatenating separated fragments would fabricate a quotation.
|
|
617
|
+
for start, end in sorted(removed) + [(len(hit["content"]), len(hit["content"]))]:
|
|
618
|
+
if start > cursor:
|
|
619
|
+
fragment = hit["content"][cursor:start]
|
|
620
|
+
if fragment.strip(" .,:;\n\t"):
|
|
621
|
+
retained_hits.append(dict(hit, content=fragment,
|
|
622
|
+
excerpt_truncated=bool(removed) or bool(hit.get("excerpt_truncated"))))
|
|
623
|
+
cursor = max(cursor, end)
|
|
624
|
+
bundles, message_contexts, emitted_messages, groups = [], {}, set(), {}
|
|
625
|
+
unresolved_time_keys = set()
|
|
626
|
+
|
|
627
|
+
def current_group(key):
|
|
628
|
+
if key not in groups:
|
|
629
|
+
with closing(self.ledger._connect()) as conn:
|
|
630
|
+
groups[key] = self._rows(conn, contact_id, session_id, key=key,
|
|
631
|
+
time_query=time_query, distinct_values=True, limit=9)
|
|
632
|
+
if time_query.mode == 'observed_range' and any(
|
|
633
|
+
c.get('event_time', {}).get('precision') == 'calendar_day'
|
|
634
|
+
and (c['event_time']['start'] < time_query.start
|
|
635
|
+
or c['event_time']['end_exclusive'] > time_query.end)
|
|
636
|
+
for c in groups[key]):
|
|
637
|
+
# The event could fall in the overlapping part or the
|
|
638
|
+
# remainder of its source day. Relevance is not proof
|
|
639
|
+
# that it happened within the query's narrower window.
|
|
640
|
+
unresolved_time_keys.add(key)
|
|
641
|
+
if not groups[key] and time_query.mode == 'observed_range':
|
|
642
|
+
# Relevance already selected this source. An unknown
|
|
643
|
+
# episode time may help answer a date question, but it
|
|
644
|
+
# cannot certify a matching date. Never admit a known
|
|
645
|
+
# event outside the requested interval through here.
|
|
646
|
+
groups[key] = [c for c in self._rows(conn, contact_id, session_id, key=key,
|
|
647
|
+
time_query=MemoryTimeQuery(), distinct_values=True, limit=9)
|
|
648
|
+
if c.get('representation') == 'episode' and not c.get('event_at')]
|
|
649
|
+
if groups[key]:
|
|
650
|
+
unresolved_time_keys.add(key)
|
|
651
|
+
return groups[key]
|
|
652
|
+
|
|
653
|
+
def complete_message_context(claim):
|
|
654
|
+
"""Keep a current short message or a procedure's conditions together.
|
|
655
|
+
|
|
656
|
+
Keep the owning message together when it is still current. A
|
|
657
|
+
changed message needs the existing source/history readers instead
|
|
658
|
+
of reviving obsolete instructions from its original quotation.
|
|
659
|
+
"""
|
|
660
|
+
procedure = claim.get('memory_quality', {}).get('memory_kind') == 'procedure'
|
|
661
|
+
identity = (claim['turn_id'], claim['message_hash'])
|
|
662
|
+
if identity not in message_contexts:
|
|
663
|
+
with closing(self.ledger._connect()) as conn:
|
|
664
|
+
source = conn.execute('''SELECT * FROM turn_sources WHERE turn_id=? AND contact_id=?
|
|
665
|
+
AND (scope='person' OR session_id=?)''',
|
|
666
|
+
(claim['turn_id'], contact_id, session_id)).fetchone()
|
|
667
|
+
message = next((m for m in json.loads(source['messages_json'])
|
|
668
|
+
if source_message_hash(source['session_id'], m) == claim['message_hash']), None) if source else None
|
|
669
|
+
ids = [row['id'] for row in conn.execute(
|
|
670
|
+
'SELECT id FROM source_claims WHERE turn_id=? AND message_hash=? LIMIT 513', identity)]
|
|
671
|
+
all_claims = self._rows(conn, contact_id, session_id, ids=ids, limit=513)
|
|
672
|
+
current = self._rows(conn, contact_id, session_id, ids=ids,
|
|
673
|
+
time_query=time_query, limit=513)
|
|
674
|
+
text = source_text(message.get('content')) if message else ''
|
|
675
|
+
complete = bool(text and all_claims and len(ids) < 513 and len(all_claims) == len(ids)
|
|
676
|
+
and {c['id'] for c in all_claims} == {c['id'] for c in current}
|
|
677
|
+
and not any(c['superseded_by'] or c['retracted_by'] for c in all_claims)
|
|
678
|
+
and all(len(current_group((c['subject_key'], c['predicate']))) == 1 for c in all_claims))
|
|
679
|
+
message_contexts[identity] = {'source': dict(source) if source else None,
|
|
680
|
+
'message': message, 'text': text, 'complete': complete, 'claims': all_claims}
|
|
681
|
+
value = message_contexts[identity]
|
|
682
|
+
if not procedure:
|
|
683
|
+
# An unresolved time expression cannot certify a dated fact,
|
|
684
|
+
# but must not strip conditions from its attributed source.
|
|
685
|
+
# The bundle retains query_time_unresolved in that case.
|
|
686
|
+
# Resolved historical windows still use qualified assertions.
|
|
687
|
+
if (not value['complete'] or len(value['text']) > 2000
|
|
688
|
+
or time_query.mode not in {'current', 'unresolved_time'}
|
|
689
|
+
# Derived media needs its exact segment/recognizer basis;
|
|
690
|
+
# a transcript's display prefix is not missing context.
|
|
691
|
+
or any(c.get('evidence_basis') for c in value['claims'])
|
|
692
|
+
or all(c['evidence'].strip() == value['text'].strip() for c in value['claims'])):
|
|
693
|
+
return None
|
|
694
|
+
return value
|
|
695
|
+
# A complete one-message assertion keeps its existing property
|
|
696
|
+
# semantics. This boundary concerns partial-message procedures.
|
|
697
|
+
if value['complete'] and claim['evidence'].strip() == value['text'].strip():
|
|
698
|
+
return None
|
|
699
|
+
return value
|
|
700
|
+
|
|
701
|
+
for key in dict.fromkeys(keys):
|
|
702
|
+
group = current_group(key)
|
|
703
|
+
if not group:
|
|
704
|
+
continue
|
|
705
|
+
overflow = len(group) > 8
|
|
706
|
+
group.sort(key=lambda c: (c["valid_from"] or "", c["recorded_at"], c["id"]))
|
|
707
|
+
# Exact value equality only; substring containment is not agreement.
|
|
708
|
+
values = {norm_value(c["value"]) for c in group}
|
|
709
|
+
def overlaps(a, b):
|
|
710
|
+
return (not a["valid_to"] or not b["valid_from"] or b["valid_from"] < a["valid_to"]) and (
|
|
711
|
+
not b["valid_to"] or not a["valid_from"] or a["valid_from"] < b["valid_to"])
|
|
712
|
+
conflict = any(norm_value(a["value"]) != norm_value(b["value"]) and overlaps(a, b)
|
|
713
|
+
for i, a in enumerate(group) for b in group[i + 1:])
|
|
714
|
+
members = [{"claim_id": c["id"], "source": "turn:" + c["turn_id"],
|
|
715
|
+
"source_message_hash": c["message_hash"], "role": c["role"],
|
|
716
|
+
"value": c["value"], "quote": c["evidence"], "observed_at": c["observed_at"],
|
|
717
|
+
"recorded_at": c["recorded_at"], "valid_from": c["valid_from"], "valid_to": c["valid_to"],
|
|
718
|
+
"event_at": c.get("event_at"), "event_time": c.get("event_time", {
|
|
719
|
+
"status": "legacy_precision_unknown" if c.get("event_at") else "unknown"}),
|
|
720
|
+
"reported_at": c["observed_at"], "validity_basis": c["validity_basis"],
|
|
721
|
+
"operation": c["operation"], "prior_claim_id": c.get("prior_claim_id"),
|
|
722
|
+
**{k: c[k] for k in ('representation', 'evidence_basis', 'epistemic_state', 'source_modality', 'subject_basis') if k in c}}
|
|
723
|
+
for c in group]
|
|
724
|
+
status = "unresolved_conflict" if conflict else ("temporal_history" if len(values) > 1 else "source_assertion")
|
|
725
|
+
source_refs = [(c['turn_id'], c['message_hash']) for c in group]
|
|
726
|
+
source_refs.extend((c['subject_basis']['turn_id'], c['subject_basis']['message_hash'])
|
|
727
|
+
for c in group if c.get('subject_basis'))
|
|
728
|
+
identifier = hashlib.sha256(json.dumps([contact_id, key, [c["id"] for c in group]]).encode()).hexdigest()
|
|
729
|
+
bundle = {"id": "assertions:" + identifier, "kind": "source_quote",
|
|
730
|
+
"history_anchor": {"source_id": group[0]['turn_id'], "claim_id": group[0]['id']},
|
|
731
|
+
"source_turn_ids": list(dict.fromkeys(turn for turn, _ in source_refs)),
|
|
732
|
+
"_source_message_hashes": {turn: list(dict.fromkeys(
|
|
733
|
+
message for source, message in source_refs if source == turn))
|
|
734
|
+
for turn, _ in source_refs},
|
|
735
|
+
"source_uri": "turn:" + group[0]["turn_id"], "claim_status": status,
|
|
736
|
+
"epistemic_state": ('derived_unverified' if any(c.get('evidence_basis') for c in group) else status),
|
|
737
|
+
**({'source_modality': 'audio_transcript'} if any(c.get('evidence_basis') for c in group) else {}),
|
|
738
|
+
"atomic_evidence": True,
|
|
739
|
+
"content_format": "source_assertions_v1",
|
|
740
|
+
# Rank the grounded language the user supplied.
|
|
741
|
+
# Administrative IDs/timestamps in the output JSON
|
|
742
|
+
# are provenance, not the passage's semantic topic.
|
|
743
|
+
# Conflicting peers remain one indivisible candidate.
|
|
744
|
+
"ranking_text": "\n".join(dict.fromkeys(c["evidence"] for c in group)),
|
|
745
|
+
**({"validity_status": "query_time_unresolved"}
|
|
746
|
+
if time_query.mode == "unresolved_time" or key in unresolved_time_keys else {}),
|
|
747
|
+
"contradiction_count": len(values) - 1 if conflict else 0, "relevance": 1 / (61 + len(bundles)),
|
|
748
|
+
"content": json.dumps({"subject": group[0]["subject"], "predicate": key[1],
|
|
749
|
+
"status": status, "assertions": members}, ensure_ascii=False),
|
|
750
|
+
**({"excerpt_truncated": True, "content": json.dumps({
|
|
751
|
+
"subject": group[0]['subject'], "predicate": key[1],
|
|
752
|
+
"status": "incomplete_assertion_history", "distinct_values_at_least": len(values),
|
|
753
|
+
"instruction": "Open assertion history before resolving this property; no value selected."},
|
|
754
|
+
ensure_ascii=False)} if overflow else {})}
|
|
755
|
+
contexts = [(claim, complete_message_context(claim)) for claim in group]
|
|
756
|
+
contexts = [(claim, context) for claim, context in contexts if context is not None]
|
|
757
|
+
if contexts:
|
|
758
|
+
bundle['source_anchors'] = [{'source_id': turn} for turn in
|
|
759
|
+
dict.fromkeys(claim['turn_id'] for claim in group)]
|
|
760
|
+
if len(group) == 1 and contexts[0][1]['complete']:
|
|
761
|
+
claim, context = contexts[0]
|
|
762
|
+
identity = (claim['turn_id'], claim['message_hash'])
|
|
763
|
+
if identity in emitted_messages:
|
|
764
|
+
continue
|
|
765
|
+
emitted_messages.add(identity)
|
|
766
|
+
source, message = context['source'], context['message']
|
|
767
|
+
procedure = claim.get('memory_quality', {}).get('memory_kind') == 'procedure'
|
|
768
|
+
bundle.update(id=('procedure-source:' if procedure else 'message-source:') + hashlib.sha256(json.dumps(identity).encode()).hexdigest(),
|
|
769
|
+
content=context['text'], ranking_text=context['text'],
|
|
770
|
+
**({'procedure_context': 'complete_source_message_text'} if procedure else {
|
|
771
|
+
'source_context': 'complete_source_message_text',
|
|
772
|
+
'source_history_anchors': [{'source_id': c['turn_id'], 'claim_id': c['id']}
|
|
773
|
+
for c in context['claims']]}),
|
|
774
|
+
claim_status='source_procedure_context' if procedure else 'source_message_context',
|
|
775
|
+
epistemic_state='derived_unverified' if claim.get('evidence_basis') else 'quotation',
|
|
776
|
+
source_turn_id=claim['turn_id'], source_message_hash=claim['message_hash'],
|
|
777
|
+
role=message['role'], contact_id=source['contact_id'], session_id=source['session_id'],
|
|
778
|
+
scope=source['scope'], occurred_at=source['occurred_at'], ingested_at=source['ingested_at'])
|
|
779
|
+
# Content now holds raw source text, which can itself look
|
|
780
|
+
# like JSON. It is no longer an internal assertion card.
|
|
781
|
+
bundle.pop('content_format', None)
|
|
782
|
+
derived = [{'claim_id': c['id'], 'evidence_basis': c['evidence_basis']}
|
|
783
|
+
for c in context['claims'] if c.get('evidence_basis')]
|
|
784
|
+
if derived:
|
|
785
|
+
bundle.update(source_evidence_bases=derived, epistemic_state='derived_unverified',
|
|
786
|
+
source_modality='audio_transcript')
|
|
787
|
+
# Other represented properties can inherit an exact subject
|
|
788
|
+
# from another source. Retain every opening dependency.
|
|
789
|
+
for represented in context['claims']:
|
|
790
|
+
basis = represented.get('subject_basis')
|
|
791
|
+
if basis:
|
|
792
|
+
if basis['turn_id'] not in bundle['source_turn_ids']:
|
|
793
|
+
bundle['source_turn_ids'].append(basis['turn_id'])
|
|
794
|
+
hashes = bundle['_source_message_hashes'].setdefault(basis['turn_id'], [])
|
|
795
|
+
if basis['message_hash'] not in hashes:
|
|
796
|
+
hashes.append(basis['message_hash'])
|
|
797
|
+
bundle['source_anchors'] = [{'source_id': turn} for turn in bundle['source_turn_ids']]
|
|
798
|
+
else:
|
|
799
|
+
# A conflicting or changed procedure remains discoverable,
|
|
800
|
+
# but its partial assertion is not sufficient instructions.
|
|
801
|
+
# Opening only the selected property's history could miss
|
|
802
|
+
# the sibling whose correction made this message unsafe.
|
|
803
|
+
anchors = {(c['turn_id'], c['subject_key'], c['predicate']): {
|
|
804
|
+
'source_id': c['turn_id'], 'claim_id': c['id']}
|
|
805
|
+
for _, context in contexts for c in context['claims']}
|
|
806
|
+
bundle.update(procedure_context='full_source_required', excerpt_truncated=True,
|
|
807
|
+
procedure_history_anchors=list(anchors.values()),
|
|
808
|
+
content='Incomplete procedure context. Open the full sources in source_anchors '
|
|
809
|
+
'using their recalled source versions and inspect each procedure_history_anchors entry before '
|
|
810
|
+
'following the procedure. Property history alone may omit its conditions.')
|
|
811
|
+
bundles.append(bundle)
|
|
812
|
+
# A complete selected message already includes its unclaimed remainder.
|
|
813
|
+
# Do not rank those fragments independently against their own context.
|
|
814
|
+
retained_hits = [hit for hit in retained_hits
|
|
815
|
+
if (hit['turn_id'], hit.get('source_message_hash')) not in emitted_messages]
|
|
816
|
+
return (filter_unstructured(retained_beliefs, time_query),
|
|
817
|
+
bundles + filter_unstructured(source_candidates(retained_hits), time_query))
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
async def run_source_claim_worker(ledger, router_provider, *, claims_enabled=True):
|
|
821
|
+
"""One consumer, durable jobs and leases; process loss resumes from SQLite."""
|
|
822
|
+
projection = SourceClaimProjection(ledger)
|
|
823
|
+
from apsimo.identity import get_owner_contact_id
|
|
824
|
+
from apsimo.self_model.judgments import SelfJudgments
|
|
825
|
+
judgments = SelfJudgments(ledger, owner_id=get_owner_contact_id())
|
|
826
|
+
from apsimo.self_model.appraisals import AppraisalStore
|
|
827
|
+
appraisals = AppraisalStore(ledger, owner_id=get_owner_contact_id())
|
|
828
|
+
from apsimo.turns.media import SourceMedia
|
|
829
|
+
media = SourceMedia(ledger)
|
|
830
|
+
from apsimo.turns.source_vectors import SourceVectors
|
|
831
|
+
from apsimo.vector import get_store, get_pipeline
|
|
832
|
+
vectors = SourceVectors(ledger, get_store(), get_pipeline())
|
|
833
|
+
vectors.backfill()
|
|
834
|
+
try:
|
|
835
|
+
media.recover_unowned_files()
|
|
836
|
+
except OSError:
|
|
837
|
+
logger.warning("source media orphan recovery deferred")
|
|
838
|
+
reflections = {'judgment': judgments, 'appraisal': appraisals, 'claim': projection}
|
|
839
|
+
reflection_tasks = {name: None for name in reflections}
|
|
840
|
+
next_identity_check = 0.0
|
|
841
|
+
try:
|
|
842
|
+
while True:
|
|
843
|
+
worked = False
|
|
844
|
+
if time.monotonic() >= next_identity_check:
|
|
845
|
+
next_identity_check = time.monotonic() + 30
|
|
846
|
+
try:
|
|
847
|
+
from apsimo.api.routers.social_state import reconcile_pending_identities
|
|
848
|
+
worked = await reconcile_pending_identities(ledger)
|
|
849
|
+
except Exception as exc:
|
|
850
|
+
logger.warning('identity source reconciliation deferred (%s)', type(exc).__name__)
|
|
851
|
+
# Durable model projections share this worker's lifecycle. Their
|
|
852
|
+
# requests must not stall source indexing or media processing.
|
|
853
|
+
if claims_enabled:
|
|
854
|
+
for name, projection_worker in reflections.items():
|
|
855
|
+
task = reflection_tasks[name]
|
|
856
|
+
if task is None or task.done():
|
|
857
|
+
if task is not None:
|
|
858
|
+
try:
|
|
859
|
+
worked = task.result() or worked
|
|
860
|
+
except Exception as exc:
|
|
861
|
+
logger.warning("source %s deferred (%s)", name, type(exc).__name__)
|
|
862
|
+
reflection_tasks[name] = asyncio.create_task(
|
|
863
|
+
projection_worker.process_one(router_provider()))
|
|
864
|
+
try:
|
|
865
|
+
worked = await vectors.process_one() or worked
|
|
866
|
+
except asyncio.CancelledError:
|
|
867
|
+
raise
|
|
868
|
+
except Exception as exc:
|
|
869
|
+
logger.warning("source semantic projection deferred (%s)", type(exc).__name__)
|
|
870
|
+
try:
|
|
871
|
+
if claims_enabled:
|
|
872
|
+
media_worked = await media.process_one(router_provider())
|
|
873
|
+
worked = worked or media_worked
|
|
874
|
+
except asyncio.CancelledError:
|
|
875
|
+
raise
|
|
876
|
+
except Exception as exc:
|
|
877
|
+
logger.warning("source claim worker deferred (%s)", type(exc).__name__)
|
|
878
|
+
await asyncio.sleep(.05 if worked else 2)
|
|
879
|
+
finally:
|
|
880
|
+
tasks = [task for task in reflection_tasks.values() if task is not None]
|
|
881
|
+
for task in tasks:
|
|
882
|
+
task.cancel()
|
|
883
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|