insika 0.2.0 → 0.7.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +325 -17
- data/README.md +52 -11
- data/bin/insika +895 -8
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +229 -163
- data/docs/API.md +56 -0
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +111 -21
- data/docs/CONTEXT.md +132 -19
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +75 -4
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +146 -2
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +116 -0
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +16 -10
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +65 -6
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +17 -12
- data/docs/RELEASING.md +27 -0
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +6 -6
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +79 -2
- data/docs/SKILLS.md +13 -4
- data/docs/SOAK.md +127 -0
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +220 -27
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/_includes/head_custom.html +5 -0
- data/docs/_includes/title.html +13 -0
- data/docs/_sass/color_schemes/insika.scss +32 -0
- data/docs/_sass/custom/custom.scss +199 -0
- data/docs/_sass/custom/setup.scss +26 -0
- data/docs/assets/img/favicon.svg +7 -0
- data/docs/assets/img/insika-mark.svg +7 -0
- data/docs/core-concepts.md +21 -0
- data/docs/domain.md +115 -0
- data/docs/improve.md +20 -0
- data/docs/index.md +9 -6
- data/docs/integrate.md +20 -0
- data/docs/operate.md +13 -6
- data/docs/prompts/ADD-TOOL.md +118 -0
- data/docs/prompts/DIAGNOSE-TURN.md +65 -0
- data/docs/prompts/GO-LIVE.md +138 -0
- data/docs/prompts/RUN-EXAMPLES.md +70 -0
- data/docs/reference.md +19 -0
- data/docs/ship.md +10 -2
- data/docs/start-here.md +18 -0
- data/lib/insika/agent_profile.rb +202 -10
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +119 -14
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +105 -2
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_artifact.rb +35 -0
- data/lib/insika/commands/delete_concept.rb +34 -0
- data/lib/insika/commands/delete_mcp.rb +6 -2
- data/lib/insika/commands/delete_tenant_data.rb +107 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/restore_concept.rb +34 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/seed_demo_data.rb +31 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +5 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +57 -23
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- data/lib/insika/cron.rb +189 -0
- data/lib/insika/demo/agent_attrs.rb +43 -0
- data/lib/insika/demo/golden_cases.rb +81 -0
- data/lib/insika/demo/seeder.rb +336 -0
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +889 -11
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +69 -82
- data/lib/insika/dsl/server_boot.rb +92 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +186 -5
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +25 -3
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +29 -6
- data/lib/insika/evals/persona.rb +98 -0
- data/lib/insika/evals/runner.rb +9 -0
- data/lib/insika/evals/simulator.rb +225 -0
- data/lib/insika/evals/transport.rb +83 -1
- data/lib/insika/event_stream.rb +10 -0
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +869 -76
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +198 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +17 -2
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/knowledge.rb +680 -0
- data/lib/insika/knowledge_store.rb +140 -0
- data/lib/insika/mcp_client.rb +94 -0
- data/lib/insika/mcp_json.rb +74 -0
- data/lib/insika/mcp_live_tool.rb +43 -0
- data/lib/insika/mcp_store.rb +98 -26
- data/lib/insika/mcp_tool_ingestor.rb +30 -8
- data/lib/insika/mcp_tool_registry.rb +100 -0
- data/lib/insika/media.rb +382 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +7 -2
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +22 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +37 -17
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +29 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +316 -0
- data/lib/insika/router/app.rb +157 -0
- data/lib/insika/router/backend_pool.rb +98 -0
- data/lib/insika/router/hash_ring.rb +55 -0
- data/lib/insika/router/proxy_body.rb +34 -0
- data/lib/insika/router/session_key.rb +54 -0
- data/lib/insika/router.rb +18 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- data/lib/insika/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +247 -17
- data/lib/insika/server/rack_app.rb +21 -1
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +16 -0
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/steer_injector.rb +21 -10
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1437 -54
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +23 -23
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +308 -10
- data/lib/insika/studio/nav_icons.rb +19 -1
- data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
- data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
- data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
- data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
- data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
- data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
- data/lib/insika/studio/views/_agents_master.erb +44 -0
- data/lib/insika/studio/views/_message.erb +49 -32
- data/lib/insika/studio/views/agent_detail.erb +62 -278
- data/lib/insika/studio/views/agents.erb +71 -54
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +6 -3
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +106 -66
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +15 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +233 -124
- data/lib/insika/studio/views/settings.erb +41 -1
- data/lib/insika/studio/views/skills.erb +1 -2
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +24 -10
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/templates/browser-agent/README.md +36 -0
- data/lib/insika/templates/browser-agent/agent.rb +49 -0
- data/lib/insika/templates/daily-digest/README.md +38 -0
- data/lib/insika/templates/daily-digest/agent.rb +77 -0
- data/lib/insika/templates/repo-explorer/README.md +36 -0
- data/lib/insika/templates/repo-explorer/agent.rb +45 -0
- data/lib/insika/templates/research-analyst/README.md +26 -0
- data/lib/insika/templates/research-analyst/agent.rb +58 -0
- data/lib/insika/templates/review-panel/README.md +20 -0
- data/lib/insika/templates/review-panel/agent.rb +50 -0
- data/lib/insika/templates/travel-planner/README.md +35 -0
- data/lib/insika/templates/travel-planner/agent.rb +87 -0
- data/lib/insika/templates.rb +112 -0
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +47 -11
- data/lib/insika/timezone.rb +45 -0
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +89 -0
- data/lib/insika/tools/load_knowledge.rb +74 -0
- data/lib/insika/tools/run_persona_eval.rb +328 -0
- data/lib/insika/tools/save_artifact.rb +95 -0
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +1 -1
- data/lib/insika/turn_state.rb +49 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +472 -13
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +149 -3
- metadata +181 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command (WS8, phase 2 — LGPD): purges everything the engine holds
|
|
8
|
+
# about ONE TENANT — its sessions (the "<tenant>:" namespace) and everything
|
|
9
|
+
# those sessions left behind (traces, tasks, checkpoints, outbox deliveries
|
|
10
|
+
# — see SessionPurge), every memory cell under the tenant (its own + the
|
|
11
|
+
# customer cells), its outcome records (WS7) and its API CREDENTIALS (every
|
|
12
|
+
# active token of the tenant is revoked first — an offboarded tenant must
|
|
13
|
+
# not authenticate). The tenant string IS the
|
|
14
|
+
# isolation boundary, so zeroing it cannot touch another tenant's data.
|
|
15
|
+
# Operator-only BY CONSTRUCTION: the generic command ingress is
|
|
16
|
+
# operator-grade (a tenant principal never reaches it).
|
|
17
|
+
class DeleteTenantData
|
|
18
|
+
include SessionPurge
|
|
19
|
+
|
|
20
|
+
def initialize(memory_store:, session_store:, tool_trace_store: nil,
|
|
21
|
+
context_trace_store: nil, model_visible_trace_store: nil,
|
|
22
|
+
outcome_store: nil, task_store: nil,
|
|
23
|
+
checkpoint_store: nil, outbox_store: nil, shadow_pairs: nil,
|
|
24
|
+
token_store: nil, funnel_store: nil, event_stream:,
|
|
25
|
+
followup_store: nil, contact_store: nil, proposal_store: nil,
|
|
26
|
+
harvest_store: nil, schedule_store: nil, artifact_store: nil)
|
|
27
|
+
@memory_store = memory_store
|
|
28
|
+
@token_store = token_store
|
|
29
|
+
@session_store = session_store
|
|
30
|
+
@tool_trace_store = tool_trace_store
|
|
31
|
+
@context_trace_store = context_trace_store
|
|
32
|
+
@model_visible_trace_store = model_visible_trace_store # ; nil = parity
|
|
33
|
+
@outcome_store = outcome_store
|
|
34
|
+
@task_store = task_store
|
|
35
|
+
@checkpoint_store = checkpoint_store
|
|
36
|
+
@outbox_store = outbox_store
|
|
37
|
+
@shadow_pairs = shadow_pairs
|
|
38
|
+
@funnel_store = funnel_store # ; nil = nothing to sweep
|
|
39
|
+
@followup_store = followup_store # ; nil = nothing to sweep
|
|
40
|
+
@contact_store = contact_store # ; nil = nothing to sweep
|
|
41
|
+
@proposal_store = proposal_store # ; nil = nothing to sweep
|
|
42
|
+
@harvest_store = harvest_store # ; nil = nothing to sweep
|
|
43
|
+
@schedule_store = schedule_store # ; nil = nothing to sweep
|
|
44
|
+
@artifact_store = artifact_store # ; nil = nothing to sweep
|
|
45
|
+
@event_stream = event_stream
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# -> { tenant:, sessions:, memory_records:, outcomes:, tokens_revoked:,
|
|
49
|
+
# tasks:, checkpoints:, deliveries:, followups:, contacts: }.
|
|
50
|
+
def call(command)
|
|
51
|
+
tenant = Coercion.presence(command.payload[:tenant] || command.payload["tenant"])
|
|
52
|
+
raise ValidationError, "tenant is required" if tenant.nil?
|
|
53
|
+
|
|
54
|
+
# CREDENTIALS FIRST (WS1+WS8): erasing the data while the tenant's
|
|
55
|
+
# tokens still resolve leaves an offboarded tenant authenticating and
|
|
56
|
+
# opening a NEW session — the purge would report success over a live
|
|
57
|
+
# customer. Revoking before the sweep closes the door, so nothing the
|
|
58
|
+
# tenant does mid-purge survives it. nil store = single_tenant mode
|
|
59
|
+
# (no per-tenant credential exists).
|
|
60
|
+
tokens_revoked = @token_store ? @token_store.revoke_all(tenant_id: tenant) : 0
|
|
61
|
+
|
|
62
|
+
sessions = @session_store.each_id.select { |id| id.to_s.start_with?("#{tenant}:") }
|
|
63
|
+
purged = purge_sessions(sessions)
|
|
64
|
+
|
|
65
|
+
memory_records = @memory_store.purge_tenant(tenant)
|
|
66
|
+
outcomes = @outcome_store ? @outcome_store.purge(tenant: tenant) : 0
|
|
67
|
+
funnel = @funnel_store ? @funnel_store.purge(tenant: tenant) : 0
|
|
68
|
+
# the follow-up footprint dies with the tenant — records
|
|
69
|
+
# and contact cells under the same tenant prefix.
|
|
70
|
+
followups = @followup_store ? @followup_store.purge(tenant: tenant) : 0
|
|
71
|
+
contacts = @contact_store ? @contact_store.purge(tenant: tenant) : 0
|
|
72
|
+
# the distilled proposals die with the tenant.
|
|
73
|
+
proposals = @proposal_store ? @proposal_store.purge(tenant: tenant) : 0
|
|
74
|
+
# candidates reference sessions, and sessions carry the
|
|
75
|
+
# tenant prefix — the harvest rows die with the tenant (D11).
|
|
76
|
+
harvest = @harvest_store ? @harvest_store.purge(tenant: tenant) : 0
|
|
77
|
+
# recurring-schedule rows die with the tenant too (their
|
|
78
|
+
# message text is content, never kept behind an offboarded tenant).
|
|
79
|
+
schedules = @schedule_store ? @schedule_store.purge(tenant: tenant) : 0
|
|
80
|
+
# artifacts die with the tenant — a report is content, and the
|
|
81
|
+
# tenant binding is the isolation boundary (never a model-typed id).
|
|
82
|
+
artifacts = @artifact_store ? @artifact_store.purge(tenant: tenant) : 0
|
|
83
|
+
|
|
84
|
+
@event_stream.emit(Insika::Event.new(
|
|
85
|
+
type: :tenant_data_deleted,
|
|
86
|
+
data: { tenant: tenant, sessions: sessions,
|
|
87
|
+
memory_records: memory_records,
|
|
88
|
+
outcomes: outcomes,
|
|
89
|
+
funnel: funnel,
|
|
90
|
+
followups: followups,
|
|
91
|
+
contacts: contacts,
|
|
92
|
+
proposals: proposals,
|
|
93
|
+
harvest: harvest,
|
|
94
|
+
schedules: schedules,
|
|
95
|
+
artifacts: artifacts,
|
|
96
|
+
tokens_revoked: tokens_revoked }.merge(purged),
|
|
97
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
98
|
+
))
|
|
99
|
+
{ tenant: tenant, sessions: sessions, memory_records: memory_records,
|
|
100
|
+
outcomes: outcomes, funnel: funnel, followups: followups, contacts: contacts,
|
|
101
|
+
proposals: proposals, harvest: harvest, schedules: schedules,
|
|
102
|
+
artifacts: artifacts,
|
|
103
|
+
tokens_revoked: tokens_revoked }.merge(purged)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command the LGPD access right): exports what the
|
|
8
|
+
# engine holds about ONE customer — their memory cell's facts + notes,
|
|
9
|
+
# expired facts excluded. The FULL content is the RETURN VALUE (the Studio
|
|
10
|
+
# turns it into a JSON download); the emitted event carries counts only, so
|
|
11
|
+
# the event stream, the audit store and every log stay content-free (D7).
|
|
12
|
+
# The return value never enters a store, an event or a log.
|
|
13
|
+
class ExportCustomerMemory
|
|
14
|
+
def initialize(memory_store:, event_stream:)
|
|
15
|
+
@memory_store = memory_store
|
|
16
|
+
@event_stream = event_stream
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# payload: { customer: (REQUIRED), tenant: } — customer from the payload
|
|
20
|
+
# (an operator names the person); tenant from the payload || meta.
|
|
21
|
+
# -> { "customer" =>, "tenant" =>, "exported_at" =>, "facts" => [Fact#to_h],
|
|
22
|
+
# "notes" => [Note#to_h], "counts" => { "facts" =>, "notes" => } }
|
|
23
|
+
def call(command)
|
|
24
|
+
p = AgentPayload.symbolize(command.payload)
|
|
25
|
+
customer = AgentPayload.presence(p[:customer])
|
|
26
|
+
raise Insika::ValidationError, "customer is required" if customer.nil?
|
|
27
|
+
|
|
28
|
+
tenant = AgentPayload.presence(p[:tenant]) || command.meta[:tenant]
|
|
29
|
+
facts = @memory_store.facts(tenant: tenant, customer: customer)
|
|
30
|
+
notes = @memory_store.notes(tenant: tenant, customer: customer)
|
|
31
|
+
exported_at = Time.now.utc.iso8601
|
|
32
|
+
|
|
33
|
+
@event_stream.emit(Insika::Event.new(
|
|
34
|
+
type: :customer_memory_exported,
|
|
35
|
+
data: { customer: customer, tenant: tenant,
|
|
36
|
+
counts: { facts: facts.size, notes: notes.size } },
|
|
37
|
+
meta: { at: exported_at }
|
|
38
|
+
))
|
|
39
|
+
{
|
|
40
|
+
"customer" => customer, "tenant" => tenant, "exported_at" => exported_at,
|
|
41
|
+
"facts" => facts.map { |f| Coercion.deep_stringify(f.to_h) },
|
|
42
|
+
"notes" => notes.map { |n| Coercion.deep_stringify(n.to_h) },
|
|
43
|
+
"counts" => { "facts" => facts.size, "notes" => notes.size }
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# Control command (WS8, phase 2 — LGPD): purges what the engine holds about
|
|
8
|
+
# ONE customer — the customer's memory cell, their sessions (found through
|
|
9
|
+
# the `customer` var the Executor stamps on a tagged conversation) and
|
|
10
|
+
# everything those sessions left behind (traces, tasks, checkpoints, outbox
|
|
11
|
+
# deliveries — see SessionPurge). The scope string IS the isolation
|
|
12
|
+
# boundary, so zeroing it cannot touch another customer's or another
|
|
13
|
+
# tenant's data. Operator-only BY CONSTRUCTION: the generic command ingress
|
|
14
|
+
# is operator-grade (a tenant principal never reaches it).
|
|
15
|
+
#
|
|
16
|
+
# WHICH TENANT is the whole correctness of the purge: the memory cell is
|
|
17
|
+
# "memory:<tenant>:<customer>" and the sessions are the "<tenant>:" ones. It
|
|
18
|
+
# is read from the command meta (an internal caller acting AS a tenant) or
|
|
19
|
+
# from the payload (`{ customer:, tenant: }` — the operator naming it over
|
|
20
|
+
# HTTP, where the principal has no tenant of its own). Absent from both, the
|
|
21
|
+
# purge is deployment-wide: the untagged memory cell plus that customer's
|
|
22
|
+
# sessions in EVERY tenant — right for a single-tenant deployment, and never
|
|
23
|
+
# what a multi-tenant operator means, so they must name the tenant.
|
|
24
|
+
class ForgetCustomer
|
|
25
|
+
include SessionPurge
|
|
26
|
+
|
|
27
|
+
def initialize(memory_store:, session_store:, tool_trace_store: nil,
|
|
28
|
+
context_trace_store: nil, model_visible_trace_store: nil,
|
|
29
|
+
task_store: nil, checkpoint_store: nil,
|
|
30
|
+
outbox_store: nil, shadow_pairs: nil, audit_store: nil, event_stream:,
|
|
31
|
+
followup_store: nil, contact_store: nil, proposal_store: nil)
|
|
32
|
+
@memory_store = memory_store
|
|
33
|
+
@session_store = session_store
|
|
34
|
+
@tool_trace_store = tool_trace_store
|
|
35
|
+
@context_trace_store = context_trace_store
|
|
36
|
+
@model_visible_trace_store = model_visible_trace_store # ; nil = parity
|
|
37
|
+
@task_store = task_store
|
|
38
|
+
@checkpoint_store = checkpoint_store
|
|
39
|
+
@outbox_store = outbox_store
|
|
40
|
+
@shadow_pairs = shadow_pairs
|
|
41
|
+
@audit_store = audit_store
|
|
42
|
+
@event_stream = event_stream
|
|
43
|
+
@followup_store = followup_store # ; nil = nothing to sweep
|
|
44
|
+
@contact_store = contact_store # ; nil = nothing to sweep
|
|
45
|
+
@proposal_store = proposal_store # ; nil = nothing to sweep
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# -> { customer:, tenant:, memory_records:, sessions: [], tasks:,
|
|
49
|
+
# checkpoints:, deliveries:, followups:, contacts: }.
|
|
50
|
+
def call(command)
|
|
51
|
+
customer = Coercion.presence(command.payload[:customer] || command.payload["customer"])
|
|
52
|
+
raise ValidationError, "customer is required" if customer.nil?
|
|
53
|
+
|
|
54
|
+
tenant = command.meta[:tenant] ||
|
|
55
|
+
Coercion.presence(command.payload[:tenant] || command.payload["tenant"])
|
|
56
|
+
memory_scope = [tenant, customer].compact.join(":")
|
|
57
|
+
memory_records = @memory_store.purge(tenant: memory_scope)
|
|
58
|
+
|
|
59
|
+
# the follow-up footprint dies with the person — the
|
|
60
|
+
# schedule records and the contact cell (LGPD).
|
|
61
|
+
followups = @followup_store&.purge_customer(tenant: tenant, customer: customer) || 0
|
|
62
|
+
contacts = @contact_store&.delete(tenant: tenant, customer: customer) ? 1 : 0
|
|
63
|
+
|
|
64
|
+
# the distilled PROPOSALS die with the person — a proposal
|
|
65
|
+
# is born inside a customer cell (D6), so forget_customer reaches it.
|
|
66
|
+
proposals = @proposal_store&.purge_customer(tenant: tenant, customer: customer) || 0
|
|
67
|
+
|
|
68
|
+
sessions = session_ids_for(customer, tenant)
|
|
69
|
+
purged = purge_sessions(sessions)
|
|
70
|
+
|
|
71
|
+
# the audit records the thing that happened, content-free —
|
|
72
|
+
# a digest-free line with the counts (the deleted VALUES never enter
|
|
73
|
+
# the audit store). Written AFTER the purge, so the line describes a
|
|
74
|
+
# deletion that actually happened. nil audit_store = no-op.
|
|
75
|
+
@audit_store&.record(
|
|
76
|
+
cell: @memory_store.cell_for(tenant, customer),
|
|
77
|
+
action: "purge", actor: operator(command), tenant: tenant, customer: customer,
|
|
78
|
+
note: "memory_records: #{memory_records}, sessions: #{sessions.size}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
@event_stream.emit(Insika::Event.new(
|
|
82
|
+
type: :customer_forgotten,
|
|
83
|
+
data: { customer: customer, tenant: tenant,
|
|
84
|
+
memory_records: memory_records,
|
|
85
|
+
sessions: sessions,
|
|
86
|
+
followups: followups,
|
|
87
|
+
contacts: contacts,
|
|
88
|
+
proposals: proposals }.merge(purged),
|
|
89
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
90
|
+
))
|
|
91
|
+
{ customer: customer, tenant: tenant, memory_records: memory_records,
|
|
92
|
+
sessions: sessions, followups: followups, contacts: contacts,
|
|
93
|
+
proposals: proposals }.merge(purged)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def operator(command)
|
|
99
|
+
Coercion.presence(command.payload[:operator] || command.payload["operator"]) || "operator"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The customer's sessions: the ones the Executor stamped with this
|
|
103
|
+
# customer var — plus, in multi_tenant, only ids inside the tenant's
|
|
104
|
+
# "<tenant>:" namespace (single_tenant sessions carry no prefix).
|
|
105
|
+
def session_ids_for(customer, tenant)
|
|
106
|
+
@session_store.each_id.each_with_object([]) do |id, acc|
|
|
107
|
+
session = @session_store.find(id)
|
|
108
|
+
next unless session
|
|
109
|
+
next unless Coercion.presence(session.vars["customer"]).to_s == customer.to_s
|
|
110
|
+
next if tenant && !id.to_s.start_with?("#{tenant}:")
|
|
111
|
+
|
|
112
|
+
acc << id.to_s
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "date"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
module Commands
|
|
8
|
+
# the operator's FREEZE — turn the folded counts over a
|
|
9
|
+
# declared span into the baseline snapshot (A/B follow-up) and
|
|
10
|
+
# (promotion gate) read. One command, dispatched from the Studio
|
|
11
|
+
# and available to any operator-grade caller. Synchronous control command —
|
|
12
|
+
# no task, no actor, same discipline as RecordOutcome (WS7).
|
|
13
|
+
#
|
|
14
|
+
# The ≥ 4-week rule ("no 1.0 target before a remeasure") is
|
|
15
|
+
# enforced HERE, not by convention: a frozen span shorter than 28 days is a
|
|
16
|
+
# ValidationError. `frozen_at` stamps when; the baseline OVERWRITES (D5) —
|
|
17
|
+
# the freeze event is the audit surface.
|
|
18
|
+
class FreezeFunnelBaseline
|
|
19
|
+
MIN_SPAN_DAYS = 28
|
|
20
|
+
|
|
21
|
+
def initialize(funnel_store:, profiles:, event_stream:)
|
|
22
|
+
@funnel_store = funnel_store
|
|
23
|
+
@profiles = profiles
|
|
24
|
+
@event_stream = event_stream
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# payload: { agent:, tenant:, from: "YYYY-MM-DD", to: "YYYY-MM-DD", operator: }
|
|
28
|
+
# agent REQUIRED (ValidationError); tenant from payload || meta[:tenant].
|
|
29
|
+
# from/to optional — default to the pair's folded span (first/last day
|
|
30
|
+
# cell); explicit from/to are inclusive bounds.
|
|
31
|
+
# -> the baseline record.
|
|
32
|
+
def call(command)
|
|
33
|
+
payload = command.payload
|
|
34
|
+
agent = Coercion.presence(payload[:agent] || payload["agent"])
|
|
35
|
+
raise ValidationError, "agent is required" if agent.nil?
|
|
36
|
+
|
|
37
|
+
declaration = declaration_for(agent)
|
|
38
|
+
tenant = payload[:tenant] || payload["tenant"] || command.meta[:tenant]
|
|
39
|
+
|
|
40
|
+
from, to = bounds(payload, tenant, agent)
|
|
41
|
+
validate_span!(from, to)
|
|
42
|
+
|
|
43
|
+
stages = @funnel_store.days(tenant: tenant, agent: agent, from: from, to: to)
|
|
44
|
+
.each_with_object(Hash.new(0)) do |(_, counts), acc|
|
|
45
|
+
counts.each { |stage, n| acc[stage] += n }
|
|
46
|
+
end
|
|
47
|
+
primary = declaration.primary
|
|
48
|
+
primary_count = stages[primary].to_i
|
|
49
|
+
first_count = stages[declaration.first_stage].to_i
|
|
50
|
+
conversion = first_count.zero? ? nil : primary_count.to_f / first_count
|
|
51
|
+
|
|
52
|
+
baseline = {
|
|
53
|
+
"from" => from, "to" => to, "stages" => stages,
|
|
54
|
+
"primary" => primary, "primary_count" => primary_count,
|
|
55
|
+
"conversion" => conversion, "window" => declaration.attribution_window,
|
|
56
|
+
"frozen_at" => Time.now.utc.iso8601
|
|
57
|
+
}
|
|
58
|
+
@funnel_store.set_baseline(tenant: tenant, agent: agent, record: baseline)
|
|
59
|
+
|
|
60
|
+
@event_stream.emit(Insika::Event.new(
|
|
61
|
+
type: :funnel_baseline_frozen,
|
|
62
|
+
data: { agent: agent, from: from, to: to,
|
|
63
|
+
primary: primary, primary_count: primary_count,
|
|
64
|
+
conversion: conversion },
|
|
65
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
66
|
+
))
|
|
67
|
+
baseline
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# -> FunnelDeclaration | raise — the freeze needs the parsed declaration,
|
|
73
|
+
# so a malformed/absent one is a hard ValidationError (D8: the FOLD skips
|
|
74
|
+
# malformed declarations, but freezing without one is an operator error).
|
|
75
|
+
def declaration_for(agent)
|
|
76
|
+
profile = @profiles.fetch(agent)
|
|
77
|
+
decl = profile && Insika::FunnelDeclaration.parse(profile.funnel)
|
|
78
|
+
return decl if decl
|
|
79
|
+
|
|
80
|
+
raise ValidationError, "agent '#{agent}' has no valid funnel declaration"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Explicit from/to (inclusive) or the pair's folded span (first/last day
|
|
84
|
+
# cell). -> [String, String].
|
|
85
|
+
def bounds(payload, tenant, agent)
|
|
86
|
+
from = Coercion.presence(payload[:from] || payload["from"])
|
|
87
|
+
to = Coercion.presence(payload[:to] || payload["to"])
|
|
88
|
+
# both given -> no scan at all (a pair with no cells still freezes,
|
|
89
|
+
# over a zero-count span, when the operator names the dates)
|
|
90
|
+
return [from, to] if from && to
|
|
91
|
+
|
|
92
|
+
days = @funnel_store.days(tenant: tenant, agent: agent)
|
|
93
|
+
days_keys = days.keys
|
|
94
|
+
raise ValidationError, "no folded days for agent '#{agent}'" if days_keys.empty?
|
|
95
|
+
|
|
96
|
+
[from || days_keys.first, to || days_keys.last]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_span!(from, to)
|
|
100
|
+
raise ValidationError, "from must be before to" if from >= to
|
|
101
|
+
|
|
102
|
+
span = (Date.iso8601(to) - Date.iso8601(from)).to_i
|
|
103
|
+
if span < MIN_SPAN_DAYS
|
|
104
|
+
raise ValidationError,
|
|
105
|
+
"baseline span must cover at least #{MIN_SPAN_DAYS} days " \
|
|
106
|
+
"(the number needs a remeasured baseline)"
|
|
107
|
+
end
|
|
108
|
+
rescue Date::Error
|
|
109
|
+
raise ValidationError, "from/to must be ISO dates (YYYY-MM-DD)"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# — the double gate on ONE candidate, in order:
|
|
8
|
+
# eval first (expensive — a full golden replay), conversion second (cheap
|
|
9
|
+
# — a fold read). Synchronous control command (the Studio dispatches it;
|
|
10
|
+
# the gate is minutes of real replay, exactly like `gate_refinement`).
|
|
11
|
+
# It records both reports on the candidate and moves it: both passed ->
|
|
12
|
+
# awaiting_approval; either failed -> rejected with the reason (a failed
|
|
13
|
+
# candidate is terminal — the same finding must re-surface with new
|
|
14
|
+
# evidence, no silent retry).
|
|
15
|
+
class GateHarvest
|
|
16
|
+
def initialize(harvest_store:, gate:, conversion_gate:, criterion: nil, event_stream:)
|
|
17
|
+
@harvest_store = harvest_store
|
|
18
|
+
@gate = gate
|
|
19
|
+
@conversion_gate = conversion_gate
|
|
20
|
+
@criterion = criterion
|
|
21
|
+
@event_stream = event_stream
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# payload: { candidate_id: } (tenant from command.meta — WS1)
|
|
25
|
+
# -> Candidate (awaiting_approval | rejected | gated-parked; both
|
|
26
|
+
# reports attached)
|
|
27
|
+
def call(command)
|
|
28
|
+
p = AgentPayload.symbolize(command.payload)
|
|
29
|
+
candidate_id = AgentPayload.presence(p[:candidate_id])
|
|
30
|
+
raise Insika::ValidationError, "candidate_id is required" if candidate_id.nil?
|
|
31
|
+
|
|
32
|
+
candidate = @harvest_store.find_candidate(candidate_id) ||
|
|
33
|
+
(raise Insika::NotFoundError, "harvest candidate not found: #{candidate_id}")
|
|
34
|
+
|
|
35
|
+
# The base graph wires NO gates (the deployment owns the eval surface):
|
|
36
|
+
# the candidate parks at gated with the named reason — refuse-with-a-
|
|
37
|
+
# named-reason, never pass, never crash.
|
|
38
|
+
if @gate.nil? || @conversion_gate.nil?
|
|
39
|
+
parked = @harvest_store.attach_gate(
|
|
40
|
+
candidate.id,
|
|
41
|
+
eval_gate: { "passed" => false, "reason" => "no_eval_gate_wired" },
|
|
42
|
+
conversion_gate: { "passed" => false, "reason" => "no_conversion_gate_wired" },
|
|
43
|
+
criterion_sha: nil
|
|
44
|
+
)
|
|
45
|
+
return parked
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The mining budget is read HERE too (the review fix): once the mining
|
|
49
|
+
# pass spent the pack's cap, the expensive golden replay is refused
|
|
50
|
+
# with the named reason — the Refinement::Budget discipline ("not
|
|
51
|
+
# gated — the budget was spent"), applied before the provider bill.
|
|
52
|
+
run = @harvest_store.find_run(candidate.run_id)
|
|
53
|
+
cap = run && run.budget && run.budget["tokens"].to_i
|
|
54
|
+
spent = run && run.cost && run.cost["spent"].to_i
|
|
55
|
+
if cap&.positive? && spent.to_i >= cap
|
|
56
|
+
parked = @harvest_store.attach_gate(
|
|
57
|
+
candidate.id,
|
|
58
|
+
eval_gate: { "passed" => false, "reason" => "budget_exceeded",
|
|
59
|
+
"spent" => spent, "tokens" => cap },
|
|
60
|
+
conversion_gate: { "passed" => false, "reason" => "budget_exceeded" },
|
|
61
|
+
criterion_sha: nil
|
|
62
|
+
)
|
|
63
|
+
return parked
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
skill = { "name" => candidate.name, "description" => candidate.description,
|
|
67
|
+
"body" => candidate.body, "triggers" => candidate.triggers }
|
|
68
|
+
eval_report = @gate.score(agent_id: candidate.agent, skill: skill,
|
|
69
|
+
run_id: candidate.run_id)
|
|
70
|
+
conversion_report = @conversion_gate.call(tenant: command.meta[:tenant],
|
|
71
|
+
agent: candidate.agent)
|
|
72
|
+
criterion_sha = @criterion ? @criterion.sha : nil
|
|
73
|
+
|
|
74
|
+
# A conversion REFUSAL (missing data — :no_frozen_baseline etc.) is NOT
|
|
75
|
+
# a candidate failure: it parks the candidate at gated with both
|
|
76
|
+
# reports and the named reason — the page shows the ruler's hole.
|
|
77
|
+
# Only a conversion FAILURE (current worse than baseline) rejects.
|
|
78
|
+
if refused?(conversion_report)
|
|
79
|
+
gated = @harvest_store.attach_gate(candidate.id,
|
|
80
|
+
eval_gate: eval_report.to_h,
|
|
81
|
+
conversion_gate: conversion_report.to_h,
|
|
82
|
+
criterion_sha: criterion_sha)
|
|
83
|
+
emit(:harvest_gated, candidate, gated, eval_report, conversion_report)
|
|
84
|
+
return gated
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
passed = eval_report.passed && conversion_report.passed
|
|
88
|
+
@harvest_store.attach_gate(candidate.id,
|
|
89
|
+
eval_gate: eval_report.to_h,
|
|
90
|
+
conversion_gate: conversion_report.to_h,
|
|
91
|
+
criterion_sha: criterion_sha)
|
|
92
|
+
result = if passed
|
|
93
|
+
@harvest_store.mark_awaiting(candidate.id)
|
|
94
|
+
else
|
|
95
|
+
reason = eval_report.passed ? conversion_report.reason : eval_report.reason
|
|
96
|
+
@harvest_store.mark_rejected(candidate.id, operator: "gate", note: reason)
|
|
97
|
+
end
|
|
98
|
+
emit(:harvest_gated, candidate, result, eval_report, conversion_report)
|
|
99
|
+
result
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# The D6 refusals: the ruler is MISSING, not moving — the candidate
|
|
105
|
+
# parks. Anything else with passed: false is a real failure.
|
|
106
|
+
def refused?(report)
|
|
107
|
+
!report.passed && REFUSAL_REASONS.include?(report.reason)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
REFUSAL_REASONS = %i[no_criterion no_funnel no_frozen_baseline metric_mismatch no_fold
|
|
111
|
+
baseline_span_short no_baseline_rate budget_exceeded].freeze
|
|
112
|
+
|
|
113
|
+
def emit(type, candidate, result, eval_report, conversion_report)
|
|
114
|
+
@event_stream.emit(Insika::Event.new(
|
|
115
|
+
type: type,
|
|
116
|
+
# ids and verdicts, never the skill body
|
|
117
|
+
data: { run_id: candidate.run_id, candidate_id: candidate.id,
|
|
118
|
+
agent: candidate.agent,
|
|
119
|
+
eval_passed: eval_report.passed,
|
|
120
|
+
conversion_passed: conversion_report.passed,
|
|
121
|
+
reason: verdict_reason(result, eval_report, conversion_report) },
|
|
122
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
123
|
+
))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The refusal/failure reason as an id the operator stream can act on —
|
|
127
|
+
# the symbol, not the human note.
|
|
128
|
+
def verdict_reason(result, eval_report, conversion_report)
|
|
129
|
+
return nil if result.status == "awaiting_approval"
|
|
130
|
+
|
|
131
|
+
return eval_report.reason unless eval_report.passed
|
|
132
|
+
return conversion_report.reason if conversion_report.reason
|
|
133
|
+
|
|
134
|
+
result.decision && result.decision["note"]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -12,7 +12,7 @@ module Insika
|
|
|
12
12
|
# Synchronous like `run_refinement`, and for the same reason: it is operator-paced
|
|
13
13
|
# work with a human waiting on the answer. It is NOT cheap — the replay is a real
|
|
14
14
|
# conversation per golden case — so it is fired deliberately, never on a timer
|
|
15
|
-
# (
|
|
15
|
+
# (a recurring timer may schedule OTHER turns; this gate stays human-paced).
|
|
16
16
|
#
|
|
17
17
|
# Payload:
|
|
18
18
|
# run_id (required) a run in :completed — the evidence the candidate answers
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Commands
|
|
7
|
+
# C7 — turn complete shadow pairs into judged pairs, using the judges the
|
|
8
|
+
# operator ALREADY configured (settings["evals"], through JudgePanel — the
|
|
9
|
+
# one builder the eval CLI and the refinement gate use too). Never automatic:
|
|
10
|
+
# a Studio button or the CLI dispatches it, and it states its cost
|
|
11
|
+
# (2 provider calls per judge per pair, both orders — position-bias
|
|
12
|
+
# rule) before it runs.
|
|
13
|
+
#
|
|
14
|
+
# Refusals are visible, a fake judge is not: no configured judges, or fewer
|
|
15
|
+
# models than the criterion demands, is a ValidationError — an unjudged
|
|
16
|
+
# window reads :invalid, where a window judged by judges nobody chose reads
|
|
17
|
+
# as evidence.
|
|
18
|
+
class JudgeShadowPairs
|
|
19
|
+
DEFAULT_LIMIT = 50
|
|
20
|
+
DEFAULT_EXPIRE_HOURS = 24
|
|
21
|
+
|
|
22
|
+
def initialize(shadow_pairs:, settings_store:, criterion:, llm: nil, event_stream: nil)
|
|
23
|
+
@shadow_pairs = shadow_pairs
|
|
24
|
+
@settings_store = settings_store
|
|
25
|
+
@criterion = criterion
|
|
26
|
+
@llm = llm
|
|
27
|
+
@event_stream = event_stream
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# payload: { limit: Integer = 50, agent: String|nil, expire_after_hours: Integer|nil }
|
|
31
|
+
# -> { judged:, skipped:, expired:, failed:, models: [..] }
|
|
32
|
+
def call(command)
|
|
33
|
+
payload = command.payload
|
|
34
|
+
limit = begin
|
|
35
|
+
Integer(payload[:limit] || payload["limit"] || DEFAULT_LIMIT)
|
|
36
|
+
rescue ArgumentError
|
|
37
|
+
raise ValidationError, "limit must be an integer"
|
|
38
|
+
end
|
|
39
|
+
agent = Coercion.presence(payload[:agent] || payload["agent"])
|
|
40
|
+
expire_hours = begin
|
|
41
|
+
Integer(payload[:expire_after_hours] || payload["expire_after_hours"] || DEFAULT_EXPIRE_HOURS)
|
|
42
|
+
rescue ArgumentError
|
|
43
|
+
raise ValidationError, "expire_after_hours must be an integer"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Judges BEFORE the expire: a refusal (no judges, too few models) must
|
|
47
|
+
# leave every pair untouched — expiring first would stamp :incomplete on
|
|
48
|
+
# pairs and that is terminal evidence against a click that judged nothing.
|
|
49
|
+
panel = Insika::Evals::JudgePanel.pairwise(settings, llm: @llm)
|
|
50
|
+
raise ValidationError, "no judges configured in settings['evals'] — refusing to judge without judges" if panel.nil?
|
|
51
|
+
|
|
52
|
+
pairwise, models = panel
|
|
53
|
+
minimum = @criterion.rule.min_judge_models
|
|
54
|
+
distinct = models.uniq
|
|
55
|
+
if distinct.length < minimum
|
|
56
|
+
raise ValidationError, "the criterion requires at least #{minimum} distinct judge models; " \
|
|
57
|
+
"#{distinct.length} configured (#{distinct.join(', ')})"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A mirror that dropped a half stops looking like pending work.
|
|
61
|
+
expired = @shadow_pairs.expire(older_than: Time.now.utc - (expire_hours * 3600))
|
|
62
|
+
|
|
63
|
+
judged = 0
|
|
64
|
+
failed = 0
|
|
65
|
+
@shadow_pairs.unjudged(limit: limit, agent: agent).each do |pair|
|
|
66
|
+
if judge_pair(pair, pairwise, models)
|
|
67
|
+
judged += 1
|
|
68
|
+
else
|
|
69
|
+
failed += 1
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
skipped = @shadow_pairs.unjudged(agent: agent).length
|
|
73
|
+
|
|
74
|
+
emit_summary(judged: judged, skipped: skipped, expired: expired, failed: failed, models: models)
|
|
75
|
+
{ judged: judged, skipped: skipped, expired: expired, failed: failed, models: models }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def settings
|
|
81
|
+
(@settings_store.get || {})["evals"] || {}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# -> true when the pair was judged. A raising judge leaves the pair
|
|
85
|
+
# :complete (retryable on the next press) and never aborts the batch; an
|
|
86
|
+
# empty side is recorded :unknown — visible, never a preference. The rescue
|
|
87
|
+
# is broad on purpose: a provider outage raises the transport's own error
|
|
88
|
+
# class (RubyLLM::Error, an HTTP status as StandardError), NOT an
|
|
89
|
+
# Insika::Error — and one broken provider must not sink the rest of the
|
|
90
|
+
# batch against this method's own contract.
|
|
91
|
+
def judge_pair(pair, pairwise, models)
|
|
92
|
+
verdict = pairwise.compare_texts(
|
|
93
|
+
ours: Insika::Parity.transcript(pair.inbound, pair.insika_reply),
|
|
94
|
+
theirs: Insika::Parity.transcript(pair.inbound, pair.incumbent_reply)
|
|
95
|
+
)
|
|
96
|
+
verdict ||= Insika::Evals::Pairwise::Verdict.new(
|
|
97
|
+
outcome: "unknown", reason: "a side of the pair was empty", vs: "agent",
|
|
98
|
+
judges: [], order_dependent: false
|
|
99
|
+
)
|
|
100
|
+
blob = verdict.to_h.merge("judged_at" => Time.now.utc.iso8601, "models" => models)
|
|
101
|
+
@shadow_pairs.record_verdict(pair.id, verdict: blob)
|
|
102
|
+
@event_stream&.emit(Insika::Event.new(
|
|
103
|
+
type: :shadow_judged,
|
|
104
|
+
data: { pair_id: pair.id, agent: pair.agent, outcome: blob[:outcome] },
|
|
105
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
106
|
+
))
|
|
107
|
+
true
|
|
108
|
+
rescue StandardError
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def emit_summary(judged:, skipped:, expired:, failed:, models:)
|
|
113
|
+
return unless @event_stream
|
|
114
|
+
|
|
115
|
+
@event_stream.emit(Insika::Event.new(
|
|
116
|
+
type: :shadow_judge_batch,
|
|
117
|
+
data: { judged: judged, skipped: skipped, expired: expired,
|
|
118
|
+
failed: failed, models: models },
|
|
119
|
+
meta: { at: Time.now.utc.iso8601 }
|
|
120
|
+
))
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|