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
data/docs/DEPLOY.md
CHANGED
|
@@ -37,7 +37,9 @@ The image boots **N Falcon worker processes over one SQLite file**
|
|
|
37
37
|
knob**: it decides which engine semantics hold cluster-wide and which are
|
|
38
38
|
per-worker. The default is 1 because the per-worker semantics are the product
|
|
39
39
|
— the queue modes (`collect`/`steer`) and FIFO ordering need one
|
|
40
|
-
session actor; raise N only with sticky routing per session in front.
|
|
40
|
+
session actor; raise N only with sticky routing per session in front. It is
|
|
41
|
+
also a [Soak](SOAK.md) precondition: with more than one worker the hourly RSS
|
|
42
|
+
series is a random process per sample, and a soak refuses to start. The
|
|
41
43
|
contract:
|
|
42
44
|
|
|
43
45
|
> Everything here describes N workers of **one** deployment — one graph, replicated.
|
|
@@ -55,7 +57,14 @@ contract:
|
|
|
55
57
|
worker that holds the session's actor. The engine does **not** promise them
|
|
56
58
|
across workers. A deploy that needs those semantics for a session must route
|
|
57
59
|
that session's traffic to one worker (sticky routing) — or accept per-worker
|
|
58
|
-
best-effort.
|
|
60
|
+
best-effort. **On Railway there is no sticky-routing option at any layer** —
|
|
61
|
+
confirmed against Railway's own docs, which distribute traffic randomly and
|
|
62
|
+
explicitly do not support sticky sessions — so N>1 there is not "per-worker
|
|
63
|
+
best-effort," it is a guaranteed cross-session leak the first time two
|
|
64
|
+
requests for the same session land on different workers. See the Railway
|
|
65
|
+
section below; `insika doctor` errors on `WEB_CONCURRENCY>1` there — unless
|
|
66
|
+
[`insika-router`](ROUTER.md), the session-sticky proxy, is in front,
|
|
67
|
+
which is what makes N>1 safe on Railway too (N local workers behind it).
|
|
59
68
|
3. **Recovery is part of boot, in every wiring.** Every worker boots through
|
|
60
69
|
`Server::Boot`, which runs recovery **before the listen**. The per-record
|
|
61
70
|
sweeps (undelivered outbox records, undelivered delegation results) run in
|
|
@@ -112,10 +121,11 @@ this section is the single source of truth for what changing it means.
|
|
|
112
121
|
| `DEEPSEEK_MODEL` | `deepseek-v4-flash` | model |
|
|
113
122
|
| `CONSUMER_INTERNAL_URL` | — | base URL for data-tools calling back a consumer's internal API (see below) |
|
|
114
123
|
| `INSIKA_EGRESS_HOSTS` | — | outbound host allowlist (SSRF guard) |
|
|
115
|
-
| `INSIKA_EGRESS_ALLOW_HTTP` / `_ALLOW_PRIVATE` | off | for `http`/loopback callbacks only (**never in cloud**) |
|
|
124
|
+
| `INSIKA_EGRESS_ALLOW_HTTP` / `_ALLOW_PRIVATE` | off | for `http`/loopback callbacks only (**never in cloud**). Also opens inbound **media** fetches (audio/image URLs) to `http`/private targets — a local run serving media over `http://` needs it |
|
|
116
125
|
| `INSIKA_RELAY_TOKEN` | — | **mounts the relay channel** at `POST /channels/relay/events`, and is the Bearer it requires. Empty = the route does not exist (`404`). See [Channels](CHANNELS.md) |
|
|
117
126
|
| `INSIKA_RELAY_DELIVER_URL` | — | your callback; the engine POSTs each reply there. Goes through the egress guard |
|
|
118
127
|
| `INSIKA_RELAY_DELIVER_TOKEN` | — | Bearer the engine sends **to** your callback (optional) |
|
|
128
|
+
| `INSIKA_RELAY_DELIVERY` | `at_end` | how the relay flushes the outbox: `at_end` (one POST) or `progressive` (one POST per balloon — [delivery policy](CHANNELS.md#delivery-policy)) |
|
|
119
129
|
| `INSIKA_WIDGET_ORIGINS` | — | exact-match origins allowed to embed the [web widget](CHANNELS.md#the-web-widget), comma-separated. No wildcards. **Half the switch**: with `INSIKA_WIDGET_AGENTS` unset, nothing is mounted (`404`) |
|
|
120
130
|
| `INSIKA_WIDGET_AGENTS` | — | agent ids a widget visitor may address, comma-separated. The other half of the switch. **A chat rate limit is also required** or the widget answers `503` |
|
|
121
131
|
| `LITESTREAM_REPLICA_URL` | — | **enables Litestream** (backup/DR). Empty = disabled (default). See below |
|
|
@@ -218,11 +228,64 @@ healthcheck, and a restart policy.
|
|
|
218
228
|
without a volume, SQLite is ephemeral and recovery resumes nothing after a
|
|
219
229
|
redeploy.
|
|
220
230
|
3. **Vars**: `DEEPSEEK_API_KEY`, `OPENCLAW_GATEWAY_TOKEN`, `CONSUMER_INTERNAL_URL`,
|
|
221
|
-
`INSIKA_EGRESS_HOSTS
|
|
231
|
+
`INSIKA_EGRESS_HOSTS`. **Leave `WEB_CONCURRENCY` at its default of 1** unless
|
|
232
|
+
you run [`insika-router`](ROUTER.md) in front. Railway's own docs say it
|
|
233
|
+
"does not support sticky sessions" and randomly distributes traffic across
|
|
234
|
+
replicas/workers — there is no way on this platform to satisfy the "sticky
|
|
235
|
+
routing per session in front" precondition item 2 of the process model
|
|
236
|
+
requires, at any layer (Railway replicas or Falcon's own `--count` workers
|
|
237
|
+
within one container). Raising `WEB_CONCURRENCY` without a router in front
|
|
238
|
+
is not a throughput knob, it is a guaranteed way to leak a reply across
|
|
239
|
+
sessions the first time two requests for the same session land on different
|
|
240
|
+
workers — `insika doctor` errors on this combination for exactly that
|
|
241
|
+
reason. `insika-router` is the fix: N local Falcon workers
|
|
242
|
+
behind one sticky proxy, still one Railway replica.
|
|
222
243
|
4. The healthcheck hits `/up`.
|
|
223
244
|
5. Point your consumer at the service's public URL, with a matching API token
|
|
224
245
|
(see [RUNNING-LOCAL.md](RUNNING-LOCAL.md)).
|
|
225
246
|
|
|
247
|
+
## Kubernetes
|
|
248
|
+
|
|
249
|
+
Same contract as Railway, for the same reason, verified against
|
|
250
|
+
ingress-nginx's own docs: **run one pod, `WEB_CONCURRENCY=1`.** Scale
|
|
251
|
+
vertically (bigger pod) for now.
|
|
252
|
+
|
|
253
|
+
Why a standard K8s setup does not get you out of this: the session id lives
|
|
254
|
+
in the **JSON body** of `POST /v1/responses` (the `user` field), not in a
|
|
255
|
+
header, cookie, or URL segment. That rules out every sticky-routing
|
|
256
|
+
mechanism K8s gives you for free:
|
|
257
|
+
|
|
258
|
+
- A vanilla `Service` (`ClusterIP`) load-balances across pod endpoints with
|
|
259
|
+
no notion of session at all — same failure mode as Railway's replicas,
|
|
260
|
+
one layer down.
|
|
261
|
+
- `Service.spec.sessionAffinity: ClientIP` does not help either: the caller
|
|
262
|
+
is the consumer's own backend (achei-b2b, a server-to-server call), not
|
|
263
|
+
the end customer's browser, so many different customers' sessions arrive
|
|
264
|
+
from the same source IP and would collide on the same pod instead of
|
|
265
|
+
spreading — the opposite of what you want, and it still does not restore
|
|
266
|
+
a real per-session guarantee.
|
|
267
|
+
- ingress-nginx's `nginx.ingress.kubernetes.io/upstream-hash-by` (the
|
|
268
|
+
standard sticky/consistent-hash annotation) hashes on nginx *variables* —
|
|
269
|
+
headers, cookies, `$request_uri`, client IP — not on a field parsed out of
|
|
270
|
+
a POST body. Reaching into the JSON body needs a custom Lua/OpenResty
|
|
271
|
+
snippet (or an Envoy filter) that parses the request and extracts `user`
|
|
272
|
+
before hashing. That is real, unbuilt engineering work, not a K8s
|
|
273
|
+
annotation to flip.
|
|
274
|
+
|
|
275
|
+
If real horizontal throughput is ever needed, two paths get there, neither
|
|
276
|
+
of them "just add replicas":
|
|
277
|
+
1. **Build the body-aware sticky layer** above (Lua/Envoy consistent-hash on
|
|
278
|
+
the `user` field) in front of N pods, each still `WEB_CONCURRENCY=1`.
|
|
279
|
+
2. **Move the session id into the URL**, the way the web widget transport
|
|
280
|
+
already does (`POST /api/widget/sessions/:token/messages` carries the
|
|
281
|
+
session token in the path) — that surface, unlike `/v1/responses`, *is*
|
|
282
|
+
sticky-routable today with a plain `upstream-hash-by: $request_uri`, no
|
|
283
|
+
custom scripting required.
|
|
284
|
+
|
|
285
|
+
Neither is urgent while a single pod's throughput is enough — this section
|
|
286
|
+
exists so scaling this deployment does not silently reintroduce the same
|
|
287
|
+
cross-session leak the Railway incident above already found once.
|
|
288
|
+
|
|
226
289
|
## Backup / DR — Litestream (opt-in, configurable)
|
|
227
290
|
|
|
228
291
|
A single volume is the **one point of total loss** between a pilot and production
|
|
@@ -297,6 +360,12 @@ per pod + **sticky-by-agent** routing (shard by tenant), or **LiteFS**, or an
|
|
|
297
360
|
optional **Postgres** adapter. **Litestream** (above) for backup/DR from day one —
|
|
298
361
|
orthogonal to topology.
|
|
299
362
|
|
|
363
|
+
For the *session-routing* half specifically — as opposed to the storage
|
|
364
|
+
topology above — see [`insika-router`](ROUTER.md): N pods behind a
|
|
365
|
+
headless `Service`, one `insika-router` Deployment in front doing the
|
|
366
|
+
consistent-hash routing ingress-nginx's own `upstream-hash-by` cannot (it
|
|
367
|
+
hashes nginx variables, never a field parsed out of a POST body).
|
|
368
|
+
|
|
300
369
|
---
|
|
301
370
|
|
|
302
371
|
## Measuring performance / load
|
|
@@ -348,6 +417,8 @@ DEEPSEEK_API_KEY=sk-... ./scripts/loadtest-local.sh 4 24
|
|
|
348
417
|
|
|
349
418
|
## See also
|
|
350
419
|
|
|
420
|
+
- [ROUTER.md](ROUTER.md) — `insika-router`, the session-sticky proxy for
|
|
421
|
+
scaling past one worker.
|
|
351
422
|
- [RUNNING-LOCAL.md](RUNNING-LOCAL.md) — run the engine locally, single-process.
|
|
352
423
|
- [Security](SECURITY.md) — tokens, egress, strict config.
|
|
353
424
|
- [BENCHMARK.md](BENCHMARK.md) — the neutral, key-free engine benchmark.
|
data/docs/EMBEDDING.md
CHANGED
data/docs/EVALS.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Evals
|
|
3
|
-
parent:
|
|
4
|
-
nav_order:
|
|
3
|
+
parent: Improve
|
|
4
|
+
nav_order: 1
|
|
5
5
|
permalink: /evals/
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -157,6 +157,82 @@ Three rules make the number worth quoting:
|
|
|
157
157
|
Cost: **two provider calls per judge per case**, which is why it is opt-in and never
|
|
158
158
|
part of the gate.
|
|
159
159
|
|
|
160
|
+
## Simulated users — conversations the agent never had
|
|
161
|
+
|
|
162
|
+
A golden case fixes the user's turns in advance. Real customers branch — they answer
|
|
163
|
+
the agent's question, or ignore it, or send an order number three messages later. A
|
|
164
|
+
case with frozen turns can only ever test the path the author imagined.
|
|
165
|
+
|
|
166
|
+
A **simulated** case generates the conversation instead: a persona model (the cheap
|
|
167
|
+
platform `utility_model`) plays a customer with the persona as its **whole
|
|
168
|
+
instruction**, and the target agent answers through the same transport a replay uses.
|
|
169
|
+
They talk until the persona's `max_turns`, or until the persona emits a stop marker —
|
|
170
|
+
`<<goal_met>>` when its goal is served, `<<gave_up>>` when it abandons. Both are
|
|
171
|
+
recorded: "gave up at turn 3" is a finding.
|
|
172
|
+
|
|
173
|
+
```yaml
|
|
174
|
+
id: loja-objetivo-difuso
|
|
175
|
+
agent: loja-chocolates
|
|
176
|
+
persona: # the SIMULATED customer (alternative to `turns:`)
|
|
177
|
+
goal: "descobrir o que comprar de presente; não sabe nomes de produto"
|
|
178
|
+
style: "mensagens curtas, responde o que perguntam, desiste se tiver que explicar duas vezes"
|
|
179
|
+
opens_with: "oi, queria um presente"
|
|
180
|
+
knows: # the ONLY facts the persona may assert
|
|
181
|
+
orcamento: "até R$ 100"
|
|
182
|
+
ocasião: "aniversário"
|
|
183
|
+
max_turns: 8
|
|
184
|
+
expect:
|
|
185
|
+
policy: investigate_first
|
|
186
|
+
rubric: |
|
|
187
|
+
Descobre o objetivo antes de recomendar (uma ou duas perguntas, não um formulário),
|
|
188
|
+
recomenda do catálogo real e fecha com um próximo passo claro. Reprova se despejar
|
|
189
|
+
catálogo antes de entender, ou se ficar perguntando sem nunca buscar.
|
|
190
|
+
min_score: 0.7
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**The anti-invention rule is the soul of the feature.** The persona's prompt contains
|
|
194
|
+
only the `knows` facts and the rule that it may assert exactly those and nothing else
|
|
195
|
+
— asked about anything it does not have, it answers with ignorance ("não sei", "não
|
|
196
|
+
tenho isso aqui"), like a real customer without that fact. A simulator that invents an
|
|
197
|
+
order number produces a conversation the agent could never have had, and a case that
|
|
198
|
+
tests nothing.
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
insika evals:simulate --persona persona.yml --target loja-chocolates --staging
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
- `--target <agent|url>` — an agent id over the deployment's `/v1/responses`, or a full
|
|
205
|
+
A2A URL (an agent that only speaks A2A rides the same Simulator through a thin A2A
|
|
206
|
+
transport; it polls once per second, bounded by `--timeout`, since a remote agent
|
|
207
|
+
takes seconds to finish a task).
|
|
208
|
+
- `--staging` or `--eval-profile` — **one is required**. A simulated conversation
|
|
209
|
+
marks its transcript `simulated: true`; it does NOT disarm the agent's tools. So the
|
|
210
|
+
Simulator only runs against (a) a target the operator declares is a staging
|
|
211
|
+
deployment (`--staging`), or (b) an eval profile where the agent's side-effect tools
|
|
212
|
+
are swapped for dry-runs (`--eval-profile`).
|
|
213
|
+
- `--eval-profile` is a **verified declaration, not a trust-me flag**. The CLI derives
|
|
214
|
+
the target's side-effect tools — the deployment's own registry over
|
|
215
|
+
`GET /v1/agents/:id` (`side_effect_tools`), or the local store when `INSIKA_DB`
|
|
216
|
+
points at it — and refuses unless `--eval-tools <a,b,c>` names every one of them.
|
|
217
|
+
The swap itself happens where the tools run (the deployment's eval profile; the
|
|
218
|
+
in-process overlay for a local graph, as in `examples/agent-tester/`); the
|
|
219
|
+
derivation is what keeps the client honest about what must be covered. An A2A target
|
|
220
|
+
has no reachable registry, so the explicit `--eval-tools` list is required there.
|
|
221
|
+
- `--persona-model` — the model playing the customer (default: the platform
|
|
222
|
+
`utility_model`). The judge that scores the transcript comes from the same
|
|
223
|
+
panel configuration as a replay.
|
|
224
|
+
- `--conv` — the conversation id. The default is unique per run
|
|
225
|
+
(`sim-<case id>-<random>`), because two runs on the same conv share the
|
|
226
|
+
deployment session: the second inherits the first's history — including
|
|
227
|
+
across the arms of an A/B comparison — and the grade stops measuring the
|
|
228
|
+
agent. Pass `--conv` only when continuing a session is the point.
|
|
229
|
+
|
|
230
|
+
Every simulated run is **`simulated: true`** — the transcript carries the flag end to
|
|
231
|
+
end, so a report never mixes a generated conversation with real traffic. A simulated
|
|
232
|
+
case (`persona:`) is one of the two shapes of a case; the replay Runner **skips** it
|
|
233
|
+
(the turns do not exist until a Simulator generates them) and only the simulate CLI
|
|
234
|
+
drives it.
|
|
235
|
+
|
|
160
236
|
Cases live in two places, and it is the same YAML in both:
|
|
161
237
|
|
|
162
238
|
- **`evals/golden/**`** in the repo — the curated corpus, reviewable in a pull request,
|
|
@@ -178,6 +254,55 @@ A case whose stored YAML no longer validates is **listed as broken** on the Eval
|
|
|
178
254
|
rather than skipped in silence — a test suite that quietly shrinks is worse than a red
|
|
179
255
|
one.
|
|
180
256
|
|
|
257
|
+
### `run_persona_eval` — a QA agent that tests other agents
|
|
258
|
+
|
|
259
|
+
A tool (`lib/insika/tools/run_persona_eval.rb`), not a CLI: a QA agent picks one of its
|
|
260
|
+
authored simulated cases and runs it **in-process**, against the case's declared
|
|
261
|
+
target agent — the same Simulator + Judge machinery `evals:simulate` drives over HTTP,
|
|
262
|
+
minus the network hop.
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
tools %w[run_persona_eval save_artifact]
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
The model only sees `case_id`, enumerated with the ids the tool can actually run
|
|
269
|
+
(never a free string it could invent) — every **simulated** case in the store, the same
|
|
270
|
+
`persona:` shape as above.
|
|
271
|
+
|
|
272
|
+
**Safety is derived here too, but there is no swap yet.** The tool computes the
|
|
273
|
+
target's reachable side-effect tools (`Evals::EvalProfile`, the same derivation the CLI
|
|
274
|
+
uses) and **refuses outright** — naming the tools — if that list is non-empty. Unlike
|
|
275
|
+
the CLI, nothing here actually swaps a side-effect tool for a dry-run: `Evals::
|
|
276
|
+
EvalProfile.registry` (the overlay) exists for exactly that, but nothing calls it yet.
|
|
277
|
+
So `run_persona_eval` only runs against **read-only** target agents today; wiring the
|
|
278
|
+
overlay into an in-process turn (so a target WITH a write tool can be tested safely) is
|
|
279
|
+
follow-up work, not something this tool claims to do.
|
|
280
|
+
|
|
281
|
+
**Budget**: the persona model + judge model calls are the cost of running the eval,
|
|
282
|
+
charged to the **calling** agent's own turn — never the target's (the target's own
|
|
283
|
+
turns bill normally, through the ordinary edge limiter, exactly as a real customer's
|
|
284
|
+
would). A hard cap on the calling agent skips the run — visibly, in the tool's own
|
|
285
|
+
result (`{skipped: true, reason: "budget", window: "daily"|"monthly"}`) — before a cent
|
|
286
|
+
is spent; a soft cap just runs (the ledger's own alert already warns).
|
|
287
|
+
|
|
288
|
+
Each run gets a **fresh session** (`eval-<case>-<random>`), never a reused one — a
|
|
289
|
+
reused session lets the target agent "remember" earlier runs, which is not what a
|
|
290
|
+
persona case is testing. See `examples/agent-tester/qa_scheduled.rb` for the full
|
|
291
|
+
loop: `schedule` fires the QA agent, it calls `run_persona_eval`, then
|
|
292
|
+
`save_artifact` publishes the quality report.
|
|
293
|
+
|
|
294
|
+
**Tenant isolation.** A golden case carries a `tenant` (`platform` — the
|
|
295
|
+
single-tenant default — unless the case declares one, the same convention
|
|
296
|
+
`save_artifact` uses for its own binding tenant). `run_persona_eval` only ever
|
|
297
|
+
lists or runs cases in the *calling* agent's own tenant: a case authored for
|
|
298
|
+
another tenant is invisible, not merely refused — the model cannot even learn
|
|
299
|
+
its id from the enum, and running it by a guessed id gets the same "unknown or
|
|
300
|
+
invalid" error a nonexistent id would (the two must not be distinguishable).
|
|
301
|
+
This is what makes "one QA agent per store" an actual boundary
|
|
302
|
+
in a deployment where several stores' persona cases live in the same
|
|
303
|
+
`GoldenStore` — without it, `qa-store-a` could enumerate and run
|
|
304
|
+
`qa-store-b`'s persona and read its `knows` in the transcript.
|
|
305
|
+
|
|
181
306
|
## Running
|
|
182
307
|
|
|
183
308
|
```bash
|
|
@@ -232,6 +357,15 @@ That is what you run before merging a prompt, tool or model change. A case with
|
|
|
232
357
|
baseline entry never blocks: it shows as failing in the report, but a brand-new case is
|
|
233
358
|
not a regression.
|
|
234
359
|
|
|
360
|
+
The same gate machinery guards the two automated loops — [Refinement](REFINEMENT.md)
|
|
361
|
+
(cloned-agent replay of a proposed edit) and [Harvest](HARVEST.md) (cloned-agent
|
|
362
|
+
replay of a mined skill). **Judges are mandatory for those gates** in exactly three
|
|
363
|
+
shapes: a gate without a recorded baseline refuses, an all-red
|
|
364
|
+
baseline refuses, and a baseline recorded WITH judge scores, replayed with no judge
|
|
365
|
+
configured, refuses — a rubric'd case with no verdict reads as a pass, so the
|
|
366
|
+
candidate would beat a measurement it never took. A store with no golden cases cannot
|
|
367
|
+
gate, and cannot promote.
|
|
368
|
+
|
|
235
369
|
## Honest limits
|
|
236
370
|
|
|
237
371
|
- **A judge is a model.** It has taste and it has bad days; that is why the
|
|
@@ -271,3 +405,13 @@ needs to score a candidate agent with the same judge, and a second copy of the j
|
|
|
271
405
|
would be the worst possible outcome. It stays a **client** even so: it reaches a running
|
|
272
406
|
deployment over HTTP through `POST /v1/responses` and never reads a store directly.
|
|
273
407
|
`evals/run.rb` is a thin CLI over it.
|
|
408
|
+
|
|
409
|
+
## Parity — the shadow criterion
|
|
410
|
+
|
|
411
|
+
The shadow experiment's rule lives in a **deployment-side criterion file** —
|
|
412
|
+
the path `INSIKA_PARITY_CRITERION` points at, prose a human reads and a `yaml`
|
|
413
|
+
block the machine applies, in one file, so there is exactly one place to edit.
|
|
414
|
+
The file's whole bytes are hashed; every shadow pair records that hash,
|
|
415
|
+
and a window whose pairs disagree produces `:invalid`, never a verdict. Editing
|
|
416
|
+
the criterion mid-experiment is *caught*, not averaged away. The fold itself is
|
|
417
|
+
`lib/insika/parity/*` (see [Channels](CHANNELS.md#shadow-mode)).
|
data/docs/FACTS.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Facts
|
|
3
|
+
parent: Improve
|
|
4
|
+
nav_order: 5
|
|
5
|
+
permalink: /facts/
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Facts — distilled customer memory, human-gated
|
|
9
|
+
|
|
10
|
+
Finished conversations teach a shop things — the customer's size, their budget,
|
|
11
|
+
how they like to pay — and today that knowledge dies with the session. The
|
|
12
|
+
engine can read it back out of the transcripts: **distillation** turns an idle,
|
|
13
|
+
finished customer conversation into a list of proposed facts, a human approves
|
|
14
|
+
or rejects them in the Studio, and an approved fact lands in the customer's
|
|
15
|
+
memory cell — the same cell the `<memory>` block injects on every later turn.
|
|
16
|
+
|
|
17
|
+
The loop has one hard rule: **nothing is ever applied automatically.** Zero
|
|
18
|
+
facts reach the store without a click. Distillation proposes; the operator
|
|
19
|
+
decides; the engine never applies its own proposal.
|
|
20
|
+
|
|
21
|
+
## The loop
|
|
22
|
+
|
|
23
|
+
1. A customer conversation (a session tagged with a `customer`, per
|
|
24
|
+
[Context](CONTEXT.md#memory)) goes idle — nobody has written for the
|
|
25
|
+
configured hours.
|
|
26
|
+
2. The engine's distillation duty picks the session, sends its transcript to
|
|
27
|
+
the platform `utility_model` (off the turn path, on its own worker fiber),
|
|
28
|
+
and the model answers with a JSON list of durable facts: `name`, `value`,
|
|
29
|
+
an optional `confidence`, and the transcript message indexes that support
|
|
30
|
+
the fact (the **evidence**).
|
|
31
|
+
3. The engine filters the answer against a safe subset (no invented scopes, no
|
|
32
|
+
oversized values, no out-of-range evidence), dedups it against the ledger,
|
|
33
|
+
and writes the survivors as **proposals**.
|
|
34
|
+
4. The **Facts** page in the Studio shows the pending proposals with their
|
|
35
|
+
evidence excerpt. The operator **approves** (the fact is written to the
|
|
36
|
+
customer's memory, stamped with its origin), **rejects** (optionally with a
|
|
37
|
+
reason) or **dismisses** (it will never be proposed again).
|
|
38
|
+
5. Approved facts join the customer's memory cell and are injected by the
|
|
39
|
+
Memory provider on the next turn of any session of that customer.
|
|
40
|
+
|
|
41
|
+
## Enabling it — the `distill:` block
|
|
42
|
+
|
|
43
|
+
Distillation is pack data on the agent, exactly like `refinement:` or
|
|
44
|
+
`followup:` — absent = the feature is off for that agent, byte-identical engine:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
agent = Insika.agent("store-support") do
|
|
48
|
+
instructions "…"
|
|
49
|
+
distill enabled: true,
|
|
50
|
+
idle_hours: 6, # how idle a session must be before it distills
|
|
51
|
+
min_messages: 3, # a shorter session distills noise, not facts
|
|
52
|
+
max_proposals: 10 # cap per session pass
|
|
53
|
+
# prompt: "<what counts as a fact for THIS store>" — the pack-authored half;
|
|
54
|
+
# absent = the engine's generic prompt. `model:` (absent = the platform
|
|
55
|
+
# utility_model) can name the distiller explicitly.
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The same keys work in a pack's `agent.config.json`. `idle_hours` /
|
|
60
|
+
`min_messages` / `max_proposals` are per-agent data; `prompt` is the store's
|
|
61
|
+
half — what counts as a fact for a fashion store ("size, preference, budget")
|
|
62
|
+
is not what counts for a logistics one ("address, delivery window, carrier").
|
|
63
|
+
The engine never writes store vocabulary.
|
|
64
|
+
|
|
65
|
+
## The human gate, precisely
|
|
66
|
+
|
|
67
|
+
The **Facts** page (operate group, next to Follow-ups) shows:
|
|
68
|
+
|
|
69
|
+
- **Pending** — oldest first, because evidence ages. Each card is the fact,
|
|
70
|
+
its confidence, its scope (`tenant:customer`), and the evidence excerpt read
|
|
71
|
+
from the transcript at request time (evidence is a link, never a copy). Three
|
|
72
|
+
buttons: **Approve & save to memory**, **Reject** (with an optional reason,
|
|
73
|
+
shown on the card), **Dismiss** (ghost — labelled "will not be proposed
|
|
74
|
+
again").
|
|
75
|
+
- **Stale** — the CAS-lost re-present (below): the proposed value struck
|
|
76
|
+
through next to the operator's current value, resolved by dismissal.
|
|
77
|
+
- **Recent** — every resolved proposal, most recent first, with operator and
|
|
78
|
+
note.
|
|
79
|
+
|
|
80
|
+
**The latch** — a dismissed *or* rejected `(name, value)` tuple is never
|
|
81
|
+
proposed again. The proposal rows ARE the ledger: a human saw that tuple and
|
|
82
|
+
said no, and re-proposing it would train the operator to stop reading. An
|
|
83
|
+
unanswered proposal is never piled on, either. A *different* value for the same
|
|
84
|
+
name is a different tuple — "wears M" dismissed does not block "wears L".
|
|
85
|
+
|
|
86
|
+
**The CAS guarantee** — approval never silently overwrites an operator edit.
|
|
87
|
+
At distill time the engine records the target fact's existence and revision; at
|
|
88
|
+
approve time it writes through the store's optimistic compare-and-swap. A fact
|
|
89
|
+
the operator moved in between flips the proposal to `stale` with both values
|
|
90
|
+
visible — the operator's edit always wins, never a silent overwrite.
|
|
91
|
+
|
|
92
|
+
## Provenance
|
|
93
|
+
|
|
94
|
+
An approved fact is written with `origin: "distilled:<session_ref>"` — the
|
|
95
|
+
The provenance discipline: `"engine"` (the `remember` tool), `"operator"`
|
|
96
|
+
(Studio edits), `"legacy"`, and `"distilled"` (+ the session that produced it).
|
|
97
|
+
The Closed loop reads the same cell every later turn injects, and the
|
|
98
|
+
distillation ledger suppresses re-proposing a fact that is already applied with
|
|
99
|
+
a `distilled:` origin.
|
|
100
|
+
|
|
101
|
+
## LGPD
|
|
102
|
+
|
|
103
|
+
Distilled facts are personal data, and the engine treats them like it:
|
|
104
|
+
|
|
105
|
+
- **Forget a customer** — `forget_customer` purges the customer's proposals
|
|
106
|
+
(every status) along with their memory cell and sessions.
|
|
107
|
+
- **Delete a tenant** — `delete_tenant_data` purges the tenant's proposals.
|
|
108
|
+
- **Retention** — proposals age out under the same `retention_days` sweep as
|
|
109
|
+
the rest of the footprint (pending included — a proposal is evidence of a
|
|
110
|
+
transcript, and when the transcript dies the pending fact is stale). The
|
|
111
|
+
session marker dies with its proposals: **an unreviewed proposal that ages
|
|
112
|
+
out is expired, not lost-locked** — the session is re-distillable, and a
|
|
113
|
+
duplicate survivor is filtered by the ledger, never applied.
|
|
114
|
+
|
|
115
|
+
## The honest limits
|
|
116
|
+
|
|
117
|
+
- **Best-effort extraction, re-scan recovery.** There is no distillation
|
|
118
|
+
queue. A crash mid-pass leaves the session unmarked; the next pass re-scans
|
|
119
|
+
it, and the ledger filters any duplicate proposal. Exactly-once is not
|
|
120
|
+
claimed — facts are re-derivable, and a duplicate is filtered, never applied.
|
|
121
|
+
- **Precision is a forge audit.** The engine guarantees the *gates* (schema,
|
|
122
|
+
dedup, CAS, human approval); it cannot guarantee the *model's judgment*.
|
|
123
|
+
"Is this fact true and durable?" is audited on real traffic, per store — the
|
|
124
|
+
pack prompt is where that judgment is tuned.
|
|
125
|
+
- **Scope comes from the session, never the model.** The proposal's landing
|
|
126
|
+
cell is assembled by the engine from the session's tenant and customer; the
|
|
127
|
+
schema rejects a model-authored scope outright (a cross-tenant escape), and
|
|
128
|
+
an untagged session is never distilled.
|
|
129
|
+
- **Sessions only.** Distillation targets customer-tagged sessions; a
|
|
130
|
+
session without a customer has no landing zone and is skipped.
|
|
131
|
+
- **One stamp, once.** A session receives `vars["agent"]` on the same write
|
|
132
|
+
that stamps its `customer` — the first tagged turn. The stamp is
|
|
133
|
+
idempotent: a session that already carries a `customer` (a pre-upgrade
|
|
134
|
+
conversation, or one that since moved to this agent) never gains `agent`
|
|
135
|
+
on its own, so it can never participate in distillation.
|
data/docs/HARVEST.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Harvest
|
|
3
|
+
parent: Improve
|
|
4
|
+
nav_order: 6
|
|
5
|
+
permalink: /harvest/
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Harvest — skills from real traffic, promoted only if eval AND conversion hold
|
|
9
|
+
|
|
10
|
+
The harvest is the loop that makes a store smarter with its own traffic: it
|
|
11
|
+
reads the store's **finished** conversations, asks a model to propose SKILLS
|
|
12
|
+
for the agent's playbook, filters every proposal through two pre-registered
|
|
13
|
+
lists (the negative list, the evidence ledger), scores it with a double gate,
|
|
14
|
+
and — **only after a human approves** — lands it as a live skill. Nothing is
|
|
15
|
+
ever applied automatically, and a skill that fails the gate is terminal: the
|
|
16
|
+
same finding must re-surface with new evidence, there is no silent retry.
|
|
17
|
+
|
|
18
|
+
Per-store data (`harvest:` on the agent), absent = the loop is off for that
|
|
19
|
+
agent:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
harvest enabled: true,
|
|
23
|
+
negative_list: [ { rule: "no-competitor-prices", pattern: "concorrente" } ],
|
|
24
|
+
miner: { model: "deepseek-v4-flash", # absent = the platform utility_model
|
|
25
|
+
window: { last_sessions: 200 },
|
|
26
|
+
max_proposals: 10,
|
|
27
|
+
budget: { tokens: 100_000 } },
|
|
28
|
+
idle_hours: 24, # how idle a session must be before it mines
|
|
29
|
+
min_messages: 3 # a shorter session mines noise
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## The pass
|
|
33
|
+
|
|
34
|
+
A mine reads ONLY durable, finished data — never an in-flight turn, never a
|
|
35
|
+
live prompt prefix (the "fork" is structural: the mining writes nothing to the
|
|
36
|
+
sessions it read, so a customer turn's cache is untouched by construction). The
|
|
37
|
+
miner gets the transcript slices (masked), the session's evidence-ledger ids,
|
|
38
|
+
and the agent's current skill names so it does not re-propose them. Then the
|
|
39
|
+
filters, each drop counted and logged:
|
|
40
|
+
|
|
41
|
+
1. **The negative list** — the versioned rules file (seed, imported
|
|
42
|
+
per store by `insika harvest:negative import --agent ID --file F`) and the
|
|
43
|
+
profile's hot-editable `harvest.negative_list`. A rule is a phrase or a
|
|
44
|
+
regex; phrases match case/accent-folded at word boundaries. Every rejected
|
|
45
|
+
candidate is logged with the rule id.
|
|
46
|
+
2. **The grounding filter** — every product reference in a proposal must be
|
|
47
|
+
in the union of the origin sessions' evidence ids (the evidence ledger).
|
|
48
|
+
A store without `grounding.matcher.sku` does not mine at all: product
|
|
49
|
+
claims that cannot be verified are blocked by refusal, not by prompt.
|
|
50
|
+
3. **Dedup** — an open `(agent, name)` tuple or a skill the store already has.
|
|
51
|
+
|
|
52
|
+
## The double gate
|
|
53
|
+
|
|
54
|
+
A candidate that survives mining is scored by RUNNING it — the eval gate
|
|
55
|
+
clones the agent, writes the candidate skill into the clone's agent-scoped
|
|
56
|
+
store, enables it on the clone's allowlist, replays the golden set over the
|
|
57
|
+
ordinary public surface and compares to the accepted baseline. **Any
|
|
58
|
+
regression disqualifies** — the gate is a veto, never a score to argue with.
|
|
59
|
+
Judges are mandatory in exactly the shapes the refinement gate already
|
|
60
|
+
refuses: no recorded baseline, an all-red baseline, and a judged baseline
|
|
61
|
+
replayed with no judge (a rubric'd case with no verdict would count as a
|
|
62
|
+
pass, see [Evals](EVALS.md)).
|
|
63
|
+
|
|
64
|
+
The conversion gate is the second ruler: the store's funnel metric over the
|
|
65
|
+
criterion's window, compared to the **frozen baseline** (`freeze_funnel_baseline`). Outcome is evidence — this gate can only say "the
|
|
66
|
+
store is measurably worse than the accepted state" or "there is nothing to
|
|
67
|
+
compare against". It refuses on missing data, never passes: no frozen
|
|
68
|
+
baseline, no criterion, no funnel store, a fold that has not converged — each
|
|
69
|
+
named on the Harvest page.
|
|
70
|
+
|
|
71
|
+
Both passed → the candidate awaits **a human**. Promotion is snapshot-first
|
|
72
|
+
(the pre-promotion content + allowlist), then the two existing write commands,
|
|
73
|
+
then an append-only log row carrying `skill`, `origin`, `eval_ref`,
|
|
74
|
+
`conversion_ref`, `approver`, `snapshot_ref` and the criterion's sha. The
|
|
75
|
+
conversion ruler is re-read at the moment the skill lands — a store that
|
|
76
|
+
dipped below its frozen baseline since gating parks the promotion with the
|
|
77
|
+
current numbers, and a criterion file that changed since boot is a criterion
|
|
78
|
+
nobody froze.
|
|
79
|
+
|
|
80
|
+
## Rollback
|
|
81
|
+
|
|
82
|
+
One click, deterministic: the snapshot is restored — `WriteSkill` with the
|
|
83
|
+
pre-promotion bytes (or `DeleteSkill` when the skill did not exist), the
|
|
84
|
+
allowlist restored to the snapshot's set, and the promotion row stamped
|
|
85
|
+
`rolled_back_at`. The log stays the single ledger: a skill promoted, rolled
|
|
86
|
+
back, re-promoted is three readable rows.
|
|
87
|
+
|
|
88
|
+
## The honest limits
|
|
89
|
+
|
|
90
|
+
- **A store with no golden cases cannot gate, and cannot promote.** The gate's
|
|
91
|
+
strength is entirely the golden set.
|
|
92
|
+
- **Grounding first.** A store without a matcher does not mine — the product
|
|
93
|
+
loop is blocked until the evidence ledger is live (by refusal, not by
|
|
94
|
+
warning).
|
|
95
|
+
- **A promoted skill is live text**, like any skill: the SkillStore's version
|
|
96
|
+
history plus the harvest snapshot plus the log make the rollback path
|
|
97
|
+
deterministic.
|
|
98
|
+
- **The first-10 audit is human.** The negative list can carry a false
|
|
99
|
+
restriction (a phrase too broad) — that is exactly the Hermes failure, and
|
|
100
|
+
it is caught by the audit, not by the engine: the list grows and the loop
|
|
101
|
+
stops (`harvest.enabled: false`), both by data.
|
|
102
|
+
- **The harvest costs provider money**: one miner call per window plus one
|
|
103
|
+
full golden replay per gated candidate. The `budget` cap, the recorded run
|
|
104
|
+
cost and the manual trigger bound it; the automated loop mines one session
|
|
105
|
+
per claim window.
|
|
106
|
+
|
|
107
|
+
## The operator surface
|
|
108
|
+
|
|
109
|
+
- `insika harvest --agent ID [--last-sessions N] [--since ISO] [--full]` — mine one window.
|
|
110
|
+
- `insika harvest:negative import --agent ID --file F` — seed the profile's list from a rules file.
|
|
111
|
+
- `insika harvest:criterion check --file F` — strict-load the frozen conversion criterion (the hook before any promotion).
|
|
112
|
+
- The **Harvest page** in the Studio: the human's inbox (each candidate with
|
|
113
|
+
its evidence excerpt, the eval report, the conversion card, promote/reject),
|
|
114
|
+
the gated-but-blocked rows with the named ruler hole, the pending list, the
|
|
115
|
+
append-only promoted log with the rollback mirror, the negative list with
|
|
116
|
+
per-rule rejection counts, and the criterion block read-only.
|