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.
Files changed (190) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +180 -0
  3. data/README.md +45 -10
  4. data/bin/insika +684 -0
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +94 -403
  7. data/docs/API.md +5 -5
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +14 -14
  12. data/docs/CONTEXT.md +9 -7
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +71 -3
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +128 -3
  17. data/docs/FACTS.md +3 -3
  18. data/docs/HARVEST.md +5 -6
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +2 -2
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +15 -10
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -0
  26. data/docs/REFINEMENT.md +14 -9
  27. data/docs/RELEASING.md +4 -4
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +3 -3
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +22 -6
  32. data/docs/SKILLS.md +11 -2
  33. data/docs/SOAK.md +2 -2
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +152 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +4 -4
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +8 -5
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +73 -16
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/channel_delivery.rb +1 -1
  62. data/lib/insika/chat_builder.rb +22 -2
  63. data/lib/insika/commands/agent_payload.rb +2 -2
  64. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  65. data/lib/insika/commands/delete_artifact.rb +35 -0
  66. data/lib/insika/commands/delete_concept.rb +34 -0
  67. data/lib/insika/commands/delete_mcp.rb +6 -2
  68. data/lib/insika/commands/delete_tenant_data.rb +15 -3
  69. data/lib/insika/commands/gate_refinement.rb +1 -1
  70. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  71. data/lib/insika/commands/restore_concept.rb +34 -0
  72. data/lib/insika/commands/seed_demo_data.rb +31 -0
  73. data/lib/insika/commands/upsert_mcp.rb +6 -3
  74. data/lib/insika/commands/write_concept.rb +57 -0
  75. data/lib/insika/context/priority.rb +2 -0
  76. data/lib/insika/context/providers/knowledge.rb +108 -0
  77. data/lib/insika/context/providers/prompt.rb +30 -24
  78. data/lib/insika/cron.rb +189 -0
  79. data/lib/insika/demo/agent_attrs.rb +43 -0
  80. data/lib/insika/demo/golden_cases.rb +81 -0
  81. data/lib/insika/demo/seeder.rb +336 -0
  82. data/lib/insika/doctor.rb +176 -8
  83. data/lib/insika/dsl/definition.rb +3 -2
  84. data/lib/insika/dsl/runtime.rb +60 -79
  85. data/lib/insika/dsl/server_boot.rb +23 -1
  86. data/lib/insika/dsl/system.rb +10 -2
  87. data/lib/insika/dsl.rb +103 -2
  88. data/lib/insika/env_schema.rb +16 -1
  89. data/lib/insika/evals/golden.rb +41 -4
  90. data/lib/insika/evals/judge.rb +47 -2
  91. data/lib/insika/evals/pairwise.rb +11 -0
  92. data/lib/insika/evals/persona.rb +98 -0
  93. data/lib/insika/evals/runner.rb +9 -0
  94. data/lib/insika/evals/simulator.rb +225 -0
  95. data/lib/insika/evals/transport.rb +83 -1
  96. data/lib/insika/event_stream.rb +10 -0
  97. data/lib/insika/executor.rb +231 -55
  98. data/lib/insika/followup_policy.rb +2 -25
  99. data/lib/insika/golden_store.rb +16 -1
  100. data/lib/insika/grounding/matcher.rb +1 -1
  101. data/lib/insika/knowledge.rb +680 -0
  102. data/lib/insika/knowledge_store.rb +140 -0
  103. data/lib/insika/mcp_client.rb +94 -0
  104. data/lib/insika/mcp_json.rb +74 -0
  105. data/lib/insika/mcp_live_tool.rb +43 -0
  106. data/lib/insika/mcp_store.rb +98 -26
  107. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  108. data/lib/insika/mcp_tool_registry.rb +100 -0
  109. data/lib/insika/media.rb +115 -31
  110. data/lib/insika/message_origin.rb +1 -1
  111. data/lib/insika/middleware.rb +9 -0
  112. data/lib/insika/onboarding.rb +17 -1
  113. data/lib/insika/outcome_store.rb +1 -1
  114. data/lib/insika/overlay_tool_registry.rb +37 -17
  115. data/lib/insika/packaging.rb +2 -2
  116. data/lib/insika/profile_source.rb +8 -1
  117. data/lib/insika/prompt_catalog.rb +10 -0
  118. data/lib/insika/retention.rb +36 -1
  119. data/lib/insika/router/app.rb +157 -0
  120. data/lib/insika/router/backend_pool.rb +98 -0
  121. data/lib/insika/router/hash_ring.rb +55 -0
  122. data/lib/insika/router/proxy_body.rb +34 -0
  123. data/lib/insika/router/session_key.rb +54 -0
  124. data/lib/insika/router.rb +18 -0
  125. data/lib/insika/schedule.rb +177 -0
  126. data/lib/insika/schedule_engine.rb +314 -0
  127. data/lib/insika/schedule_store.rb +208 -0
  128. data/lib/insika/server/app.rb +105 -15
  129. data/lib/insika/server/rack_app.rb +5 -1
  130. data/lib/insika/server/responses.rb +1 -1
  131. data/lib/insika/skill_catalog.rb +12 -0
  132. data/lib/insika/steer_injector.rb +21 -10
  133. data/lib/insika/studio/app.rb +567 -45
  134. data/lib/insika/studio/assets/dist/application.css +1 -1
  135. data/lib/insika/studio/assets/dist/application.js +21 -21
  136. data/lib/insika/studio/forms.rb +46 -5
  137. data/lib/insika/studio/nav_icons.rb +14 -1
  138. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  139. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  140. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  141. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  142. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  143. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  144. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  145. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  146. data/lib/insika/studio/views/_agents_master.erb +44 -0
  147. data/lib/insika/studio/views/_message.erb +49 -32
  148. data/lib/insika/studio/views/agent_detail.erb +61 -820
  149. data/lib/insika/studio/views/agents.erb +70 -57
  150. data/lib/insika/studio/views/artifact.erb +23 -0
  151. data/lib/insika/studio/views/artifacts.erb +59 -0
  152. data/lib/insika/studio/views/evals.erb +2 -2
  153. data/lib/insika/studio/views/facts.erb +1 -1
  154. data/lib/insika/studio/views/funnel.erb +1 -1
  155. data/lib/insika/studio/views/home.erb +106 -67
  156. data/lib/insika/studio/views/knowledge.erb +123 -0
  157. data/lib/insika/studio/views/layout.erb +14 -11
  158. data/lib/insika/studio/views/mcp.erb +174 -80
  159. data/lib/insika/studio/views/session.erb +231 -177
  160. data/lib/insika/studio/views/settings.erb +39 -1
  161. data/lib/insika/studio/views/skills.erb +1 -1
  162. data/lib/insika/studio/views/tools.erb +24 -9
  163. data/lib/insika/templates/browser-agent/README.md +36 -0
  164. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  165. data/lib/insika/templates/daily-digest/README.md +38 -0
  166. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  167. data/lib/insika/templates/repo-explorer/README.md +36 -0
  168. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  169. data/lib/insika/templates/research-analyst/README.md +26 -0
  170. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  171. data/lib/insika/templates/review-panel/README.md +20 -0
  172. data/lib/insika/templates/review-panel/agent.rb +50 -0
  173. data/lib/insika/templates/travel-planner/README.md +35 -0
  174. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  175. data/lib/insika/templates.rb +112 -0
  176. data/lib/insika/tick.rb +24 -12
  177. data/lib/insika/timezone.rb +45 -0
  178. data/lib/insika/tools/generate_image.rb +52 -7
  179. data/lib/insika/tools/load_knowledge.rb +74 -0
  180. data/lib/insika/tools/run_persona_eval.rb +328 -0
  181. data/lib/insika/tools/save_artifact.rb +95 -0
  182. data/lib/insika/turn_output.rb +1 -1
  183. data/lib/insika/turn_state.rb +15 -4
  184. data/lib/insika/version.rb +1 -1
  185. data/lib/insika/wiring/graph.rb +184 -12
  186. data/lib/insika/wiring/graph_chat.rb +102 -0
  187. data/lib/insika.rb +57 -0
  188. metadata +105 -5
  189. data/docs/build.md +0 -14
  190. 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. `profile.prompt_files` (file names)
12
- # wins over the wiring's `files:` — this fixes the limitation of a new
13
- # agent inheriting the previous persona's prompt. The content comes from `agent_files`
14
- # (AgentFileStore, lives in the Store), with a File.read fallback
15
- # for on-disk paths (compat/seed). Without prompt_files -> uses the wiring's
16
- # `files:` (deployment default; byte-for-byte parity).
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: "", files: [], catalog: nil, agent_files: nil, system_files: nil)
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
- unless identity.empty?
49
- fragments << ContextFragment.build(content: identity, placement: :system,
50
- priority: Context::Priority::IDENTITY, source: id, pinned: true)
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 (names) wins over @files (wiring): an agent with
63
- # its own identity does not inherit the deployment's. Each source resolves
64
- # via AgentFileStore (per agent) OR File.read (on-disk path) — in that
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
- sources = Array(profile&.prompt_files)
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
- # agent with no identity at all must stay detectably empty.
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}"
@@ -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