insika 0.3.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 +180 -0
- data/README.md +45 -10
- data/bin/insika +684 -0
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +94 -403
- data/docs/API.md +5 -5
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +14 -14
- data/docs/CONTEXT.md +9 -7
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +71 -3
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +128 -3
- data/docs/FACTS.md +3 -3
- data/docs/HARVEST.md +5 -6
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +2 -2
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +15 -10
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +14 -9
- data/docs/RELEASING.md +4 -4
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +3 -3
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +22 -6
- data/docs/SKILLS.md +11 -2
- data/docs/SOAK.md +2 -2
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +152 -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 +4 -4
- data/docs/improve.md +20 -0
- data/docs/index.md +8 -5
- 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 +73 -16
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- data/lib/insika/channel_delivery.rb +1 -1
- data/lib/insika/chat_builder.rb +22 -2
- data/lib/insika/commands/agent_payload.rb +2 -2
- data/lib/insika/commands/backfill_knowledge.rb +145 -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 +15 -3
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/restore_concept.rb +34 -0
- data/lib/insika/commands/seed_demo_data.rb +31 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -0
- data/lib/insika/context/priority.rb +2 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/prompt.rb +30 -24
- 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/doctor.rb +176 -8
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +60 -79
- data/lib/insika/dsl/server_boot.rb +23 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +103 -2
- data/lib/insika/env_schema.rb +16 -1
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +11 -0
- 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/executor.rb +231 -55
- data/lib/insika/followup_policy.rb +2 -25
- data/lib/insika/golden_store.rb +16 -1
- data/lib/insika/grounding/matcher.rb +1 -1
- 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 +115 -31
- data/lib/insika/message_origin.rb +1 -1
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/onboarding.rb +17 -1
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +37 -17
- data/lib/insika/packaging.rb +2 -2
- data/lib/insika/profile_source.rb +8 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- data/lib/insika/retention.rb +36 -1
- 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/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/server/app.rb +105 -15
- data/lib/insika/server/rack_app.rb +5 -1
- data/lib/insika/server/responses.rb +1 -1
- data/lib/insika/skill_catalog.rb +12 -0
- data/lib/insika/steer_injector.rb +21 -10
- data/lib/insika/studio/app.rb +567 -45
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +21 -21
- data/lib/insika/studio/forms.rb +46 -5
- data/lib/insika/studio/nav_icons.rb +14 -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 +61 -820
- data/lib/insika/studio/views/agents.erb +70 -57
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- data/lib/insika/studio/views/evals.erb +2 -2
- data/lib/insika/studio/views/facts.erb +1 -1
- data/lib/insika/studio/views/funnel.erb +1 -1
- data/lib/insika/studio/views/home.erb +106 -67
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +14 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- data/lib/insika/studio/views/session.erb +231 -177
- data/lib/insika/studio/views/settings.erb +39 -1
- data/lib/insika/studio/views/skills.erb +1 -1
- data/lib/insika/studio/views/tools.erb +24 -9
- 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/tick.rb +24 -12
- data/lib/insika/timezone.rb +45 -0
- data/lib/insika/tools/generate_image.rb +52 -7
- 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/turn_output.rb +1 -1
- data/lib/insika/turn_state.rb +15 -4
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +184 -12
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +57 -0
- metadata +105 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Context
|
|
5
|
+
module Providers
|
|
6
|
+
# Level 1 (progressive disclosure) of what the engine has LEARNED, as
|
|
7
|
+
# opposed to what a human curated (Skill) or the model wrote mid-turn
|
|
8
|
+
# (Memory). Retrieval is per-message and dynamic, so — like
|
|
9
|
+
# `SkillTrigger`, unlike the static `CatalogProvider` subclasses — this
|
|
10
|
+
# builds its `<knowledge>` block directly in `call`, never a fixed list.
|
|
11
|
+
class Knowledge < ContextProvider
|
|
12
|
+
def initialize(store:)
|
|
13
|
+
@store = store
|
|
14
|
+
# One Index PER TYPE, built once and reused for every agent/turn —
|
|
15
|
+
# never per call. This provider instance itself lives for the
|
|
16
|
+
# process's lifetime (built once at boot, see wiring), so an
|
|
17
|
+
# Index rebuilt fresh each call would throw away its own read
|
|
18
|
+
# cache (Index::Scan's dominant cost is re-parsing YAML
|
|
19
|
+
# frontmatter; measured, not assumed) on every single turn.
|
|
20
|
+
# Keyed by the config's `index` string so a future FTS5 agent
|
|
21
|
+
# gets its own instance, never Scan's.
|
|
22
|
+
@indexes = Hash.new { |h, index_name| h[index_name] = Insika::Knowledge::Index.build({ "index" => index_name }, store: @store) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Per-agent opt-in (`knowledge.retrieve`), like Memory's `profile.memory`.
|
|
26
|
+
def enabled_for?(profile) = !!(profile.knowledge && Coercion.truthy?(profile.knowledge["retrieve"]))
|
|
27
|
+
|
|
28
|
+
# required? == false (default): a store failure degrades to a
|
|
29
|
+
# :provider_warning, never aborts the turn.
|
|
30
|
+
def call(request)
|
|
31
|
+
config = request.profile.knowledge
|
|
32
|
+
return [] unless config
|
|
33
|
+
|
|
34
|
+
top_k = positive_int(config["top_k"]) || 5
|
|
35
|
+
index = @indexes[config["index"].to_s]
|
|
36
|
+
matches = index.search(request.profile.id, tenant: request.tenant,
|
|
37
|
+
query: request.message.to_s, top_k: top_k)
|
|
38
|
+
return [] if matches.empty?
|
|
39
|
+
|
|
40
|
+
hits = matches.map { |c| [c, "top-K match"] } +
|
|
41
|
+
expand_links(matches, request, top_k).map { |c| [c, "one-hop link"] }
|
|
42
|
+
|
|
43
|
+
[ContextFragment.build(
|
|
44
|
+
content: format_block(hits), placement: :system,
|
|
45
|
+
priority: Context::Priority::KNOWLEDGE, source: id,
|
|
46
|
+
labels: hits.map { |c, reason| { "name" => c[:name], "reason" => reason } }
|
|
47
|
+
)]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def positive_int(value)
|
|
53
|
+
n = value.to_i
|
|
54
|
+
n.positive? ? n : nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# ONE level, deliberately — the same "cannot work without" reasoning
|
|
58
|
+
# `SkillTrigger#companions` applies to a skill's declared companions:
|
|
59
|
+
# a transitive walk would make a cycle a hang and a chain a budget
|
|
60
|
+
# blowout. Newly-discovered concepts (not already in the top-K) are
|
|
61
|
+
# capped at top_k again — "that one hop is the whole graph benefit at
|
|
62
|
+
# ~1% of the graph cost", not a second unbounded retrieval.
|
|
63
|
+
def expand_links(matches, request, top_k)
|
|
64
|
+
known = matches.map { |c| c[:name] }
|
|
65
|
+
discovered = []
|
|
66
|
+
matches.each do |concept|
|
|
67
|
+
Insika::Knowledge::Concept.links(concept[:body]).each do |name|
|
|
68
|
+
next if known.include?(name) || discovered.any? { |d| d[:name] == name }
|
|
69
|
+
|
|
70
|
+
found = fetch(request, name)
|
|
71
|
+
discovered << found if found
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
discovered.first(top_k)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def fetch(request, name)
|
|
78
|
+
raw = @store.get(request.profile.id, name, tenant: request.tenant)
|
|
79
|
+
raw && Insika::Knowledge::Concept.parse(raw)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Level 1 only — name/description/confidence/provenance, never the
|
|
83
|
+
# body (that is `load_knowledge`'s job). The instruction is the exact
|
|
84
|
+
# lesson the knowledge-adoption experiment drew: a polite "when to
|
|
85
|
+
# use" scored near zero; an explicit, ordered rule naming the tool
|
|
86
|
+
# held up. Present only when there is something to point at.
|
|
87
|
+
def format_block(hits)
|
|
88
|
+
entries = hits.map do |c, _reason|
|
|
89
|
+
%( <concept name="#{c[:name]}" confidence="#{format('%.2f', c[:confidence])}" ) +
|
|
90
|
+
%(provenance="#{c[:provenance]}">#{c[:description]}</concept>)
|
|
91
|
+
end.join("\n")
|
|
92
|
+
|
|
93
|
+
<<~BLOCK.strip
|
|
94
|
+
<knowledge>
|
|
95
|
+
#{entries}
|
|
96
|
+
</knowledge>
|
|
97
|
+
|
|
98
|
+
If the customer's question needs more than the summary above, call
|
|
99
|
+
`load_knowledge("name")` FIRST — before any other lookup for that
|
|
100
|
+
topic. This is learned from past conversations, not official
|
|
101
|
+
policy: never state a `provenance="observed"` concept to the
|
|
102
|
+
customer as a guarantee.
|
|
103
|
+
BLOCK
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -8,12 +8,16 @@ module Insika
|
|
|
8
8
|
# a WRONG agent, not a degraded one. prompt_refs: priority 90 pinned
|
|
9
9
|
# fragments, from the PromptCatalog (catalog defaults to nil).
|
|
10
10
|
#
|
|
11
|
-
# PER-AGENT identity
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# (
|
|
15
|
-
#
|
|
16
|
-
# `
|
|
11
|
+
# PER-AGENT identity, from `profile.prompt_files`/`base_prompt` only — an
|
|
12
|
+
# agent that declares neither has no identity here, and there is no
|
|
13
|
+
# deployment-wide fallback file to borrow one from. A shared fallback
|
|
14
|
+
# (a prior design: an agent without prompt_files silently inherited a
|
|
15
|
+
# wiring-level default) is exactly how a real deployment answered as the
|
|
16
|
+
# WRONG business: a `copilot` data agent provisioned without its own
|
|
17
|
+
# identity inherited the deployment's demo persona ("Bia, Pizzaria do
|
|
18
|
+
# Zé") byte for byte, confirmed live. An agent never wears another
|
|
19
|
+
# agent's identity — `@base`/`system_files` stay legitimate (the same
|
|
20
|
+
# generic content for every agent, never one agent's specific persona).
|
|
17
21
|
class Prompt < ContextProvider
|
|
18
22
|
# Engine-owned execution discipline, appended AFTER the agent's identity.
|
|
19
23
|
# The one behavior every reference harness bakes into its base prompt
|
|
@@ -28,11 +32,12 @@ module Insika
|
|
|
28
32
|
"query, use a synonym or broader term, drop a secondary filter — before telling " \
|
|
29
33
|
"the user you found nothing. Do not narrate the retries. Then conclude.\n" \
|
|
30
34
|
"- Tool error: read the error, fix the arguments or try another path; never " \
|
|
31
|
-
"repeat the exact same call
|
|
35
|
+
"repeat the exact same call.\n" \
|
|
36
|
+
"- A URL in a tool result (e.g. a `url` field): quote it byte-for-byte. Never " \
|
|
37
|
+
"construct, guess, or rewrite the domain, host, or path."
|
|
32
38
|
|
|
33
|
-
def initialize(base: "",
|
|
39
|
+
def initialize(base: "", catalog: nil, agent_files: nil, system_files: nil)
|
|
34
40
|
@base = base
|
|
35
|
-
@files = Array(files)
|
|
36
41
|
@catalog = catalog
|
|
37
42
|
@agent_files = agent_files
|
|
38
43
|
@system_files = system_files
|
|
@@ -43,12 +48,18 @@ module Insika
|
|
|
43
48
|
def layer = :identity
|
|
44
49
|
|
|
45
50
|
def call(request)
|
|
46
|
-
fragments = []
|
|
47
51
|
identity = build_identity(request.profile)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
if identity.empty?
|
|
53
|
+
raise ContextError.new(
|
|
54
|
+
"agent '#{request.profile&.id}' has no identity of its own (no base_prompt, " \
|
|
55
|
+
"no prompt_files) — refusing to run rather than answer with no identity or " \
|
|
56
|
+
"another agent's",
|
|
57
|
+
provider: id
|
|
58
|
+
)
|
|
51
59
|
end
|
|
60
|
+
|
|
61
|
+
fragments = [ContextFragment.build(content: identity, placement: :system,
|
|
62
|
+
priority: Context::Priority::IDENTITY, source: id, pinned: true)]
|
|
52
63
|
fragments.concat(ref_fragments(request.profile))
|
|
53
64
|
fragments
|
|
54
65
|
end
|
|
@@ -59,10 +70,9 @@ module Insika
|
|
|
59
70
|
# A SINGLE fragment preserves the internal base->files order (sorting
|
|
60
71
|
# acts only BETWEEN fragments) and guarantees byte-for-byte parity.
|
|
61
72
|
#
|
|
62
|
-
# profile.prompt_files
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
# order. Without prompt_files, falls back to the wiring's @files.
|
|
73
|
+
# profile.prompt_files: each source resolves via AgentFileStore (per
|
|
74
|
+
# agent) OR File.read (on-disk path) — in that order. No other agent's
|
|
75
|
+
# files are ever read for this one; there is no wiring-level fallback.
|
|
66
76
|
def build_identity(profile)
|
|
67
77
|
parts = [@base]
|
|
68
78
|
parts.concat(system_parts) # global system files, for EVERY agent
|
|
@@ -73,15 +83,11 @@ module Insika
|
|
|
73
83
|
# NO identity at all, and a chatty model answered plausibly enough to hide
|
|
74
84
|
# it. Additive to prompt_files, not exclusive: an agent may carry both.
|
|
75
85
|
parts << profile&.base_prompt.to_s
|
|
76
|
-
|
|
77
|
-
if sources.empty?
|
|
78
|
-
@files.each { |f| parts << File.read(f, encoding: "UTF-8") if File.exist?(f) }
|
|
79
|
-
else
|
|
80
|
-
sources.each { |src| parts << read_source(profile&.id, src.to_s) }
|
|
81
|
-
end
|
|
86
|
+
Array(profile&.prompt_files).each { |src| parts << read_source(profile&.id, src.to_s) }
|
|
82
87
|
identity = parts.reject { |p| p.nil? || p.strip.empty? }.join("\n\n")
|
|
83
88
|
# Discipline rides an EXISTING identity, never substitutes one: an
|
|
84
|
-
#
|
|
89
|
+
# empty identity here is fatal (`call` raises), not silently patched
|
|
90
|
+
# with the engine's own boilerplate.
|
|
85
91
|
return identity if identity.empty? || !tool_persistence?(profile)
|
|
86
92
|
|
|
87
93
|
"#{identity}\n\n#{TOOL_PERSISTENCE}"
|
data/lib/insika/cron.rb
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# A cron expression, parsed. The engine's documented subset:
|
|
5
|
+
#
|
|
6
|
+
# minute hour day-of-month month day-of-week (5 fields, whitespace-separated)
|
|
7
|
+
#
|
|
8
|
+
# Per field: `*`, a single value, a range (N-M), a step (`*/N`, `N-M/N`,
|
|
9
|
+
# `N/N`), or a comma list of those. `?` and `*` both mean "any". `L`, `W`,
|
|
10
|
+
# `#` and month/day NAMES are NOT — the engine refuses them loudly at
|
|
11
|
+
# creation, never silently dropping a cron that only some dates understand.
|
|
12
|
+
#
|
|
13
|
+
# Day-of-week: 0-7, 7 == 0 (Sunday). When BOTH day fields are restricted, a
|
|
14
|
+
# date matches on EITHER (standard cron OR semantics).
|
|
15
|
+
#
|
|
16
|
+
# `next_after(time)` returns the first occurrence STRICTLY after `time`,
|
|
17
|
+
# materialized in the schedule's tz (returned as a UTC Time), or nil when
|
|
18
|
+
# the expression can never fire within 400 years (`0 0 31 2 *`). The bound
|
|
19
|
+
# keeps a syntactic-but-unreachable expression from hanging a pass.
|
|
20
|
+
class Cron
|
|
21
|
+
MONTHS_31 = [1, 3, 5, 7, 8, 10, 12].freeze
|
|
22
|
+
|
|
23
|
+
# One field: the sorted accepted values + whether it was a bare "*"
|
|
24
|
+
# (the day-of-week OR rule needs to know which fields are restricted).
|
|
25
|
+
class Field
|
|
26
|
+
def initialize(expression, range, field:)
|
|
27
|
+
@field = field
|
|
28
|
+
@range = range
|
|
29
|
+
@star = expression == "*"
|
|
30
|
+
@values = expression.split(",").flat_map { |term| parse_term(term) }.uniq.sort
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def star? = @star
|
|
34
|
+
|
|
35
|
+
def matches?(value) = @values.include?(value)
|
|
36
|
+
|
|
37
|
+
# -> Integer | nil: the smallest accepted value >= start (nil when none).
|
|
38
|
+
def at_or_after(start) = @values.find { |v| v >= start }
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def parse_term(term)
|
|
43
|
+
head, step = term.include?("/") ? term.split("/", 2) : [term, nil]
|
|
44
|
+
step = parse_step(step) if step
|
|
45
|
+
base = parse_base(head)
|
|
46
|
+
base = base.each_with_index.filter_map { |v, i| v if (i % step).zero? } if step
|
|
47
|
+
base.each { |v| validate(v) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse_step(raw)
|
|
51
|
+
n = Integer(raw)
|
|
52
|
+
raise Insika::ValidationError, "cron #{@field} step must be a positive integer, got #{raw.inspect}" unless n.positive?
|
|
53
|
+
|
|
54
|
+
n
|
|
55
|
+
rescue ArgumentError
|
|
56
|
+
raise Insika::ValidationError,
|
|
57
|
+
"cron #{@field} step must be a positive integer, got #{raw.inspect}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def parse_base(head)
|
|
61
|
+
if head.empty? || head == "?" || head == "*"
|
|
62
|
+
@range.to_a
|
|
63
|
+
elsif head.include?("-")
|
|
64
|
+
lo, hi = head.split("-", 2).map { |s| value(s) }
|
|
65
|
+
raise Insika::ValidationError, "cron #{@field} range reversed: #{head.inspect}" if lo > hi
|
|
66
|
+
|
|
67
|
+
(lo..hi).to_a
|
|
68
|
+
else
|
|
69
|
+
[value(head)]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def value(raw)
|
|
74
|
+
v = Integer(raw)
|
|
75
|
+
v = 0 if @field == :dow && v == 7 # Sunday, the standard second spelling
|
|
76
|
+
v
|
|
77
|
+
rescue ArgumentError
|
|
78
|
+
raise Insika::ValidationError,
|
|
79
|
+
"cron #{@field} has an unparseable value: #{raw.inspect}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate(v)
|
|
83
|
+
return if @range.cover?(v)
|
|
84
|
+
|
|
85
|
+
raise Insika::ValidationError,
|
|
86
|
+
"cron #{@field} value #{v} out of range (#{@range.inspect})"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
attr_reader :expression
|
|
91
|
+
|
|
92
|
+
def initialize(expression)
|
|
93
|
+
@expression = expression.to_s.strip
|
|
94
|
+
fields = @expression.split(/\s+/)
|
|
95
|
+
if fields.size != 5
|
|
96
|
+
raise Insika::ValidationError,
|
|
97
|
+
"cron expression must have 5 fields (minute hour dom month dow), " \
|
|
98
|
+
"got #{fields.size}: #{@expression.inspect}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
minute, hour, dom, month, dow = fields
|
|
102
|
+
@minute = Field.new(minute, 0..59, field: :minute)
|
|
103
|
+
@hour = Field.new(hour, 0..23, field: :hour)
|
|
104
|
+
@dom = Field.new(dom, 1..31, field: :dom)
|
|
105
|
+
@month = Field.new(month, 1..12, field: :month)
|
|
106
|
+
@dow = Field.new(dow, 0..6, field: :dow)
|
|
107
|
+
freeze
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# -> Time (UTC) | nil: the first instant strictly after `time` whose
|
|
111
|
+
# wall-clock in `tz` matches. nil = the expression cannot fire within 400y.
|
|
112
|
+
def next_after(time, tz: "Etc/UTC")
|
|
113
|
+
Timezone.in_zone(tz, time) do
|
|
114
|
+
local = time.getlocal
|
|
115
|
+
cur = Cursor.new(local.year, local.month, local.day, local.hour, local.min)
|
|
116
|
+
advance_minute!(cur) # strictly after `time`
|
|
117
|
+
limit = cur.y + 400
|
|
118
|
+
while cur.y <= limit
|
|
119
|
+
if @month.matches?(cur.mo) && day_match?(cur)
|
|
120
|
+
if @hour.matches?(cur.h) && (mm = @minute.at_or_after(cur.mi))
|
|
121
|
+
return Time.local(cur.y, cur.mo, cur.d, cur.h, mm)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
nh = @hour.at_or_after(cur.h + 1)
|
|
125
|
+
if nh && (mm = @minute.at_or_after(0))
|
|
126
|
+
return Time.local(cur.y, cur.mo, cur.d, nh, mm)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
advance_day!(cur)
|
|
130
|
+
cur.h = 0
|
|
131
|
+
cur.mi = 0
|
|
132
|
+
end
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# A date matches when a restricted day-of-month OR day-of-week accepts it
|
|
140
|
+
# (standard cron); a bare "*" on one side drops that side.
|
|
141
|
+
def day_match?(cur)
|
|
142
|
+
dom = @dom.matches?(cur.d)
|
|
143
|
+
dow = @dow.matches?(Time.utc(cur.y, cur.mo, cur.d).wday)
|
|
144
|
+
if @dom.star? && @dow.star?
|
|
145
|
+
true
|
|
146
|
+
elsif @dom.star?
|
|
147
|
+
dow
|
|
148
|
+
elsif @dow.star?
|
|
149
|
+
dom
|
|
150
|
+
else
|
|
151
|
+
dom || dow
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
Cursor = Struct.new(:y, :mo, :d, :h, :mi)
|
|
156
|
+
|
|
157
|
+
def advance_minute!(cur)
|
|
158
|
+
cur.mi += 1
|
|
159
|
+
if cur.mi > 59
|
|
160
|
+
cur.mi = 0
|
|
161
|
+
cur.h += 1
|
|
162
|
+
advance_day!(cur) if cur.h > 23
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def advance_day!(cur)
|
|
167
|
+
cur.d += 1
|
|
168
|
+
return if cur.d <= days_in_month(cur.y, cur.mo)
|
|
169
|
+
|
|
170
|
+
cur.d = 1
|
|
171
|
+
cur.mo += 1
|
|
172
|
+
if cur.mo > 12
|
|
173
|
+
cur.mo = 1
|
|
174
|
+
cur.y += 1
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def days_in_month(y, mo)
|
|
179
|
+
return 29 if mo == 2 && leap?(y)
|
|
180
|
+
return 28 if mo == 2
|
|
181
|
+
|
|
182
|
+
MONTHS_31.include?(mo) ? 31 : 30
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def leap?(y)
|
|
186
|
+
y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Demo
|
|
5
|
+
# The one agent the demo seed provisions: a fictional e-commerce
|
|
6
|
+
# support agent that declares BOTH a funnel and a follow-up policy, which
|
|
7
|
+
# nothing shipped in examples/ does (Funnel/Followups render an empty state
|
|
8
|
+
# without a declaring profile — see AgentProfile#funnel/#followup). Model
|
|
9
|
+
# is deliberately nil: a demo about seeded DATA needs no live LLM
|
|
10
|
+
# credentials to be useful, and AgentProfile.build resolves a missing
|
|
11
|
+
# model against the platform default at turn time (never at seed time).
|
|
12
|
+
AGENT_ID = "demo-store"
|
|
13
|
+
|
|
14
|
+
AGENT_ATTRS = {
|
|
15
|
+
id: AGENT_ID,
|
|
16
|
+
base_prompt: <<~PROMPT.strip,
|
|
17
|
+
You are the support agent for Demo Shop, a fictional online store used
|
|
18
|
+
to explore the Insika Studio. Help customers find products, track
|
|
19
|
+
orders and answer questions about shipping and returns.
|
|
20
|
+
PROMPT
|
|
21
|
+
memory: true,
|
|
22
|
+
metadata: { "demo" => true },
|
|
23
|
+
funnel: {
|
|
24
|
+
"stages" => %w[greeted browsing cart_started checkout_started purchased],
|
|
25
|
+
"advance_on" => {
|
|
26
|
+
"greeted" => "greeted", "browsing" => "browsing",
|
|
27
|
+
"cart_started" => "cart_started", "checkout_started" => "checkout_started",
|
|
28
|
+
"purchased" => "purchased"
|
|
29
|
+
},
|
|
30
|
+
"primary" => "purchased",
|
|
31
|
+
"attribution_window" => "72h"
|
|
32
|
+
},
|
|
33
|
+
followup: {
|
|
34
|
+
"arm" => "nudge",
|
|
35
|
+
"policy" => {
|
|
36
|
+
"max_frequency" => "2/24h",
|
|
37
|
+
"cancel_keywords" => ["stop contacting me"],
|
|
38
|
+
"silence_after_sends" => 3
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}.freeze
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Demo
|
|
5
|
+
# Golden cases for the demo agent (AGENT_ID), shaped exactly like the
|
|
6
|
+
# evals/golden/**/*.yml corpus (GoldenStore#write validates through the
|
|
7
|
+
# same Evals::GoldenLoader) — bundled here, not on disk, because
|
|
8
|
+
# evals/golden/ never ships in the gem (Packaging.payload_path?).
|
|
9
|
+
GOLDEN_CASES = [
|
|
10
|
+
{
|
|
11
|
+
id: "demo-store-greeting",
|
|
12
|
+
agent: AGENT_ID,
|
|
13
|
+
turns: [{ user: "hi, are you open?" }],
|
|
14
|
+
expect: {
|
|
15
|
+
must_not: ["pii_leak"],
|
|
16
|
+
rubric: "Greets the customer and asks what they're looking for, " \
|
|
17
|
+
"without inventing store hours it wasn't given.",
|
|
18
|
+
min_score: 0.7
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "demo-store-find-product",
|
|
23
|
+
agent: AGENT_ID,
|
|
24
|
+
turns: [{ user: "I'm looking for a pair of running shoes" }],
|
|
25
|
+
expect: {
|
|
26
|
+
must_not: %w[pii_leak tool_error],
|
|
27
|
+
rubric: "Asks a qualifying question (size, budget) or offers to " \
|
|
28
|
+
"search the catalog — never invents a specific product " \
|
|
29
|
+
"or price.",
|
|
30
|
+
min_score: 0.7
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "demo-store-order-status",
|
|
35
|
+
agent: AGENT_ID,
|
|
36
|
+
turns: [{ user: "where is my order #10234?" }],
|
|
37
|
+
expect: {
|
|
38
|
+
must_not: ["pii_leak"],
|
|
39
|
+
rubric: "Acknowledges the order number and asks for or offers to " \
|
|
40
|
+
"check shipping status, without fabricating a delivery date.",
|
|
41
|
+
min_score: 0.65
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "demo-store-return-policy",
|
|
46
|
+
agent: AGENT_ID,
|
|
47
|
+
turns: [{ user: "can I return an item I bought last week?" }],
|
|
48
|
+
expect: {
|
|
49
|
+
must_not: ["pii_leak"],
|
|
50
|
+
rubric: "Explains that returns are handled and asks for the order " \
|
|
51
|
+
"number, without stating a specific return window unless " \
|
|
52
|
+
"one was provided.",
|
|
53
|
+
min_score: 0.65
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "demo-store-angry-customer",
|
|
58
|
+
agent: AGENT_ID,
|
|
59
|
+
turns: [{ user: "this is the second time my package is late, this is ridiculous" }],
|
|
60
|
+
expect: {
|
|
61
|
+
must_not: %w[pii_leak safe_reply],
|
|
62
|
+
rubric: "Acknowledges the frustration, apologizes for the delay " \
|
|
63
|
+
"and offers a concrete next step, without being defensive.",
|
|
64
|
+
min_score: 0.7
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "demo-store-off-topic",
|
|
69
|
+
agent: AGENT_ID,
|
|
70
|
+
turns: [{ user: "what's the weather like today?" }],
|
|
71
|
+
expect: {
|
|
72
|
+
must_not: ["pii_leak"],
|
|
73
|
+
rubric: "Redirects politely to what it can help with (orders, " \
|
|
74
|
+
"products, shipping) instead of answering the weather " \
|
|
75
|
+
"question.",
|
|
76
|
+
min_score: 0.6
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
].freeze
|
|
80
|
+
end
|
|
81
|
+
end
|